diff --git a/.env.example b/.env.example index a0ef3cc5..f2647f8b 100644 --- a/.env.example +++ b/.env.example @@ -14,6 +14,11 @@ ADMIN_NOTIFICATIONS_ENABLED=true ADMIN_NOTIFICATIONS_CHAT_ID=-1001234567890 # Замени на ID твоего канала (-100) - ПРЕФИКС ЗАКРЫТОГО КАНАЛА! ВСТАВИТЬ СВОЙ ID СРАЗУ ПОСЛЕ (-100) БЕЗ ПРОБЕЛОВ! ADMIN_NOTIFICATIONS_TOPIC_ID=123 # Опционально: ID топика ADMIN_NOTIFICATIONS_TICKET_TOPIC_ID=126 # Опционально: ID топика для тикетов +# Автоматические отчеты +ADMIN_REPORTS_ENABLED=false +ADMIN_REPORTS_CHAT_ID= # Опционально: чат для отчетов (по умолчанию ADMIN_NOTIFICATIONS_CHAT_ID) +ADMIN_REPORTS_TOPIC_ID= # ID топика для отчетов +ADMIN_REPORTS_SEND_TIME=10:00 # Время отправки (по МСК) ежедневного отчета # Обязательная подписка на канал CHANNEL_SUB_ID= # Опционально ID твоего канала (-100) CHANNEL_IS_REQUIRED_SUB=false # Обязательна ли подписка на канал @@ -221,7 +226,7 @@ PAYMENT_BALANCE_TEMPLATE={service_name} - {description} PAYMENT_SUBSCRIPTION_TEMPLATE={service_name} - {description} # CRYPTOBOT -CRYPTOBOT_ENABLED=true +CRYPTOBOT_ENABLED=false CRYPTOBOT_API_TOKEN=123456789:AAzQcZWQqQAbsfgPnOLr4FHC8Doa4L7KryC CRYPTOBOT_WEBHOOK_SECRET=your_webhook_secret_here CRYPTOBOT_BASE_URL=https://pay.crypt.bot @@ -232,6 +237,35 @@ CRYPTOBOT_DEFAULT_ASSET=USDT CRYPTOBOT_ASSETS=USDT,TON,BTC,ETH,LTC,BNB,TRX,USDC CRYPTOBOT_INVOICE_EXPIRES_HOURS=24 +# MULENPAY +MULENPAY_ENABLED=false +MULENPAY_API_KEY= +MULENPAY_SECRET_KEY= +MULENPAY_SHOP_ID= +# необязательно, есть дефолтные значения +MULENPAY_BASE_URL=https://mulenpay.ru/api +MULENPAY_WEBHOOK_PATH=/mulenpay-webhook +MULENPAY_DESCRIPTION="Пополнение баланса" +MULENPAY_LANGUAGE=ru +MULENPAY_VAT_CODE=0 +MULENPAY_PAYMENT_SUBJECT=4 +MULENPAY_PAYMENT_MODE=4 +MULENPAY_MIN_AMOUNT_KOPEKS=10000 +MULENPAY_MAX_AMOUNT_KOPEKS=10000000 + +# PAYPALYCH / PAL24 +PAL24_ENABLED=false +PAL24_API_TOKEN= +PAL24_SHOP_ID= +PAL24_SIGNATURE_TOKEN= +PAL24_BASE_URL=https://pal24.pro/api/v1/ +PAL24_WEBHOOK_PATH=/pal24-webhook +PAL24_WEBHOOK_PORT=8084 +PAL24_PAYMENT_DESCRIPTION="Пополнение баланса" +PAL24_MIN_AMOUNT_KOPEKS=10000 +PAL24_MAX_AMOUNT_KOPEKS=100000000 +PAL24_REQUEST_TIMEOUT=30 + # ===== ИНТЕРФЕЙС И UX ===== # Включить логотип для всех сообщений (true - с изображением, false - только текст) @@ -246,11 +280,21 @@ HIDE_SUBSCRIPTION_LINK=false # miniapp_subscription - открывает ссылку подписки в мини-приложении (режим 2) # miniapp_custom - открывает заданную ссылку в мини-приложении (режим 3) # link - Открывает ссылку напрямую в браузере (режим 4) +# happ_cryptolink - Вывод cryptoLink ссылки на подписку Happ (режим 5) CONNECT_BUTTON_MODE=guide # URL для режима miniapp_custom (обязателен при CONNECT_BUTTON_MODE=miniapp_custom) MINIAPP_CUSTOM_URL= +# Параметры режима happ_cryptolink +CONNECT_BUTTON_HAPP_DOWNLOAD_ENABLED=false +HAPP_DOWNLOAD_LINK_IOS= +HAPP_DOWNLOAD_LINK_ANDROID= +HAPP_DOWNLOAD_LINK_MACOS= +HAPP_DOWNLOAD_LINK_WINDOWS= +# Кнопка (Подключится) с редиректом (тк ссылки с happ:// тг не поддерживает) - Без установленной ссылки на редирект кнопки (подключится) не будет! Пример: https://sub.domain.sub/redirect-page/?redirect_to= +HAPP_CRYPTOLINK_REDIRECT_TEMPLATE= + # Пропустить принятие правил использования бота SKIP_RULES_ACCEPT=false # Пропустить запрос реферального кода @@ -268,9 +312,9 @@ MONITORING_LOGS_RETENTION_DAYS=30 NOTIFICATION_CACHE_HOURS=24 # ===== СТАТУС СЕРВЕРОВ ===== -# Режимы: disabled, external_link, xray +# Режимы: disabled, external_link, external_link_miniapp, xray SERVER_STATUS_MODE=disabled -# Ссылка на внешний мониторинг (для режима external_link) +# Ссылка на внешний мониторинг (для режимов external_link и external_link_miniapp) SERVER_STATUS_EXTERNAL_URL= # URL метрик XrayChecker (для режима xray) SERVER_STATUS_METRICS_URL= @@ -330,4 +374,4 @@ LOG_FILE=logs/bot.log # ===== РАЗРАБОТКА ===== DEBUG=false WEBHOOK_URL= -WEBHOOK_PATH=/webhook \ No newline at end of file +WEBHOOK_PATH=/webhook diff --git a/.github/workflows/docker-hub.yml b/.github/workflows/docker-hub.yml index aa4c23c1..1a6e5e5b 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.3.8-$(git rev-parse --short HEAD)" + VERSION="v2.3.9-$(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.3.8-dev-$(git rev-parse --short HEAD)" + VERSION="v2.3.9-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.3.8-pr-$(git rev-parse --short HEAD)" + VERSION="v2.3.9-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 12796fc9..907ea3fd 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.3.8-$(git rev-parse --short HEAD)" + VERSION="v2.3.9-$(git rev-parse --short HEAD)" echo "🚀 Building main version: $VERSION" elif [[ $GITHUB_REF == refs/heads/dev ]]; then - VERSION="v2.3.8-dev-$(git rev-parse --short HEAD)" + VERSION="v2.3.9-dev-$(git rev-parse --short HEAD)" echo "🧪 Building dev version: $VERSION" else - VERSION="v2.3.8-pr-$(git rev-parse --short HEAD)" + VERSION="v2.3.9-pr-$(git rev-parse --short HEAD)" echo "🔀 Building PR version: $VERSION" fi echo "version=$VERSION" >> $GITHUB_OUTPUT diff --git a/Dockerfile b/Dockerfile index fde6f796..0fb39c32 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.3.8" +ARG VERSION="v2.3.9" ARG BUILD_DATE ARG VCS_REF diff --git a/README.md b/README.md index 3c6f7796..748e2e94 100644 --- a/README.md +++ b/README.md @@ -35,7 +35,7 @@ ### ⚡ **Полная автоматизация VPN бизнеса** - 🎯 **Готовое решение** - разверни за 5 минут, начни продавать сегодня -- 💰 **Многоканальные платежи** - Telegram Stars + Tribute + CryptoBot + ЮKassa + P2P +- 💰 **Многоканальные платежи** - Telegram Stars + Tribute + CryptoBot + ЮKassa + MulenPay + PayPalych + P2P - 🔄 **Автоматизация 99%** - от регистрации до продления подписок - 📊 **Детальная аналитика** - полная картина вашего бизнеса - 💬 **Уведомления в топики** об: Активация триала 💎 Покупка подписки 🔄 Конверсия из триала в платную ⏰ Продление подписки 💰 Пополнение баланса 🚧 Включении тех работ ♻️ Появлении новой версии бота @@ -112,8 +112,8 @@ docker compose logs | Переменная | Описание | Пример | |------------|----------|--------| -| `SERVER_STATUS_MODE` | Режим работы кнопки: `disabled`, `external_link` (просто ссылка) или `xray` (интеграция с XrayChecker). | `xray` | -| `SERVER_STATUS_EXTERNAL_URL` | Прямая ссылка на внешний мониторинг (используется в режиме `external_link`). | `https://status.example.com` | +| `SERVER_STATUS_MODE` | Режим работы кнопки: `disabled`, `external_link` (открывает ссылку в браузере), `external_link_miniapp` (открывает ссылку во встроенном мини-приложении Telegram) или `xray` (интеграция с XrayChecker). | `xray` | +| `SERVER_STATUS_EXTERNAL_URL` | Прямая ссылка на внешний мониторинг (используется в режимах `external_link` и `external_link_miniapp`). | `https://status.example.com` | | `SERVER_STATUS_METRICS_URL` | URL страницы метрик XrayChecker (Prometheus формат). | `https://sub.example.com/metrics` | | `SERVER_STATUS_METRICS_USERNAME` / `SERVER_STATUS_METRICS_PASSWORD` | Данные Basic Auth, если страница метрик защищена паролем. | `status` / `secret` | | `SERVER_STATUS_ITEMS_PER_PAGE` | Количество серверов, показываемых на одной странице в режиме интеграции. | `10` | @@ -248,11 +248,17 @@ ADMIN_IDS= # Ссылка на поддержку: Telegram username (например, @support) или полный URL SUPPORT_USERNAME=@support + # Уведомления администраторов ADMIN_NOTIFICATIONS_ENABLED=true ADMIN_NOTIFICATIONS_CHAT_ID=-1001234567890 # Замени на ID твоего канала (-100) - ПРЕФИКС ЗАКРЫТОГО КАНАЛА! ВСТАВИТЬ СВОЙ ID СРАЗУ ПОСЛЕ (-100) БЕЗ ПРОБЕЛОВ! ADMIN_NOTIFICATIONS_TOPIC_ID=123 # Опционально: ID топика - +ADMIN_NOTIFICATIONS_TICKET_TOPIC_ID=126 # Опционально: ID топика для тикетов +# Автоматические отчеты +ADMIN_REPORTS_ENABLED=false +ADMIN_REPORTS_CHAT_ID= # Опционально: чат для отчетов (по умолчанию ADMIN_NOTIFICATIONS_CHAT_ID) +ADMIN_REPORTS_TOPIC_ID= # ID топика для отчетов +ADMIN_REPORTS_SEND_TIME=10:00 # Время отправки (по МСК) ежедневного отчета # Обязательная подписка на канал CHANNEL_SUB_ID= # Опционально ID твоего канала (-100) CHANNEL_IS_REQUIRED_SUB=false # Обязательна ли подписка на канал @@ -274,6 +280,7 @@ POSTGRES_PASSWORD=secure_password_123 # SQLite настройки (для локального запуска) SQLITE_PATH=./data/bot.db +LOCALES_PATH=./locales # Redis REDIS_URL=redis://redis:6379/0 @@ -300,6 +307,11 @@ REMNAWAVE_SECRET_KEY= # {telegram_id} — ID Telegram REMNAWAVE_USER_DESCRIPTION_TEMPLATE="Bot user: {full_name} {username}" +# Режим удаления пользователей из панели RemnaWave +# delete - полностью удалить пользователя из панели +# disable - только деактивировать пользователя +REMNAWAVE_USER_DELETE_MODE=delete + # ========= ПОДПИСКИ ========= # ===== ТРИАЛ ПОДПИСКА ===== TRIAL_DURATION_DAYS=3 @@ -437,6 +449,13 @@ YOOKASSA_WEBHOOK_PATH=/yookassa-webhook YOOKASSA_WEBHOOK_PORT=8082 YOOKASSA_WEBHOOK_SECRET=your_webhook_secret +# Лимиты сумм пополнения через YooKassa (в копейках) +YOOKASSA_MIN_AMOUNT_KOPEKS=5000 +YOOKASSA_MAX_AMOUNT_KOPEKS=1000000 + +# Быстрый выбор суммы пополнения через YooKassa +YOOKASSA_QUICK_AMOUNT_SELECTION_ENABLED=true + # ===== НАСТРОЙКИ ОПИСАНИЙ ПЛАТЕЖЕЙ ===== # Эти настройки позволяют изменить описания платежей, # чтобы избежать блокировок платежных систем @@ -447,7 +466,7 @@ PAYMENT_BALANCE_TEMPLATE={service_name} - {description} PAYMENT_SUBSCRIPTION_TEMPLATE={service_name} - {description} # CRYPTOBOT -CRYPTOBOT_ENABLED=true +CRYPTOBOT_ENABLED=false CRYPTOBOT_API_TOKEN=123456789:AAzQcZWQqQAbsfgPnOLr4FHC8Doa4L7KryC CRYPTOBOT_WEBHOOK_SECRET=your_webhook_secret_here CRYPTOBOT_BASE_URL=https://pay.crypt.bot @@ -458,6 +477,35 @@ CRYPTOBOT_DEFAULT_ASSET=USDT CRYPTOBOT_ASSETS=USDT,TON,BTC,ETH,LTC,BNB,TRX,USDC CRYPTOBOT_INVOICE_EXPIRES_HOURS=24 +# MULENPAY +MULENPAY_ENABLED=false +MULENPAY_API_KEY= +MULENPAY_SECRET_KEY= +MULENPAY_SHOP_ID= +# необязательно, есть дефолтные значения +MULENPAY_BASE_URL=https://mulenpay.ru/api +MULENPAY_WEBHOOK_PATH=/mulenpay-webhook +MULENPAY_DESCRIPTION="Пополнение баланса" +MULENPAY_LANGUAGE=ru +MULENPAY_VAT_CODE=0 +MULENPAY_PAYMENT_SUBJECT=4 +MULENPAY_PAYMENT_MODE=4 +MULENPAY_MIN_AMOUNT_KOPEKS=10000 +MULENPAY_MAX_AMOUNT_KOPEKS=10000000 + +# PAYPALYCH / PAL24 +PAL24_ENABLED=false +PAL24_API_TOKEN= +PAL24_SHOP_ID= +PAL24_SIGNATURE_TOKEN= +PAL24_BASE_URL=https://pal24.pro/api/v1/ +PAL24_WEBHOOK_PATH=/pal24-webhook +PAL24_WEBHOOK_PORT=8084 +PAL24_PAYMENT_DESCRIPTION="Пополнение баланса" +PAL24_MIN_AMOUNT_KOPEKS=10000 +PAL24_MAX_AMOUNT_KOPEKS=100000000 +PAL24_REQUEST_TIMEOUT=30 + # ===== ИНТЕРФЕЙС И UX ===== # Включить логотип для всех сообщений (true - с изображением, false - только текст) @@ -472,11 +520,21 @@ HIDE_SUBSCRIPTION_LINK=false # miniapp_subscription - открывает ссылку подписки в мини-приложении (режим 2) # miniapp_custom - открывает заданную ссылку в мини-приложении (режим 3) # link - Открывает ссылку напрямую в браузере (режим 4) +# happ_cryptolink - Вывод cryptoLink ссылки на подписку Happ (режим 5) CONNECT_BUTTON_MODE=guide # URL для режима miniapp_custom (обязателен при CONNECT_BUTTON_MODE=miniapp_custom) MINIAPP_CUSTOM_URL= +# Параметры режима happ_cryptolink +CONNECT_BUTTON_HAPP_DOWNLOAD_ENABLED=false +HAPP_DOWNLOAD_LINK_IOS= +HAPP_DOWNLOAD_LINK_ANDROID= +HAPP_DOWNLOAD_LINK_MACOS= +HAPP_DOWNLOAD_LINK_WINDOWS= +# Кнопка (Подключится) с редиректом (тк ссылки с happ:// тг не поддерживает) - Без установленной ссылки на редирект кнопки (подключится) не будет! Пример: https://sub.domain.sub/redirect-page/?redirect_to= +HAPP_CRYPTOLINK_REDIRECT_TEMPLATE= + # Пропустить принятие правил использования бота SKIP_RULES_ACCEPT=false # Пропустить запрос реферального кода @@ -493,6 +551,23 @@ NOTIFICATION_RETRY_ATTEMPTS=3 MONITORING_LOGS_RETENTION_DAYS=30 NOTIFICATION_CACHE_HOURS=24 +# ===== СТАТУС СЕРВЕРОВ ===== +# Режимы: disabled, external_link, external_link_miniapp, xray +SERVER_STATUS_MODE=disabled +# Ссылка на внешний мониторинг (для режимов external_link и external_link_miniapp) +SERVER_STATUS_EXTERNAL_URL= +# URL метрик XrayChecker (для режима xray) +SERVER_STATUS_METRICS_URL= +# Данные Basic Auth (опционально) +SERVER_STATUS_METRICS_USERNAME= +SERVER_STATUS_METRICS_PASSWORD= +# Проверять SSL сертификат при запросе метрик +SERVER_STATUS_METRICS_VERIFY_SSL=true +# Таймаут запроса к метрикам (в секундах) +SERVER_STATUS_REQUEST_TIMEOUT=10 +# Количество серверов на странице в режиме интеграции +SERVER_STATUS_ITEMS_PER_PAGE=10 + # ===== РЕЖИМ ТЕХНИЧЕСКИХ РАБОТ ===== MAINTENANCE_MODE=false MAINTENANCE_CHECK_INTERVAL=30 @@ -540,8 +615,6 @@ LOG_FILE=logs/bot.log DEBUG=false WEBHOOK_URL= WEBHOOK_PATH=/webhook - - ``` @@ -573,6 +646,8 @@ WEBHOOK_PATH=/webhook - ⭐ Telegram Stars - 💳 Tribute - 💳 YooKassa (включая СБП и онлайн-чек) +- 💳 MulenPay +- 💳 PayPalych (Pal24) - 💰 CryptoBot (мультивалюта и срок жизни инвойсов) - 🎁 Реферальные и промо-бонусы - Детальная история транзакций и чеков @@ -598,7 +673,7 @@ WEBHOOK_PATH=/webhook 📊 **Мощная аналитика** - 👥 Детальная статистика пользователей и подписок -- 💰 Анализ платежей по источникам (Stars, YooKassa, Tribute, CryptoBot) +- 💰 Анализ платежей по источникам (Stars, YooKassa, Tribute, MulenPay, PayPalych, CryptoBot) - 🖥️ Мониторинг серверов Remnawave и статуса сквадов - 📈 Финансовые отчеты, конверсии и эффективность рекламных кампаний @@ -891,6 +966,7 @@ docker compose down -v --remove-orphans - **Telegram Stars**: Работает автоматически - **Tribute**: Настрой webhook на `https://your-domain.com/tribute-webhook` - **YooKassa**: Настрой webhook на `https://your-domain.com/yookassa-webhook` + - **PayPalych**: Укажи Result URL `https://your-domain.com/pal24-webhook` в кабинете Pal24 ### 🛠️ Настройка Уведомлений в топик группы @@ -1103,7 +1179,7 @@ docker system prune |----------|-------------|---------| | **Бот не отвечает** | `docker logs remnawave_bot` | Проверь `BOT_TOKEN` и интернет | | **Ошибки БД** | `docker compose ps postgres` | Проверь статус PostgreSQL | -| **Webhook не работает** | Проверь порты 8081/8082 | Настрой прокси-сервер правильно | +| **Webhook не работает** | Проверь порты 8081/8082/8084 | Настрой прокси-сервер правильно | | **API недоступен** | Проверь логи бота | Проверь `REMNAWAVE_API_URL` и ключ | | **Мониторинг не работает** | Админ панель → Мониторинг | Проверь `MAINTENANCE_AUTO_ENABLE` | | **Платежи не проходят** | Проверь webhook'и | Настрой URL в платежных системах | @@ -1124,20 +1200,41 @@ server { proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; } - + # CryptoBot webhook endpoint - handle /cryptobot-webhook* { - reverse_proxy localhost:8081 { - header_up Host {host} - header_up X-Real-IP {remote_host} - } + location /cryptobot-webhook { + proxy_pass http://127.0.0.1:8081; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; } + # MulenPay webhook endpoint + location /mulenpay-webhook { + proxy_pass http://127.0.0.1:8081; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + # PayPalych webhook endpoint + location /pal24-webhook { + proxy_pass http://127.0.0.1:8084; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + # Для YooKassa location /yookassa-webhook { proxy_pass http://127.0.0.1:8082; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; } # Health check @@ -1153,21 +1250,48 @@ your-domain.com { handle /tribute-webhook* { reverse_proxy localhost:8081 } - + handle /cryptobot-webhook* { reverse_proxy localhost:8081 } + handle /mulenpay-webhook* { + reverse_proxy localhost:8081 + } + + handle /pal24-webhook* { + reverse_proxy localhost:8084 + } + handle /yookassa-webhook* { reverse_proxy localhost:8082 } - + handle /health { reverse_proxy localhost:8081/health } } ``` +#### 🧪 Проверка PayPalych postback + +```bash +# Генерируем подпись: md5("100.00:test-order-1:${PAL24_SIGNATURE_TOKEN}") +SIGNATURE=$(python - <<'PY' +import hashlib, os +token = os.environ.get('PAL24_SIGNATURE_TOKEN', 'test_token') +payload = f"100.00:test-order-1:{token}".encode() +print(hashlib.md5(payload).hexdigest().upper()) +PY +) + +curl -X POST https://your-domain.com/pal24-webhook \ + -H "Content-Type: application/json" \ + -d '{"InvId": "test-order-1", "OutSum": "100.00", "Status": "SUCCESS", "SignatureValue": "'$SIGNATURE'"}' +``` + +Ответ `{"status": "ok"}` подтверждает корректную обработку вебхука. + --- ## 💡 Использование diff --git a/app/bot.py b/app/bot.py index c23bf06d..a13e2237 100644 --- a/app/bot.py +++ b/app/bot.py @@ -38,6 +38,7 @@ from app.handlers.admin import ( backup as admin_backup, welcome_text as admin_welcome_text, tickets as admin_tickets, + reports as admin_reports, ) from app.handlers.stars_payments import register_stars_handlers @@ -139,6 +140,7 @@ async def setup_bot() -> tuple[Bot, Dispatcher]: admin_backup.register_handlers(dp) admin_welcome_text.register_welcome_text_handlers(dp) admin_tickets.register_handlers(dp) + admin_reports.register_handlers(dp) common.register_handlers(dp) register_stars_handlers(dp) logger.info("⭐ Зарегистрированы обработчики Telegram Stars платежей") diff --git a/app/config.py b/app/config.py index 07ab145c..9de0a3a0 100644 --- a/app/config.py +++ b/app/config.py @@ -1,7 +1,9 @@ +import logging import os import re import html from collections import defaultdict +from datetime import time from typing import List, Optional, Union, Dict from pydantic_settings import BaseSettings from pydantic import field_validator, Field @@ -16,12 +18,22 @@ class Settings(BaseSettings): SUPPORT_MENU_ENABLED: bool = True SUPPORT_SYSTEM_MODE: str = "both" # one of: tickets, contact, both SUPPORT_MENU_ENABLED: bool = True + # SLA for support tickets + SUPPORT_TICKET_SLA_ENABLED: bool = True + SUPPORT_TICKET_SLA_MINUTES: int = 5 + SUPPORT_TICKET_SLA_CHECK_INTERVAL_SECONDS: int = 60 + SUPPORT_TICKET_SLA_REMINDER_COOLDOWN_MINUTES: int = 15 ADMIN_NOTIFICATIONS_ENABLED: bool = False ADMIN_NOTIFICATIONS_CHAT_ID: Optional[str] = None ADMIN_NOTIFICATIONS_TOPIC_ID: Optional[int] = None ADMIN_NOTIFICATIONS_TICKET_TOPIC_ID: Optional[int] = None + ADMIN_REPORTS_ENABLED: bool = False + ADMIN_REPORTS_CHAT_ID: Optional[str] = None + ADMIN_REPORTS_TOPIC_ID: Optional[int] = None + ADMIN_REPORTS_SEND_TIME: Optional[str] = None + CHANNEL_SUB_ID: Optional[str] = None CHANNEL_LINK: Optional[str] = None CHANNEL_IS_REQUIRED_SUB: bool = False @@ -169,8 +181,41 @@ class Settings(BaseSettings): CRYPTOBOT_ASSETS: str = "USDT,TON,BTC,ETH" CRYPTOBOT_INVOICE_EXPIRES_HOURS: int = 24 + MULENPAY_ENABLED: bool = False + MULENPAY_API_KEY: Optional[str] = None + MULENPAY_SECRET_KEY: Optional[str] = None + MULENPAY_SHOP_ID: Optional[int] = None + MULENPAY_BASE_URL: str = "https://mulenpay.ru/api" + MULENPAY_WEBHOOK_PATH: str = "/mulenpay-webhook" + MULENPAY_DESCRIPTION: str = "Пополнение баланса" + MULENPAY_LANGUAGE: str = "ru" + MULENPAY_VAT_CODE: int = 0 + MULENPAY_PAYMENT_SUBJECT: int = 4 + MULENPAY_PAYMENT_MODE: int = 4 + MULENPAY_MIN_AMOUNT_KOPEKS: int = 10000 + MULENPAY_MAX_AMOUNT_KOPEKS: int = 10000000 + + PAL24_ENABLED: bool = False + PAL24_API_TOKEN: Optional[str] = None + PAL24_SHOP_ID: Optional[str] = None + PAL24_SIGNATURE_TOKEN: Optional[str] = None + PAL24_BASE_URL: str = "https://pal24.pro/api/v1/" + PAL24_WEBHOOK_PATH: str = "/pal24-webhook" + PAL24_WEBHOOK_PORT: int = 8084 + PAL24_PAYMENT_DESCRIPTION: str = "Пополнение баланса" + PAL24_MIN_AMOUNT_KOPEKS: int = 10000 + PAL24_MAX_AMOUNT_KOPEKS: int = 100000000 + PAL24_REQUEST_TIMEOUT: int = 30 + CONNECT_BUTTON_MODE: str = "guide" MINIAPP_CUSTOM_URL: str = "" + CONNECT_BUTTON_HAPP_DOWNLOAD_ENABLED: bool = False + HAPP_CRYPTOLINK_REDIRECT_TEMPLATE: Optional[str] = None + HAPP_DOWNLOAD_LINK_IOS: Optional[str] = None + HAPP_DOWNLOAD_LINK_ANDROID: Optional[str] = None + HAPP_DOWNLOAD_LINK_MACOS: Optional[str] = None + HAPP_DOWNLOAD_LINK_WINDOWS: Optional[str] = None + HAPP_DOWNLOAD_LINK_PC: Optional[str] = None HIDE_SUBSCRIPTION_LINK: bool = False ENABLE_LOGO_MODE: bool = True LOGO_FILE: str = "vpn_logo.png" @@ -221,6 +266,13 @@ class Settings(BaseSettings): "link": "external_link", "url": "external_link", "external_link": "external_link", + "miniapp": "external_link_miniapp", + "mini_app": "external_link_miniapp", + "mini-app": "external_link_miniapp", + "webapp": "external_link_miniapp", + "web_app": "external_link_miniapp", + "web-app": "external_link_miniapp", + "external_link_miniapp": "external_link_miniapp", "xray": "xray", "xraychecker": "xray", "xray_metrics": "xray", @@ -228,8 +280,10 @@ class Settings(BaseSettings): } mode = aliases.get(normalized, normalized) - if mode not in {"disabled", "external_link", "xray"}: - raise ValueError("SERVER_STATUS_MODE must be one of: disabled, external_link, xray") + if mode not in {"disabled", "external_link", "external_link_miniapp", "xray"}: + raise ValueError( + "SERVER_STATUS_MODE must be one of: disabled, external_link, external_link_miniapp, xray" + ) return mode @field_validator('SERVER_STATUS_ITEMS_PER_PAGE', mode='before') @@ -387,6 +441,32 @@ class Settings(BaseSettings): def format_price(self, price_kopeks: int) -> str: rubles = price_kopeks // 100 return f"{rubles} ₽" + + def get_reports_chat_id(self) -> Optional[str]: + if self.ADMIN_REPORTS_CHAT_ID: + return self.ADMIN_REPORTS_CHAT_ID + return self.ADMIN_NOTIFICATIONS_CHAT_ID + + def get_reports_topic_id(self) -> Optional[int]: + return self.ADMIN_REPORTS_TOPIC_ID or None + + def get_reports_send_time(self) -> Optional[time]: + value = self.ADMIN_REPORTS_SEND_TIME + if not value: + return None + + try: + hours_str, minutes_str = value.strip().split(":", 1) + hours = int(hours_str) + minutes = int(minutes_str) + if not (0 <= hours <= 23 and 0 <= minutes <= 59): + raise ValueError + return time(hour=hours, minute=minutes) + except (ValueError, AttributeError): + logging.getLogger(__name__).warning( + "Некорректное значение ADMIN_REPORTS_SEND_TIME: %s", value + ) + return None def kopeks_to_rubles(self, kopeks: int) -> float: return kopeks / 100 @@ -435,9 +515,24 @@ class Settings(BaseSettings): return "https://t.me/" def is_cryptobot_enabled(self) -> bool: - return (self.CRYPTOBOT_ENABLED and + return (self.CRYPTOBOT_ENABLED and self.CRYPTOBOT_API_TOKEN is not None) - + + def is_mulenpay_enabled(self) -> bool: + return ( + self.MULENPAY_ENABLED + and self.MULENPAY_API_KEY is not None + and self.MULENPAY_SECRET_KEY is not None + and self.MULENPAY_SHOP_ID is not None + ) + + def is_pal24_enabled(self) -> bool: + return ( + self.PAL24_ENABLED + and self.PAL24_API_TOKEN is not None + and self.PAL24_SHOP_ID is not None + ) + def get_cryptobot_base_url(self) -> str: if self.CRYPTOBOT_TESTNET: return "https://testnet-pay.crypt.bot" @@ -455,6 +550,34 @@ class Settings(BaseSettings): def get_cryptobot_invoice_expires_seconds(self) -> int: return self.CRYPTOBOT_INVOICE_EXPIRES_HOURS * 3600 + def is_happ_cryptolink_mode(self) -> bool: + return self.CONNECT_BUTTON_MODE == "happ_cryptolink" + + def is_happ_download_button_enabled(self) -> bool: + return self.is_happ_cryptolink_mode() and self.CONNECT_BUTTON_HAPP_DOWNLOAD_ENABLED + + def get_happ_cryptolink_redirect_template(self) -> Optional[str]: + template = (self.HAPP_CRYPTOLINK_REDIRECT_TEMPLATE or "").strip() + return template or None + + def get_happ_download_link(self, platform: str) -> Optional[str]: + platform_key = platform.lower() + + if platform_key == "pc": + platform_key = "windows" + + links = { + "ios": (self.HAPP_DOWNLOAD_LINK_IOS or "").strip(), + "android": (self.HAPP_DOWNLOAD_LINK_ANDROID or "").strip(), + "macos": (self.HAPP_DOWNLOAD_LINK_MACOS or "").strip(), + "windows": ( + (self.HAPP_DOWNLOAD_LINK_WINDOWS or "").strip() + or (self.HAPP_DOWNLOAD_LINK_PC or "").strip() + ), + } + link = links.get(platform_key) + return link if link else None + def is_maintenance_mode(self) -> bool: return self.MAINTENANCE_MODE @@ -841,32 +964,6 @@ class Settings(BaseSettings): def is_support_contact_enabled(self) -> bool: return self.get_support_system_mode() in {"contact", "both"} - - enabled_packages = [pkg for pkg in packages if pkg["enabled"]] - if not enabled_packages: - return 0 - - unlimited_package = next((pkg for pkg in enabled_packages if pkg["gb"] == 0), None) - - finite_packages = [pkg for pkg in enabled_packages if pkg["gb"] > 0] - if finite_packages: - max_package = max(finite_packages, key=lambda x: x["gb"]) - - if gb > max_package["gb"]: - if unlimited_package: - return unlimited_package["price"] - else: - return max_package["price"] - - suitable_packages = [pkg for pkg in finite_packages if pkg["gb"] >= gb] - if suitable_packages: - nearest_package = min(suitable_packages, key=lambda x: x["gb"]) - return nearest_package["price"] - - if unlimited_package: - return unlimited_package["price"] - - return 0 model_config = { "env_file": ".env", diff --git a/app/database/crud/discount_offer.py b/app/database/crud/discount_offer.py new file mode 100644 index 00000000..eaa789ae --- /dev/null +++ b/app/database/crud/discount_offer.py @@ -0,0 +1,90 @@ +from datetime import datetime, timedelta +from typing import Optional + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.database.models import DiscountOffer + + +async def upsert_discount_offer( + db: AsyncSession, + *, + user_id: int, + subscription_id: Optional[int], + notification_type: str, + discount_percent: int, + bonus_amount_kopeks: int, + valid_hours: int, +) -> DiscountOffer: + """Create or refresh a discount offer for a user.""" + + expires_at = datetime.utcnow() + timedelta(hours=valid_hours) + + result = await db.execute( + select(DiscountOffer) + .where( + DiscountOffer.user_id == user_id, + DiscountOffer.notification_type == notification_type, + DiscountOffer.is_active == True, # noqa: E712 + ) + .order_by(DiscountOffer.created_at.desc()) + ) + offer = result.scalars().first() + + if offer and offer.claimed_at is None: + offer.discount_percent = discount_percent + offer.bonus_amount_kopeks = bonus_amount_kopeks + offer.expires_at = expires_at + offer.subscription_id = subscription_id + else: + offer = DiscountOffer( + user_id=user_id, + subscription_id=subscription_id, + notification_type=notification_type, + discount_percent=discount_percent, + bonus_amount_kopeks=bonus_amount_kopeks, + expires_at=expires_at, + is_active=True, + ) + db.add(offer) + + await db.commit() + await db.refresh(offer) + return offer + + +async def get_offer_by_id(db: AsyncSession, offer_id: int) -> Optional[DiscountOffer]: + result = await db.execute( + select(DiscountOffer).where(DiscountOffer.id == offer_id) + ) + return result.scalar_one_or_none() + + +async def mark_offer_claimed(db: AsyncSession, offer: DiscountOffer) -> DiscountOffer: + offer.claimed_at = datetime.utcnow() + offer.is_active = False + await db.commit() + await db.refresh(offer) + return offer + + +async def deactivate_expired_offers(db: AsyncSession) -> int: + now = datetime.utcnow() + result = await db.execute( + select(DiscountOffer).where( + DiscountOffer.is_active == True, # noqa: E712 + DiscountOffer.expires_at < now, + ) + ) + offers = result.scalars().all() + if not offers: + return 0 + + count = 0 + for offer in offers: + offer.is_active = False + count += 1 + + await db.commit() + return count diff --git a/app/database/crud/mulenpay.py b/app/database/crud/mulenpay.py new file mode 100644 index 00000000..245849da --- /dev/null +++ b/app/database/crud/mulenpay.py @@ -0,0 +1,118 @@ +import logging +from datetime import datetime +from typing import Optional + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.database.models import MulenPayPayment + +logger = logging.getLogger(__name__) + + +async def create_mulenpay_payment( + db: AsyncSession, + *, + user_id: int, + amount_kopeks: int, + uuid: str, + description: str, + payment_url: Optional[str], + mulen_payment_id: Optional[int], + currency: str, + status: str, + metadata: Optional[dict] = None, +) -> MulenPayPayment: + payment = MulenPayPayment( + user_id=user_id, + amount_kopeks=amount_kopeks, + uuid=uuid, + description=description, + payment_url=payment_url, + mulen_payment_id=mulen_payment_id, + currency=currency, + status=status, + metadata_json=metadata or {}, + ) + + db.add(payment) + await db.commit() + await db.refresh(payment) + + logger.info( + "Создан MulenPay платеж #%s (uuid=%s) на сумму %s копеек для пользователя %s", + payment.mulen_payment_id, + uuid, + amount_kopeks, + user_id, + ) + + return payment + + +async def get_mulenpay_payment_by_local_id( + db: AsyncSession, payment_id: int +) -> Optional[MulenPayPayment]: + result = await db.execute( + select(MulenPayPayment).where(MulenPayPayment.id == payment_id) + ) + return result.scalar_one_or_none() + + +async def get_mulenpay_payment_by_uuid( + db: AsyncSession, uuid: str +) -> Optional[MulenPayPayment]: + result = await db.execute( + select(MulenPayPayment).where(MulenPayPayment.uuid == uuid) + ) + return result.scalar_one_or_none() + + +async def get_mulenpay_payment_by_mulen_id( + db: AsyncSession, mulen_payment_id: int +) -> Optional[MulenPayPayment]: + result = await db.execute( + select(MulenPayPayment).where( + MulenPayPayment.mulen_payment_id == mulen_payment_id + ) + ) + return result.scalar_one_or_none() + + +async def update_mulenpay_payment_status( + db: AsyncSession, + *, + payment: MulenPayPayment, + status: str, + is_paid: Optional[bool] = None, + paid_at: Optional[datetime] = None, + callback_payload: Optional[dict] = None, + mulen_payment_id: Optional[int] = None, +) -> MulenPayPayment: + payment.status = status + if is_paid is not None: + payment.is_paid = is_paid + if paid_at: + payment.paid_at = paid_at + if callback_payload is not None: + payment.callback_payload = callback_payload + if mulen_payment_id is not None and not payment.mulen_payment_id: + payment.mulen_payment_id = mulen_payment_id + + payment.updated_at = datetime.utcnow() + await db.commit() + await db.refresh(payment) + return payment + + +async def link_mulenpay_payment_to_transaction( + db: AsyncSession, + *, + payment: MulenPayPayment, + transaction_id: int, +) -> MulenPayPayment: + payment.transaction_id = transaction_id + payment.updated_at = datetime.utcnow() + await db.commit() + await db.refresh(payment) + return payment diff --git a/app/database/crud/pal24.py b/app/database/crud/pal24.py new file mode 100644 index 00000000..42a03015 --- /dev/null +++ b/app/database/crud/pal24.py @@ -0,0 +1,160 @@ +"""CRUD helpers for PayPalych (Pal24) payments.""" + +from __future__ import annotations + +import logging +from typing import Any, Dict, Optional + +from sqlalchemy import select, update +from sqlalchemy.ext.asyncio import AsyncSession + +from app.database.models import Pal24Payment + +logger = logging.getLogger(__name__) + + +async def create_pal24_payment( + db: AsyncSession, + *, + user_id: int, + bill_id: str, + amount_kopeks: int, + description: Optional[str], + status: str, + type_: str, + currency: str, + link_url: Optional[str], + link_page_url: Optional[str], + order_id: Optional[str] = None, + ttl: Optional[int] = None, + metadata: Optional[Dict[str, Any]] = None, +) -> Pal24Payment: + payment = Pal24Payment( + user_id=user_id, + bill_id=bill_id, + order_id=order_id, + amount_kopeks=amount_kopeks, + currency=currency, + description=description, + status=status, + type=type_, + link_url=link_url, + link_page_url=link_page_url, + metadata_json=metadata or {}, + ttl=ttl, + ) + + db.add(payment) + await db.commit() + await db.refresh(payment) + + logger.info( + "Создан Pal24 платеж #%s для пользователя %s: %s копеек (статус %s)", + payment.id, + user_id, + amount_kopeks, + status, + ) + + return payment + + +async def get_pal24_payment_by_id(db: AsyncSession, payment_id: int) -> Optional[Pal24Payment]: + result = await db.execute( + select(Pal24Payment).where(Pal24Payment.id == payment_id) + ) + return result.scalar_one_or_none() + + +async def get_pal24_payment_by_bill_id(db: AsyncSession, bill_id: str) -> Optional[Pal24Payment]: + result = await db.execute( + select(Pal24Payment).where(Pal24Payment.bill_id == bill_id) + ) + return result.scalar_one_or_none() + + +async def get_pal24_payment_by_order_id(db: AsyncSession, order_id: str) -> Optional[Pal24Payment]: + result = await db.execute( + select(Pal24Payment).where(Pal24Payment.order_id == order_id) + ) + return result.scalar_one_or_none() + + +async def update_pal24_payment_status( + db: AsyncSession, + payment: Pal24Payment, + *, + status: str, + is_active: Optional[bool] = None, + is_paid: Optional[bool] = None, + payment_id: Optional[str] = None, + payment_status: Optional[str] = None, + payment_method: Optional[str] = None, + balance_amount: Optional[str] = None, + balance_currency: Optional[str] = None, + payer_account: Optional[str] = None, + callback_payload: Optional[Dict[str, Any]] = None, +) -> Pal24Payment: + update_values: Dict[str, Any] = { + "status": status, + } + + if is_active is not None: + update_values["is_active"] = is_active + if is_paid is not None: + update_values["is_paid"] = is_paid + if payment_id is not None: + update_values["payment_id"] = payment_id + if payment_status is not None: + update_values["payment_status"] = payment_status + if payment_method is not None: + update_values["payment_method"] = payment_method + if balance_amount is not None: + update_values["balance_amount"] = balance_amount + if balance_currency is not None: + update_values["balance_currency"] = balance_currency + if payer_account is not None: + update_values["payer_account"] = payer_account + if callback_payload is not None: + update_values["callback_payload"] = callback_payload + + update_values["last_status"] = status + + await db.execute( + update(Pal24Payment) + .where(Pal24Payment.id == payment.id) + .values(**update_values) + ) + + await db.commit() + await db.refresh(payment) + + logger.info( + "Обновлен Pal24 платеж %s: статус=%s, is_paid=%s", + payment.bill_id, + payment.status, + payment.is_paid, + ) + + return payment + + +async def link_pal24_payment_to_transaction( + db: AsyncSession, + payment: Pal24Payment, + transaction_id: int, +) -> Pal24Payment: + await db.execute( + update(Pal24Payment) + .where(Pal24Payment.id == payment.id) + .values(transaction_id=transaction_id) + ) + await db.commit() + await db.refresh(payment) + logger.info( + "Pal24 платеж %s привязан к транзакции %s", + payment.bill_id, + transaction_id, + ) + return payment + diff --git a/app/database/crud/promo_group.py b/app/database/crud/promo_group.py index 3845f531..9296dd48 100644 --- a/app/database/crud/promo_group.py +++ b/app/database/crud/promo_group.py @@ -1,5 +1,5 @@ import logging -from typing import List, Optional, Tuple +from typing import Dict, List, Optional, Tuple from sqlalchemy import func, select, update from sqlalchemy.ext.asyncio import AsyncSession @@ -7,6 +7,24 @@ from sqlalchemy.orm import selectinload from app.database.models import PromoGroup, User + +def _normalize_period_discounts(period_discounts: Optional[Dict[int, int]]) -> Dict[int, int]: + if not period_discounts: + return {} + + normalized: Dict[int, int] = {} + + for key, value in period_discounts.items(): + try: + period = int(key) + percent = int(value) + except (TypeError, ValueError): + continue + + normalized[period] = max(0, min(100, percent)) + + return normalized + logger = logging.getLogger(__name__) @@ -40,12 +58,26 @@ async def create_promo_group( server_discount_percent: int, traffic_discount_percent: int, device_discount_percent: int, + period_discounts: Optional[Dict[int, int]] = None, + auto_assign_total_spent_kopeks: Optional[int] = None, + apply_discounts_to_addons: bool = True, ) -> PromoGroup: + normalized_period_discounts = _normalize_period_discounts(period_discounts) + + auto_assign_total_spent_kopeks = ( + max(0, auto_assign_total_spent_kopeks) + if auto_assign_total_spent_kopeks is not None + else None + ) + promo_group = PromoGroup( name=name.strip(), server_discount_percent=max(0, min(100, server_discount_percent)), traffic_discount_percent=max(0, min(100, traffic_discount_percent)), device_discount_percent=max(0, min(100, device_discount_percent)), + period_discounts=normalized_period_discounts or None, + auto_assign_total_spent_kopeks=auto_assign_total_spent_kopeks, + apply_discounts_to_addons=bool(apply_discounts_to_addons), is_default=False, ) @@ -54,11 +86,14 @@ async def create_promo_group( await db.refresh(promo_group) logger.info( - "Создана промогруппа '%s' с скидками (servers=%s%%, traffic=%s%%, devices=%s%%)", + "Создана промогруппа '%s' с скидками (servers=%s%%, traffic=%s%%, devices=%s%%, periods=%s) и порогом автоприсвоения %s₽, скидки на доп. услуги: %s", promo_group.name, promo_group.server_discount_percent, promo_group.traffic_discount_percent, promo_group.device_discount_percent, + normalized_period_discounts, + (auto_assign_total_spent_kopeks or 0) / 100, + "on" if promo_group.apply_discounts_to_addons else "off", ) return promo_group @@ -72,6 +107,9 @@ async def update_promo_group( server_discount_percent: Optional[int] = None, traffic_discount_percent: Optional[int] = None, device_discount_percent: Optional[int] = None, + period_discounts: Optional[Dict[int, int]] = None, + auto_assign_total_spent_kopeks: Optional[int] = None, + apply_discounts_to_addons: Optional[bool] = None, ) -> PromoGroup: if name is not None: group.name = name.strip() @@ -81,6 +119,13 @@ async def update_promo_group( group.traffic_discount_percent = max(0, min(100, traffic_discount_percent)) if device_discount_percent is not None: group.device_discount_percent = max(0, min(100, device_discount_percent)) + if period_discounts is not None: + normalized_period_discounts = _normalize_period_discounts(period_discounts) + group.period_discounts = normalized_period_discounts or None + if auto_assign_total_spent_kopeks is not None: + group.auto_assign_total_spent_kopeks = max(0, auto_assign_total_spent_kopeks) + if apply_discounts_to_addons is not None: + group.apply_discounts_to_addons = bool(apply_discounts_to_addons) await db.commit() await db.refresh(group) diff --git a/app/database/crud/server_squad.py b/app/database/crud/server_squad.py index eb0692e6..85f1dc58 100644 --- a/app/database/crud/server_squad.py +++ b/app/database/crud/server_squad.py @@ -1,14 +1,22 @@ import logging -from typing import List, Optional, Tuple +from typing import Iterable, List, Optional, Sequence, Tuple + from sqlalchemy import select, and_, func, update, delete, text from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload -from app.database.models import ServerSquad, SubscriptionServer, Subscription +from app.database.models import PromoGroup, ServerSquad, SubscriptionServer, Subscription logger = logging.getLogger(__name__) +async def _get_default_promo_group_id(db: AsyncSession) -> Optional[int]: + result = await db.execute( + select(PromoGroup.id).where(PromoGroup.is_default.is_(True)).limit(1) + ) + return result.scalar_one_or_none() + + async def create_server_squad( db: AsyncSession, squad_uuid: str, @@ -18,9 +26,30 @@ async def create_server_squad( price_kopeks: int = 0, description: str = None, max_users: int = None, - is_available: bool = True + is_available: bool = True, + promo_group_ids: Optional[Iterable[int]] = None, ) -> ServerSquad: - + + normalized_group_ids: Sequence[int] + if promo_group_ids is None: + default_id = await _get_default_promo_group_id(db) + normalized_group_ids = [default_id] if default_id is not None else [] + else: + normalized_group_ids = [int(pg_id) for pg_id in set(promo_group_ids)] + + if not normalized_group_ids: + raise ValueError("Server squad must be linked to at least one promo group") + + promo_groups_result = await db.execute( + select(PromoGroup).where(PromoGroup.id.in_(normalized_group_ids)) + ) + promo_groups = promo_groups_result.scalars().all() + + if len(promo_groups) != len(normalized_group_ids): + logger.warning( + "Не все промогруппы найдены при создании сервера %s", display_name + ) + server_squad = ServerSquad( squad_uuid=squad_uuid, display_name=display_name, @@ -29,9 +58,10 @@ async def create_server_squad( price_kopeks=price_kopeks, description=description, max_users=max_users, - is_available=is_available + is_available=is_available, + allowed_promo_groups=promo_groups, ) - + db.add(server_squad) await db.commit() await db.refresh(server_squad) @@ -46,9 +76,11 @@ async def get_server_squad_by_uuid( ) -> Optional[ServerSquad]: result = await db.execute( - select(ServerSquad).where(ServerSquad.squad_uuid == squad_uuid) + select(ServerSquad) + .options(selectinload(ServerSquad.allowed_promo_groups)) + .where(ServerSquad.squad_uuid == squad_uuid) ) - return result.scalar_one_or_none() + return result.scalars().unique().one_or_none() async def get_server_squad_by_id( @@ -57,9 +89,11 @@ async def get_server_squad_by_id( ) -> Optional[ServerSquad]: result = await db.execute( - select(ServerSquad).where(ServerSquad.id == server_id) + select(ServerSquad) + .options(selectinload(ServerSquad.allowed_promo_groups)) + .where(ServerSquad.id == server_id) ) - return result.scalar_one_or_none() + return result.scalars().unique().one_or_none() async def get_all_server_squads( @@ -91,14 +125,59 @@ async def get_all_server_squads( return servers, total_count -async def get_available_server_squads(db: AsyncSession) -> List[ServerSquad]: - - result = await db.execute( +async def get_available_server_squads( + db: AsyncSession, + promo_group_id: Optional[int] = None, +) -> List[ServerSquad]: + + query = ( select(ServerSquad) - .where(ServerSquad.is_available == True) + .options(selectinload(ServerSquad.allowed_promo_groups)) + .where(ServerSquad.is_available.is_(True)) .order_by(ServerSquad.sort_order, ServerSquad.display_name) ) - return result.scalars().all() + + if promo_group_id is not None: + query = query.join(ServerSquad.allowed_promo_groups).where( + PromoGroup.id == promo_group_id + ) + + result = await db.execute(query) + return result.scalars().unique().all() + + +async def update_server_squad_promo_groups( + db: AsyncSession, server_id: int, promo_group_ids: Iterable[int] +) -> Optional[ServerSquad]: + unique_ids = [int(pg_id) for pg_id in set(promo_group_ids)] + + if not unique_ids: + raise ValueError("Нужно выбрать хотя бы одну промогруппу") + + server = await get_server_squad_by_id(db, server_id) + if not server: + return None + + result = await db.execute( + select(PromoGroup).where(PromoGroup.id.in_(unique_ids)) + ) + promo_groups = result.scalars().all() + + if not promo_groups: + raise ValueError("Не найдены промогруппы для обновления сервера") + + server.allowed_promo_groups = promo_groups + await db.commit() + await db.refresh(server) + + logger.info( + "Обновлены промогруппы сервера %s (ID: %s): %s", + server.display_name, + server.id, + ", ".join(pg.name for pg in promo_groups), + ) + + return server async def update_server_squad( diff --git a/app/database/crud/subscription.py b/app/database/crud/subscription.py index 8898b220..91b79375 100644 --- a/app/database/crud/subscription.py +++ b/app/database/crud/subscription.py @@ -531,7 +531,15 @@ async def calculate_subscription_total_cost( months_in_period = calculate_months_from_days(period_days) - base_price = PERIOD_PRICES.get(period_days, 0) + base_price_original = PERIOD_PRICES.get(period_days, 0) + period_discount_percent = _get_discount_percent( + user, + promo_group, + "period", + period_days=period_days, + ) + base_discount_total = base_price_original * period_discount_percent // 100 + base_price = base_price_original - base_discount_total promo_group = promo_group or (user.promo_group if user else None) @@ -577,6 +585,9 @@ async def calculate_subscription_total_cost( details = { 'base_price': base_price, + 'base_price_original': base_price_original, + 'base_discount_percent': period_discount_percent, + 'base_discount_total': base_discount_total, 'traffic_price_per_month': traffic_price_per_month, 'traffic_discount_percent': traffic_discount_percent, 'traffic_discount_total': total_traffic_discount, @@ -954,7 +965,8 @@ async def create_subscription( device_limit: int = 1, connected_squads: list = None, remnawave_short_uuid: str = None, - subscription_url: str = "" + subscription_url: str = "", + subscription_crypto_link: str = "" ) -> Subscription: if end_date is None: @@ -973,7 +985,8 @@ async def create_subscription( device_limit=device_limit, connected_squads=connected_squads, remnawave_short_uuid=remnawave_short_uuid, - subscription_url=subscription_url + subscription_url=subscription_url, + subscription_crypto_link=subscription_crypto_link ) db.add(subscription) diff --git a/app/database/crud/ticket.py b/app/database/crud/ticket.py index add26178..6f566cdc 100644 --- a/app/database/crud/ticket.py +++ b/app/database/crud/ticket.py @@ -4,7 +4,7 @@ from sqlalchemy import select, desc, and_, or_, update, func from sqlalchemy.orm import selectinload from datetime import datetime -from app.database.models import Ticket, TicketMessage, TicketStatus, User +from app.database.models import Ticket, TicketMessage, TicketStatus, User, SupportAuditLog class TicketCRUD: @@ -87,6 +87,40 @@ class TicketCRUD: result = await db.execute(query) return result.scalars().all() + @staticmethod + async def count_user_tickets_by_statuses( + db: AsyncSession, + user_id: int, + statuses: List[str] + ) -> int: + """Подсчитать количество тикетов пользователя по списку статусов""" + query = select(func.count()).select_from(Ticket).where(Ticket.user_id == user_id) + if statuses: + query = query.where(Ticket.status.in_(statuses)) + result = await db.execute(query) + return int(result.scalar() or 0) + + @staticmethod + async def get_user_tickets_by_statuses( + db: AsyncSession, + user_id: int, + statuses: List[str], + limit: int = 20, + offset: int = 0 + ) -> List[Ticket]: + """Получить тикеты пользователя по списку статусов с пагинацией""" + query = ( + select(Ticket) + .where(Ticket.user_id == user_id) + .order_by(desc(Ticket.updated_at)) + .offset(offset) + .limit(limit) + ) + if statuses: + query = query.where(Ticket.status.in_(statuses)) + result = await db.execute(query) + return result.scalars().all() + @staticmethod async def user_has_active_ticket( db: AsyncSession, @@ -239,6 +273,54 @@ class TicketCRUD: return await TicketCRUD.update_ticket_status( db, ticket_id, TicketStatus.CLOSED.value, datetime.utcnow() ) + + @staticmethod + async def add_support_audit( + db: AsyncSession, + *, + actor_user_id: Optional[int], + actor_telegram_id: int, + is_moderator: bool, + action: str, + ticket_id: Optional[int] = None, + target_user_id: Optional[int] = None, + details: Optional[dict] = None, + ) -> None: + try: + log = SupportAuditLog( + actor_user_id=actor_user_id, + actor_telegram_id=actor_telegram_id, + is_moderator=bool(is_moderator), + action=action, + ticket_id=ticket_id, + target_user_id=target_user_id, + details=details or {}, + ) + db.add(log) + await db.commit() + except Exception: + await db.rollback() + # не мешаем основной логике + pass + + @staticmethod + async def list_support_audit( + db: AsyncSession, + *, + limit: int = 50, + offset: int = 0, + ) -> List[SupportAuditLog]: + from sqlalchemy import select, desc + result = await db.execute( + select(SupportAuditLog).order_by(desc(SupportAuditLog.created_at)).offset(offset).limit(limit) + ) + return result.scalars().all() + + @staticmethod + async def count_support_audit(db: AsyncSession) -> int: + from sqlalchemy import select, func + result = await db.execute(select(func.count()).select_from(SupportAuditLog)) + return int(result.scalar() or 0) @staticmethod async def get_open_tickets_count(db: AsyncSession) -> int: @@ -291,6 +373,14 @@ class TicketMessageCRUD: else: # Пользователь ответил - тикет открыт ticket.status = TicketStatus.OPEN.value + # Сбросить отметку последнего SLA-напоминания, чтобы снова напоминать от времени нового сообщения + try: + from sqlalchemy import inspect as sa_inspect + # если колонка существует в модели + if hasattr(ticket, 'last_sla_reminder_at'): + ticket.last_sla_reminder_at = None + except Exception: + pass ticket.updated_at = datetime.utcnow() diff --git a/app/database/crud/transaction.py b/app/database/crud/transaction.py index b258f1b2..a7077161 100644 --- a/app/database/crud/transaction.py +++ b/app/database/crud/transaction.py @@ -37,6 +37,20 @@ async def create_transaction( await db.refresh(transaction) logger.info(f"💳 Создана транзакция: {type.value} на {amount_kopeks/100}₽ для пользователя {user_id}") + + try: + from app.services.promo_group_assignment import ( + maybe_assign_promo_group_by_total_spent, + ) + + await maybe_assign_promo_group_by_total_spent(db, user_id) + except Exception as exc: + logger.debug( + "Не удалось проверить автовыдачу промогруппы для пользователя %s: %s", + user_id, + exc, + ) + return transaction @@ -98,15 +112,47 @@ async def get_user_transactions_count( return result.scalar() +async def get_user_total_spent_kopeks(db: AsyncSession, user_id: int) -> int: + result = await db.execute( + select(func.coalesce(func.sum(Transaction.amount_kopeks), 0)).where( + and_( + Transaction.user_id == user_id, + Transaction.is_completed.is_(True), + Transaction.type.in_( + [ + TransactionType.DEPOSIT.value, + TransactionType.SUBSCRIPTION_PAYMENT.value, + ] + ), + ) + ) + ) + return int(result.scalar_one()) + + async def complete_transaction(db: AsyncSession, transaction: Transaction) -> Transaction: - + transaction.is_completed = True transaction.completed_at = datetime.utcnow() - + await db.commit() await db.refresh(transaction) - + logger.info(f"✅ Транзакция {transaction.id} завершена") + + try: + from app.services.promo_group_assignment import ( + maybe_assign_promo_group_by_total_spent, + ) + + await maybe_assign_promo_group_by_total_spent(db, transaction.user_id) + except Exception as exc: + logger.debug( + "Не удалось проверить автовыдачу промогруппы для пользователя %s: %s", + transaction.user_id, + exc, + ) + return transaction diff --git a/app/database/crud/user.py b/app/database/crud/user.py index 1abb0055..582c8695 100644 --- a/app/database/crud/user.py +++ b/app/database/crud/user.py @@ -88,8 +88,7 @@ async def create_user( ) -> User: if not referral_code: - from app.utils.user_utils import generate_unique_referral_code - referral_code = await generate_unique_referral_code(db, telegram_id) + referral_code = await create_unique_referral_code(db) default_group = await get_default_promo_group(db) if not default_group: diff --git a/app/database/models.py b/app/database/models.py index 2491eef2..bc36c94e 100644 --- a/app/database/models.py +++ b/app/database/models.py @@ -1,5 +1,5 @@ from datetime import datetime, timedelta -from typing import Optional, List +from typing import Optional, List, Dict from enum import Enum from sqlalchemy import ( @@ -14,6 +14,8 @@ from sqlalchemy import ( JSON, BigInteger, UniqueConstraint, + Index, + Table, ) from sqlalchemy.ext.declarative import declarative_base from sqlalchemy.orm import relationship, Mapped, mapped_column @@ -23,6 +25,24 @@ from sqlalchemy.sql import func Base = declarative_base() +server_squad_promo_groups = Table( + "server_squad_promo_groups", + Base.metadata, + Column( + "server_squad_id", + Integer, + ForeignKey("server_squads.id", ondelete="CASCADE"), + primary_key=True, + ), + Column( + "promo_group_id", + Integer, + ForeignKey("promo_groups.id", ondelete="CASCADE"), + primary_key=True, + ), +) + + class UserStatus(Enum): ACTIVE = "active" BLOCKED = "blocked" @@ -53,9 +73,11 @@ class PromoCodeType(Enum): class PaymentMethod(Enum): TELEGRAM_STARS = "telegram_stars" TRIBUTE = "tribute" - YOOKASSA = "yookassa" + YOOKASSA = "yookassa" CRYPTOBOT = "cryptobot" - MANUAL = "manual" + MULENPAY = "mulenpay" + PAL24 = "pal24" + MANUAL = "manual" class YooKassaPayment(Base): __tablename__ = "yookassa_payments" @@ -107,7 +129,7 @@ class YooKassaPayment(Base): class CryptoBotPayment(Base): __tablename__ = "cryptobot_payments" - + id = Column(Integer, primary_key=True, index=True) user_id = Column(Integer, ForeignKey("users.id"), nullable=False) @@ -155,6 +177,111 @@ class CryptoBotPayment(Base): return f"" +class MulenPayPayment(Base): + __tablename__ = "mulenpay_payments" + + id = Column(Integer, primary_key=True, index=True) + user_id = Column(Integer, ForeignKey("users.id"), nullable=False) + + mulen_payment_id = Column(Integer, nullable=True, index=True) + uuid = Column(String(255), unique=True, nullable=False, index=True) + amount_kopeks = Column(Integer, nullable=False) + currency = Column(String(10), nullable=False, default="RUB") + description = Column(Text, nullable=True) + + status = Column(String(50), nullable=False, default="created") + is_paid = Column(Boolean, default=False) + paid_at = Column(DateTime, nullable=True) + + payment_url = Column(Text, nullable=True) + metadata_json = Column(JSON, nullable=True) + callback_payload = Column(JSON, nullable=True) + + transaction_id = Column(Integer, ForeignKey("transactions.id"), nullable=True) + + created_at = Column(DateTime, default=func.now()) + updated_at = Column(DateTime, default=func.now(), onupdate=func.now()) + + user = relationship("User", backref="mulenpay_payments") + transaction = relationship("Transaction", backref="mulenpay_payment") + + @property + def amount_rubles(self) -> float: + return self.amount_kopeks / 100 + + def __repr__(self) -> str: # pragma: no cover - debug helper + return ( + "".format( + self.id, + self.mulen_payment_id, + self.amount_rubles, + self.status, + ) + ) + + +class Pal24Payment(Base): + __tablename__ = "pal24_payments" + + id = Column(Integer, primary_key=True, index=True) + user_id = Column(Integer, ForeignKey("users.id"), nullable=False) + + bill_id = Column(String(255), unique=True, nullable=False, index=True) + order_id = Column(String(255), nullable=True, index=True) + amount_kopeks = Column(Integer, nullable=False) + currency = Column(String(10), nullable=False, default="RUB") + description = Column(Text, nullable=True) + type = Column(String(20), nullable=False, default="normal") + + status = Column(String(50), nullable=False, default="NEW") + is_active = Column(Boolean, default=True) + is_paid = Column(Boolean, default=False) + paid_at = Column(DateTime, nullable=True) + last_status = Column(String(50), nullable=True) + last_status_checked_at = Column(DateTime, nullable=True) + + link_url = Column(Text, nullable=True) + link_page_url = Column(Text, nullable=True) + metadata_json = Column(JSON, nullable=True) + callback_payload = Column(JSON, nullable=True) + + payment_id = Column(String(255), nullable=True, index=True) + payment_status = Column(String(50), nullable=True) + payment_method = Column(String(50), nullable=True) + balance_amount = Column(String(50), nullable=True) + balance_currency = Column(String(10), nullable=True) + payer_account = Column(String(255), nullable=True) + + ttl = Column(Integer, nullable=True) + expires_at = Column(DateTime, nullable=True) + + transaction_id = Column(Integer, ForeignKey("transactions.id"), nullable=True) + + created_at = Column(DateTime, default=func.now()) + updated_at = Column(DateTime, default=func.now(), onupdate=func.now()) + + user = relationship("User", backref="pal24_payments") + transaction = relationship("Transaction", backref="pal24_payment") + + @property + def amount_rubles(self) -> float: + return self.amount_kopeks / 100 + + @property + def is_pending(self) -> bool: + return self.status in {"NEW", "PROCESS"} + + def __repr__(self) -> str: # pragma: no cover - debug helper + return ( + "".format( + self.id, + self.bill_id, + self.amount_rubles, + self.status, + ) + ) + + class PromoGroup(Base): __tablename__ = "promo_groups" @@ -163,13 +290,67 @@ class PromoGroup(Base): server_discount_percent = Column(Integer, nullable=False, default=0) traffic_discount_percent = Column(Integer, nullable=False, default=0) device_discount_percent = Column(Integer, nullable=False, default=0) + period_discounts = Column(JSON, nullable=True, default=dict) + auto_assign_total_spent_kopeks = Column(Integer, nullable=True, default=None) + apply_discounts_to_addons = Column(Boolean, nullable=False, default=True) is_default = Column(Boolean, nullable=False, default=False) created_at = Column(DateTime, default=func.now()) updated_at = Column(DateTime, default=func.now(), onupdate=func.now()) users = relationship("User", back_populates="promo_group") + server_squads = relationship( + "ServerSquad", + secondary=server_squad_promo_groups, + back_populates="allowed_promo_groups", + lazy="selectin", + ) + + def _get_period_discounts_map(self) -> Dict[int, int]: + raw_discounts = self.period_discounts or {} + + if isinstance(raw_discounts, dict): + items = raw_discounts.items() + else: + items = [] + + normalized: Dict[int, int] = {} + + for key, value in items: + try: + period = int(key) + percent = int(value) + except (TypeError, ValueError): + continue + + normalized[period] = max(0, min(100, percent)) + + return normalized + + def _get_period_discount(self, period_days: Optional[int]) -> int: + if not period_days: + return 0 + + discounts = self._get_period_discounts_map() + + if period_days in discounts: + return discounts[period_days] + + if self.is_default: + try: + from app.config import settings + + if settings.is_base_promo_group_period_discount_enabled(): + config_discounts = settings.get_base_promo_group_period_discounts() + return config_discounts.get(period_days, 0) + except Exception: + return 0 + + return 0 def get_discount_percent(self, category: str, period_days: Optional[int] = None) -> int: + if category == "period": + return max(0, min(100, self._get_period_discount(period_days))) + mapping = { "servers": self.server_discount_percent, "traffic": self.traffic_discount_percent, @@ -177,24 +358,12 @@ class PromoGroup(Base): } percent = mapping.get(category, 0) - if self.is_default and period_days is not None: - try: - from app.config import settings - - if settings.is_base_promo_group_period_discount_enabled(): - discounts = settings.get_base_promo_group_period_discounts() - if period_days in discounts: - period_discount = discounts[period_days] - percent = period_discount - except Exception: - pass - return max(0, min(100, percent)) class User(Base): __tablename__ = "users" - + id = Column(Integer, primary_key=True, index=True) telegram_id = Column(BigInteger, unique=True, index=True, nullable=False) username = Column(String(255), nullable=True) @@ -216,7 +385,10 @@ class User(Base): subscription = relationship("Subscription", back_populates="user", uselist=False) transactions = relationship("Transaction", back_populates="user") referral_earnings = relationship("ReferralEarning", foreign_keys="ReferralEarning.user_id", back_populates="user") + discount_offers = relationship("DiscountOffer", back_populates="user") lifetime_used_traffic_bytes = Column(BigInteger, default=0) + auto_promo_group_assigned = Column(Boolean, nullable=False, default=False) + auto_promo_group_threshold_kopeks = Column(BigInteger, nullable=False, default=0) last_remnawave_sync = Column(DateTime, nullable=True) trojan_password = Column(String(255), nullable=True) vless_uuid = Column(String(255), nullable=True) @@ -265,7 +437,8 @@ class Subscription(Base): traffic_used_gb = Column(Float, default=0.0) subscription_url = Column(String, nullable=True) - + subscription_crypto_link = Column(String, nullable=True) + device_limit = Column(Integer, default=1) connected_squads = Column(JSON, default=list) @@ -277,8 +450,9 @@ class Subscription(Base): updated_at = Column(DateTime, default=func.now(), onupdate=func.now()) remnawave_short_uuid = Column(String(255), nullable=True) - + user = relationship("User", back_populates="subscription") + discount_offers = relationship("DiscountOffer", back_populates="subscription") @property def is_active(self) -> bool: @@ -622,6 +796,28 @@ class SentNotification(Base): user = relationship("User", backref="sent_notifications") subscription = relationship("Subscription", backref="sent_notifications") + +class DiscountOffer(Base): + __tablename__ = "discount_offers" + __table_args__ = ( + Index("ix_discount_offers_user_type", "user_id", "notification_type"), + ) + + id = Column(Integer, primary_key=True, index=True) + user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False) + subscription_id = Column(Integer, ForeignKey("subscriptions.id", ondelete="SET NULL"), nullable=True) + notification_type = Column(String(50), nullable=False) + discount_percent = Column(Integer, nullable=False, default=0) + bonus_amount_kopeks = Column(Integer, nullable=False, default=0) + expires_at = Column(DateTime, nullable=False) + claimed_at = Column(DateTime, nullable=True) + is_active = Column(Boolean, default=True, nullable=False) + created_at = Column(DateTime, default=func.now()) + updated_at = Column(DateTime, default=func.now(), onupdate=func.now()) + + user = relationship("User", back_populates="discount_offers") + subscription = relationship("Subscription", back_populates="discount_offers") + class BroadcastHistory(Base): __tablename__ = "broadcast_history" @@ -644,9 +840,9 @@ class BroadcastHistory(Base): class ServerSquad(Base): __tablename__ = "server_squads" - + id = Column(Integer, primary_key=True, index=True) - + squad_uuid = Column(String(255), unique=True, nullable=False, index=True) display_name = Column(String(255), nullable=False) @@ -664,10 +860,17 @@ class ServerSquad(Base): sort_order = Column(Integer, default=0) max_users = Column(Integer, nullable=True) - current_users = Column(Integer, default=0) - + current_users = Column(Integer, default=0) + created_at = Column(DateTime, default=func.now()) updated_at = Column(DateTime, default=func.now(), onupdate=func.now()) + + allowed_promo_groups = relationship( + "PromoGroup", + secondary=server_squad_promo_groups, + back_populates="server_squads", + lazy="selectin", + ) @property def price_rubles(self) -> float: @@ -703,6 +906,23 @@ class SubscriptionServer(Base): subscription = relationship("Subscription", backref="subscription_servers") server_squad = relationship("ServerSquad", backref="subscription_servers") + +class SupportAuditLog(Base): + __tablename__ = "support_audit_logs" + + id = Column(Integer, primary_key=True, index=True) + actor_user_id = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True) + actor_telegram_id = Column(BigInteger, nullable=False) + is_moderator = Column(Boolean, default=False) + action = Column(String(50), nullable=False) # close_ticket, block_user_timed, block_user_perm, unblock_user + ticket_id = Column(Integer, ForeignKey("tickets.id", ondelete="SET NULL"), nullable=True) + target_user_id = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True) + details = Column(JSON, nullable=True) + created_at = Column(DateTime, default=func.now()) + + actor = relationship("User", foreign_keys=[actor_user_id]) + ticket = relationship("Ticket", foreign_keys=[ticket_id]) + class UserMessage(Base): __tablename__ = "user_messages" id = Column(Integer, primary_key=True, index=True) @@ -810,6 +1030,8 @@ class Ticket(Base): created_at = Column(DateTime, default=func.now()) updated_at = Column(DateTime, default=func.now(), onupdate=func.now()) closed_at = Column(DateTime, nullable=True) + # SLA reminders + last_sla_reminder_at = Column(DateTime, nullable=True) # Связи user = relationship("User", backref="tickets") diff --git a/app/database/universal_migration.py b/app/database/universal_migration.py index 2b1136ca..b0edb1b6 100644 --- a/app/database/universal_migration.py +++ b/app/database/universal_migration.py @@ -274,6 +274,429 @@ async def create_cryptobot_payments_table(): logger.error(f"Ошибка создания таблицы cryptobot_payments: {e}") return False + +async def create_mulenpay_payments_table(): + table_exists = await check_table_exists('mulenpay_payments') + if table_exists: + logger.info("Таблица mulenpay_payments уже существует") + return True + + try: + async with engine.begin() as conn: + db_type = await get_database_type() + + if db_type == 'sqlite': + create_sql = """ + CREATE TABLE mulenpay_payments ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + mulen_payment_id INTEGER NULL, + uuid VARCHAR(255) NOT NULL UNIQUE, + amount_kopeks INTEGER NOT NULL, + currency VARCHAR(10) NOT NULL DEFAULT 'RUB', + description TEXT NULL, + status VARCHAR(50) NOT NULL DEFAULT 'created', + is_paid BOOLEAN DEFAULT 0, + paid_at DATETIME NULL, + payment_url TEXT NULL, + metadata_json JSON NULL, + callback_payload JSON NULL, + transaction_id INTEGER NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users(id), + FOREIGN KEY (transaction_id) REFERENCES transactions(id) + ); + + CREATE INDEX idx_mulenpay_uuid ON mulenpay_payments(uuid); + CREATE INDEX idx_mulenpay_payment_id ON mulenpay_payments(mulen_payment_id); + """ + + elif db_type == 'postgresql': + create_sql = """ + CREATE TABLE mulenpay_payments ( + id SERIAL PRIMARY KEY, + user_id INTEGER NOT NULL REFERENCES users(id), + mulen_payment_id INTEGER NULL, + uuid VARCHAR(255) NOT NULL UNIQUE, + amount_kopeks INTEGER NOT NULL, + currency VARCHAR(10) NOT NULL DEFAULT 'RUB', + description TEXT NULL, + status VARCHAR(50) NOT NULL DEFAULT 'created', + is_paid BOOLEAN NOT NULL DEFAULT FALSE, + paid_at TIMESTAMP NULL, + payment_url TEXT NULL, + metadata_json JSON NULL, + callback_payload JSON NULL, + transaction_id INTEGER NULL REFERENCES transactions(id), + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ); + + CREATE INDEX idx_mulenpay_uuid ON mulenpay_payments(uuid); + CREATE INDEX idx_mulenpay_payment_id ON mulenpay_payments(mulen_payment_id); + """ + + elif db_type == 'mysql': + create_sql = """ + CREATE TABLE mulenpay_payments ( + id INT AUTO_INCREMENT PRIMARY KEY, + user_id INT NOT NULL, + mulen_payment_id INT NULL, + uuid VARCHAR(255) NOT NULL UNIQUE, + amount_kopeks INT NOT NULL, + currency VARCHAR(10) NOT NULL DEFAULT 'RUB', + description TEXT NULL, + status VARCHAR(50) NOT NULL DEFAULT 'created', + is_paid BOOLEAN NOT NULL DEFAULT 0, + paid_at DATETIME NULL, + payment_url TEXT NULL, + metadata_json JSON NULL, + callback_payload JSON NULL, + transaction_id INT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users(id), + FOREIGN KEY (transaction_id) REFERENCES transactions(id) + ); + + CREATE INDEX idx_mulenpay_uuid ON mulenpay_payments(uuid); + CREATE INDEX idx_mulenpay_payment_id ON mulenpay_payments(mulen_payment_id); + """ + + else: + logger.error(f"Неподдерживаемый тип БД для таблицы mulenpay_payments: {db_type}") + return False + + await conn.execute(text(create_sql)) + logger.info("Таблица mulenpay_payments успешно создана") + return True + + except Exception as e: + logger.error(f"Ошибка создания таблицы mulenpay_payments: {e}") + return False + + +async def ensure_mulenpay_payment_schema() -> bool: + logger.info("=== ОБНОВЛЕНИЕ СХЕМЫ MULEN PAY ===") + + table_exists = await check_table_exists("mulenpay_payments") + if not table_exists: + logger.warning("⚠️ Таблица mulenpay_payments отсутствует — создаём заново") + return await create_mulenpay_payments_table() + + try: + column_exists = await check_column_exists("mulenpay_payments", "mulen_payment_id") + paid_at_column_exists = await check_column_exists("mulenpay_payments", "paid_at") + index_exists = await check_index_exists("mulenpay_payments", "idx_mulenpay_payment_id") + + async with engine.begin() as conn: + db_type = await get_database_type() + + if not column_exists: + if db_type == "sqlite": + alter_sql = "ALTER TABLE mulenpay_payments ADD COLUMN mulen_payment_id INTEGER NULL" + elif db_type == "postgresql": + alter_sql = "ALTER TABLE mulenpay_payments ADD COLUMN mulen_payment_id INTEGER NULL" + elif db_type == "mysql": + alter_sql = "ALTER TABLE mulenpay_payments ADD COLUMN mulen_payment_id INT NULL" + else: + logger.error( + "Неподдерживаемый тип БД для добавления mulen_payment_id в mulenpay_payments: %s", + db_type, + ) + return False + + await conn.execute(text(alter_sql)) + logger.info("✅ Добавлена колонка mulenpay_payments.mulen_payment_id") + else: + logger.info("ℹ️ Колонка mulenpay_payments.mulen_payment_id уже существует") + + if not paid_at_column_exists: + if db_type == "sqlite": + alter_paid_at_sql = "ALTER TABLE mulenpay_payments ADD COLUMN paid_at DATETIME NULL" + elif db_type == "postgresql": + alter_paid_at_sql = "ALTER TABLE mulenpay_payments ADD COLUMN paid_at TIMESTAMP NULL" + elif db_type == "mysql": + alter_paid_at_sql = "ALTER TABLE mulenpay_payments ADD COLUMN paid_at DATETIME NULL" + else: + logger.error( + "Неподдерживаемый тип БД для добавления paid_at в mulenpay_payments: %s", + db_type, + ) + return False + + await conn.execute(text(alter_paid_at_sql)) + logger.info("✅ Добавлена колонка mulenpay_payments.paid_at") + else: + logger.info("ℹ️ Колонка mulenpay_payments.paid_at уже существует") + + if not index_exists: + if db_type == "sqlite": + create_index_sql = ( + "CREATE INDEX IF NOT EXISTS idx_mulenpay_payment_id " + "ON mulenpay_payments(mulen_payment_id)" + ) + elif db_type == "postgresql": + create_index_sql = ( + "CREATE INDEX IF NOT EXISTS idx_mulenpay_payment_id " + "ON mulenpay_payments(mulen_payment_id)" + ) + elif db_type == "mysql": + create_index_sql = ( + "CREATE INDEX idx_mulenpay_payment_id " + "ON mulenpay_payments(mulen_payment_id)" + ) + else: + logger.error( + "Неподдерживаемый тип БД для создания индекса mulenpay_payment_id: %s", + db_type, + ) + return False + + await conn.execute(text(create_index_sql)) + logger.info("✅ Создан индекс idx_mulenpay_payment_id") + else: + logger.info("ℹ️ Индекс idx_mulenpay_payment_id уже существует") + + return True + + except Exception as e: + logger.error(f"Ошибка обновления схемы mulenpay_payments: {e}") + return False + + +async def create_pal24_payments_table(): + table_exists = await check_table_exists('pal24_payments') + if table_exists: + logger.info("Таблица pal24_payments уже существует") + return True + + try: + async with engine.begin() as conn: + db_type = await get_database_type() + + if db_type == 'sqlite': + create_sql = """ + CREATE TABLE pal24_payments ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + bill_id VARCHAR(255) NOT NULL UNIQUE, + order_id VARCHAR(255) NULL, + amount_kopeks INTEGER NOT NULL, + currency VARCHAR(10) NOT NULL DEFAULT 'RUB', + description TEXT NULL, + type VARCHAR(20) NOT NULL DEFAULT 'normal', + status VARCHAR(50) NOT NULL DEFAULT 'NEW', + is_active BOOLEAN NOT NULL DEFAULT 1, + is_paid BOOLEAN NOT NULL DEFAULT 0, + paid_at DATETIME NULL, + last_status VARCHAR(50) NULL, + last_status_checked_at DATETIME NULL, + link_url TEXT NULL, + link_page_url TEXT NULL, + metadata_json JSON NULL, + callback_payload JSON NULL, + payment_id VARCHAR(255) NULL, + payment_status VARCHAR(50) NULL, + payment_method VARCHAR(50) NULL, + balance_amount VARCHAR(50) NULL, + balance_currency VARCHAR(10) NULL, + payer_account VARCHAR(255) NULL, + ttl INTEGER NULL, + expires_at DATETIME NULL, + transaction_id INTEGER NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users(id), + FOREIGN KEY (transaction_id) REFERENCES transactions(id) + ); + + CREATE INDEX idx_pal24_bill_id ON pal24_payments(bill_id); + CREATE INDEX idx_pal24_order_id ON pal24_payments(order_id); + CREATE INDEX idx_pal24_payment_id ON pal24_payments(payment_id); + """ + + elif db_type == 'postgresql': + create_sql = """ + CREATE TABLE pal24_payments ( + id SERIAL PRIMARY KEY, + user_id INTEGER NOT NULL REFERENCES users(id), + bill_id VARCHAR(255) NOT NULL UNIQUE, + order_id VARCHAR(255) NULL, + amount_kopeks INTEGER NOT NULL, + currency VARCHAR(10) NOT NULL DEFAULT 'RUB', + description TEXT NULL, + type VARCHAR(20) NOT NULL DEFAULT 'normal', + status VARCHAR(50) NOT NULL DEFAULT 'NEW', + is_active BOOLEAN NOT NULL DEFAULT TRUE, + is_paid BOOLEAN NOT NULL DEFAULT FALSE, + paid_at TIMESTAMP NULL, + last_status VARCHAR(50) NULL, + last_status_checked_at TIMESTAMP NULL, + link_url TEXT NULL, + link_page_url TEXT NULL, + metadata_json JSON NULL, + callback_payload JSON NULL, + payment_id VARCHAR(255) NULL, + payment_status VARCHAR(50) NULL, + payment_method VARCHAR(50) NULL, + balance_amount VARCHAR(50) NULL, + balance_currency VARCHAR(10) NULL, + payer_account VARCHAR(255) NULL, + ttl INTEGER NULL, + expires_at TIMESTAMP NULL, + transaction_id INTEGER NULL REFERENCES transactions(id), + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ); + + CREATE INDEX idx_pal24_bill_id ON pal24_payments(bill_id); + CREATE INDEX idx_pal24_order_id ON pal24_payments(order_id); + CREATE INDEX idx_pal24_payment_id ON pal24_payments(payment_id); + """ + + elif db_type == 'mysql': + create_sql = """ + CREATE TABLE pal24_payments ( + id INT AUTO_INCREMENT PRIMARY KEY, + user_id INT NOT NULL, + bill_id VARCHAR(255) NOT NULL UNIQUE, + order_id VARCHAR(255) NULL, + amount_kopeks INT NOT NULL, + currency VARCHAR(10) NOT NULL DEFAULT 'RUB', + description TEXT NULL, + type VARCHAR(20) NOT NULL DEFAULT 'normal', + status VARCHAR(50) NOT NULL DEFAULT 'NEW', + is_active BOOLEAN NOT NULL DEFAULT 1, + is_paid BOOLEAN NOT NULL DEFAULT 0, + paid_at DATETIME NULL, + last_status VARCHAR(50) NULL, + last_status_checked_at DATETIME NULL, + link_url TEXT NULL, + link_page_url TEXT NULL, + metadata_json JSON NULL, + callback_payload JSON NULL, + payment_id VARCHAR(255) NULL, + payment_status VARCHAR(50) NULL, + payment_method VARCHAR(50) NULL, + balance_amount VARCHAR(50) NULL, + balance_currency VARCHAR(10) NULL, + payer_account VARCHAR(255) NULL, + ttl INT NULL, + expires_at DATETIME NULL, + transaction_id INT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users(id), + FOREIGN KEY (transaction_id) REFERENCES transactions(id) + ); + + CREATE INDEX idx_pal24_bill_id ON pal24_payments(bill_id); + CREATE INDEX idx_pal24_order_id ON pal24_payments(order_id); + CREATE INDEX idx_pal24_payment_id ON pal24_payments(payment_id); + """ + + else: + logger.error(f"Неподдерживаемый тип БД для таблицы pal24_payments: {db_type}") + return False + + await conn.execute(text(create_sql)) + logger.info("Таблица pal24_payments успешно создана") + return True + + except Exception as e: + logger.error(f"Ошибка создания таблицы pal24_payments: {e}") + return False + + +async def create_discount_offers_table(): + table_exists = await check_table_exists('discount_offers') + if table_exists: + logger.info("Таблица discount_offers уже существует") + return True + + try: + async with engine.begin() as conn: + db_type = await get_database_type() + + if db_type == 'sqlite': + await conn.execute(text(""" + CREATE TABLE discount_offers ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + subscription_id INTEGER NULL, + notification_type VARCHAR(50) NOT NULL, + discount_percent INTEGER NOT NULL DEFAULT 0, + bonus_amount_kopeks INTEGER NOT NULL DEFAULT 0, + expires_at DATETIME NOT NULL, + claimed_at DATETIME NULL, + is_active BOOLEAN NOT NULL DEFAULT 1, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE, + FOREIGN KEY(subscription_id) REFERENCES subscriptions(id) ON DELETE SET NULL + ) + """)) + await conn.execute(text(""" + CREATE INDEX IF NOT EXISTS ix_discount_offers_user_type + ON discount_offers (user_id, notification_type) + """)) + + elif db_type == 'postgresql': + await conn.execute(text(""" + CREATE TABLE IF NOT EXISTS discount_offers ( + id SERIAL PRIMARY KEY, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + subscription_id INTEGER NULL REFERENCES subscriptions(id) ON DELETE SET NULL, + notification_type VARCHAR(50) NOT NULL, + discount_percent INTEGER NOT NULL DEFAULT 0, + bonus_amount_kopeks INTEGER NOT NULL DEFAULT 0, + expires_at TIMESTAMP NOT NULL, + claimed_at TIMESTAMP NULL, + is_active BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) + """)) + await conn.execute(text(""" + CREATE INDEX IF NOT EXISTS ix_discount_offers_user_type + ON discount_offers (user_id, notification_type) + """)) + + elif db_type == 'mysql': + await conn.execute(text(""" + CREATE TABLE IF NOT EXISTS discount_offers ( + id INTEGER PRIMARY KEY AUTO_INCREMENT, + user_id INTEGER NOT NULL, + subscription_id INTEGER NULL, + notification_type VARCHAR(50) NOT NULL, + discount_percent INTEGER NOT NULL DEFAULT 0, + bonus_amount_kopeks INTEGER NOT NULL DEFAULT 0, + expires_at DATETIME NOT NULL, + claimed_at DATETIME NULL, + is_active BOOLEAN NOT NULL DEFAULT TRUE, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + CONSTRAINT fk_discount_offers_user FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE, + CONSTRAINT fk_discount_offers_subscription FOREIGN KEY(subscription_id) REFERENCES subscriptions(id) ON DELETE SET NULL + ) + """)) + await conn.execute(text(""" + CREATE INDEX ix_discount_offers_user_type + ON discount_offers (user_id, notification_type) + """)) + + else: + raise ValueError(f"Unsupported database type: {db_type}") + + logger.info("✅ Таблица discount_offers успешно создана") + return True + + except Exception as e: + logger.error(f"Ошибка создания таблицы discount_offers: {e}") + return False + async def create_user_messages_table(): table_exists = await check_table_exists('user_messages') if table_exists: @@ -435,6 +858,127 @@ async def ensure_promo_groups_setup(): f"Не удалось добавить уникальное ограничение uq_promo_groups_name: {e}" ) + period_discounts_column_exists = await check_column_exists( + "promo_groups", "period_discounts" + ) + + if not period_discounts_column_exists: + if db_type == "sqlite": + await conn.execute( + text("ALTER TABLE promo_groups ADD COLUMN period_discounts JSON") + ) + await conn.execute( + text("UPDATE promo_groups SET period_discounts = '{}' WHERE period_discounts IS NULL") + ) + elif db_type == "postgresql": + await conn.execute( + text( + "ALTER TABLE promo_groups ADD COLUMN period_discounts JSONB" + ) + ) + await conn.execute( + text( + "UPDATE promo_groups SET period_discounts = '{}'::jsonb WHERE period_discounts IS NULL" + ) + ) + elif db_type == "mysql": + await conn.execute( + text("ALTER TABLE promo_groups ADD COLUMN period_discounts JSON") + ) + await conn.execute( + text( + "UPDATE promo_groups SET period_discounts = JSON_OBJECT() WHERE period_discounts IS NULL" + ) + ) + else: + logger.error( + f"Неподдерживаемый тип БД для promo_groups.period_discounts: {db_type}" + ) + return False + + logger.info("Добавлена колонка promo_groups.period_discounts") + + auto_assign_column_exists = await check_column_exists( + "promo_groups", "auto_assign_total_spent_kopeks" + ) + + if not auto_assign_column_exists: + if db_type == "sqlite": + await conn.execute( + text( + "ALTER TABLE promo_groups ADD COLUMN auto_assign_total_spent_kopeks INTEGER DEFAULT 0" + ) + ) + elif db_type == "postgresql": + await conn.execute( + text( + "ALTER TABLE promo_groups ADD COLUMN auto_assign_total_spent_kopeks INTEGER DEFAULT 0" + ) + ) + elif db_type == "mysql": + await conn.execute( + text( + "ALTER TABLE promo_groups ADD COLUMN auto_assign_total_spent_kopeks INT DEFAULT 0" + ) + ) + else: + logger.error( + f"Неподдерживаемый тип БД для promo_groups.auto_assign_total_spent_kopeks: {db_type}" + ) + return False + + logger.info( + "Добавлена колонка promo_groups.auto_assign_total_spent_kopeks" + ) + + addon_discount_column_exists = await check_column_exists( + "promo_groups", "apply_discounts_to_addons" + ) + + if not addon_discount_column_exists: + if db_type == "sqlite": + await conn.execute( + text( + "ALTER TABLE promo_groups ADD COLUMN apply_discounts_to_addons BOOLEAN NOT NULL DEFAULT 1" + ) + ) + await conn.execute( + text( + "UPDATE promo_groups SET apply_discounts_to_addons = 1 WHERE apply_discounts_to_addons IS NULL" + ) + ) + elif db_type == "postgresql": + await conn.execute( + text( + "ALTER TABLE promo_groups ADD COLUMN apply_discounts_to_addons BOOLEAN NOT NULL DEFAULT TRUE" + ) + ) + await conn.execute( + text( + "UPDATE promo_groups SET apply_discounts_to_addons = TRUE WHERE apply_discounts_to_addons IS NULL" + ) + ) + elif db_type == "mysql": + await conn.execute( + text( + "ALTER TABLE promo_groups ADD COLUMN apply_discounts_to_addons TINYINT(1) NOT NULL DEFAULT 1" + ) + ) + await conn.execute( + text( + "UPDATE promo_groups SET apply_discounts_to_addons = 1 WHERE apply_discounts_to_addons IS NULL" + ) + ) + else: + logger.error( + f"Неподдерживаемый тип БД для promo_groups.apply_discounts_to_addons: {db_type}" + ) + return False + + logger.info( + "Добавлена колонка promo_groups.apply_discounts_to_addons" + ) + column_exists = await check_column_exists("users", "promo_group_id") if not column_exists: @@ -450,6 +994,70 @@ async def ensure_promo_groups_setup(): logger.info("Добавлена колонка users.promo_group_id") + auto_promo_flag_exists = await check_column_exists( + "users", "auto_promo_group_assigned" + ) + + if not auto_promo_flag_exists: + if db_type == "sqlite": + await conn.execute( + text( + "ALTER TABLE users ADD COLUMN auto_promo_group_assigned BOOLEAN DEFAULT 0" + ) + ) + elif db_type == "postgresql": + await conn.execute( + text( + "ALTER TABLE users ADD COLUMN auto_promo_group_assigned BOOLEAN DEFAULT FALSE" + ) + ) + elif db_type == "mysql": + await conn.execute( + text( + "ALTER TABLE users ADD COLUMN auto_promo_group_assigned TINYINT(1) DEFAULT 0" + ) + ) + else: + logger.error( + f"Неподдерживаемый тип БД для users.auto_promo_group_assigned: {db_type}" + ) + return False + + logger.info("Добавлена колонка users.auto_promo_group_assigned") + + threshold_column_exists = await check_column_exists( + "users", "auto_promo_group_threshold_kopeks" + ) + + if not threshold_column_exists: + if db_type == "sqlite": + await conn.execute( + text( + "ALTER TABLE users ADD COLUMN auto_promo_group_threshold_kopeks INTEGER NOT NULL DEFAULT 0" + ) + ) + elif db_type == "postgresql": + await conn.execute( + text( + "ALTER TABLE users ADD COLUMN auto_promo_group_threshold_kopeks BIGINT NOT NULL DEFAULT 0" + ) + ) + elif db_type == "mysql": + await conn.execute( + text( + "ALTER TABLE users ADD COLUMN auto_promo_group_threshold_kopeks BIGINT NOT NULL DEFAULT 0" + ) + ) + else: + logger.error( + f"Неподдерживаемый тип БД для users.auto_promo_group_threshold_kopeks: {db_type}" + ) + return False + + logger.info( + "Добавлена колонка users.auto_promo_group_threshold_kopeks" + ) + index_exists = await check_index_exists("users", "ix_users_promo_group_id") if not index_exists: @@ -815,6 +1423,63 @@ async def add_ticket_reply_block_columns(): logger.error(f"Ошибка добавления колонок блокировок в tickets: {e}") return False + +async def add_ticket_sla_columns(): + try: + col_exists = await check_column_exists('tickets', 'last_sla_reminder_at') + if col_exists: + return True + async with engine.begin() as conn: + db_type = await get_database_type() + if db_type == 'sqlite': + alter_sql = "ALTER TABLE tickets ADD COLUMN last_sla_reminder_at DATETIME NULL" + elif db_type == 'postgresql': + alter_sql = "ALTER TABLE tickets ADD COLUMN last_sla_reminder_at TIMESTAMP NULL" + elif db_type == 'mysql': + alter_sql = "ALTER TABLE tickets ADD COLUMN last_sla_reminder_at DATETIME NULL" + else: + logger.error(f"Неподдерживаемый тип БД для добавления last_sla_reminder_at: {db_type}") + return False + await conn.execute(text(alter_sql)) + logger.info("✅ Добавлена колонка tickets.last_sla_reminder_at") + return True + except Exception as e: + logger.error(f"Ошибка добавления SLA колонки в tickets: {e}") + return False + + +async def add_subscription_crypto_link_column() -> bool: + column_exists = await check_column_exists('subscriptions', 'subscription_crypto_link') + if column_exists: + logger.info("ℹ️ Колонка subscription_crypto_link уже существует") + return True + + try: + 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 subscription_crypto_link TEXT")) + elif db_type == 'postgresql': + await conn.execute(text("ALTER TABLE subscriptions ADD COLUMN subscription_crypto_link VARCHAR")) + elif db_type == 'mysql': + await conn.execute(text("ALTER TABLE subscriptions ADD COLUMN subscription_crypto_link VARCHAR(512)")) + else: + logger.error(f"Неподдерживаемый тип БД для добавления subscription_crypto_link: {db_type}") + return False + + await conn.execute(text( + "UPDATE subscriptions SET subscription_crypto_link = subscription_url " + "WHERE subscription_crypto_link IS NULL OR subscription_crypto_link = ''" + )) + + logger.info("✅ Добавлена колонка subscription_crypto_link в таблицу subscriptions") + return True + except Exception as e: + logger.error(f"Ошибка добавления колонки subscription_crypto_link: {e}") + return False + + async def fix_foreign_keys_for_user_deletion(): try: async with engine.begin() as conn: @@ -1053,14 +1718,121 @@ async def fix_subscription_duplicates_universal(): deleted_count = delete_result.rowcount total_deleted += deleted_count logger.info(f"Удалено {deleted_count} дублирующихся подписок для пользователя {user_id}") - + logger.info(f"Всего удалено дублирующихся подписок: {total_deleted}") return total_deleted - + except Exception as e: logger.error(f"Ошибка при очистке дублирующихся подписок: {e}") raise + +async def ensure_server_promo_groups_setup() -> bool: + logger.info("=== НАСТРОЙКА ДОСТУПА СЕРВЕРОВ К ПРОМОГРУППАМ ===") + + try: + table_exists = await check_table_exists("server_squad_promo_groups") + + async with engine.begin() as conn: + db_type = await get_database_type() + + if not table_exists: + if db_type == "sqlite": + create_table_sql = """ + CREATE TABLE server_squad_promo_groups ( + server_squad_id INTEGER NOT NULL, + promo_group_id INTEGER NOT NULL, + PRIMARY KEY (server_squad_id, promo_group_id), + FOREIGN KEY (server_squad_id) REFERENCES server_squads(id) ON DELETE CASCADE, + FOREIGN KEY (promo_group_id) REFERENCES promo_groups(id) ON DELETE CASCADE + ); + """ + create_index_sql = """ + CREATE INDEX IF NOT EXISTS idx_server_squad_promo_groups_promo ON server_squad_promo_groups(promo_group_id); + """ + elif db_type == "postgresql": + create_table_sql = """ + CREATE TABLE server_squad_promo_groups ( + server_squad_id INTEGER NOT NULL REFERENCES server_squads(id) ON DELETE CASCADE, + promo_group_id INTEGER NOT NULL REFERENCES promo_groups(id) ON DELETE CASCADE, + PRIMARY KEY (server_squad_id, promo_group_id) + ); + """ + create_index_sql = """ + CREATE INDEX IF NOT EXISTS idx_server_squad_promo_groups_promo ON server_squad_promo_groups(promo_group_id); + """ + else: + create_table_sql = """ + CREATE TABLE server_squad_promo_groups ( + server_squad_id INT NOT NULL, + promo_group_id INT NOT NULL, + PRIMARY KEY (server_squad_id, promo_group_id), + FOREIGN KEY (server_squad_id) REFERENCES server_squads(id) ON DELETE CASCADE, + FOREIGN KEY (promo_group_id) REFERENCES promo_groups(id) ON DELETE CASCADE + ); + """ + create_index_sql = """ + CREATE INDEX IF NOT EXISTS idx_server_squad_promo_groups_promo ON server_squad_promo_groups(promo_group_id); + """ + + await conn.execute(text(create_table_sql)) + await conn.execute(text(create_index_sql)) + logger.info("✅ Таблица server_squad_promo_groups создана") + else: + logger.info("ℹ️ Таблица server_squad_promo_groups уже существует") + + default_query = ( + "SELECT id FROM promo_groups WHERE is_default IS TRUE LIMIT 1" + if db_type == "postgresql" + else "SELECT id FROM promo_groups WHERE is_default = 1 LIMIT 1" + ) + default_result = await conn.execute(text(default_query)) + default_row = default_result.fetchone() + + if not default_row: + logger.warning("⚠️ Не найдена базовая промогруппа для назначения серверам") + return True + + default_group_id = default_row[0] + + servers_result = await conn.execute(text("SELECT id FROM server_squads")) + server_ids = [row[0] for row in servers_result.fetchall()] + + assigned_count = 0 + for server_id in server_ids: + existing = await conn.execute( + text( + "SELECT 1 FROM server_squad_promo_groups WHERE server_squad_id = :sid LIMIT 1" + ), + {"sid": server_id}, + ) + if existing.fetchone(): + continue + + await conn.execute( + text( + "INSERT INTO server_squad_promo_groups (server_squad_id, promo_group_id) " + "VALUES (:sid, :gid)" + ), + {"sid": server_id, "gid": default_group_id}, + ) + assigned_count += 1 + + if assigned_count: + logger.info( + f"✅ Базовая промогруппа назначена {assigned_count} серверам" + ) + else: + logger.info("ℹ️ Все серверы уже имеют назначенные промогруппы") + + return True + + except Exception as e: + logger.error( + f"Ошибка настройки таблицы server_squad_promo_groups: {e}" + ) + return False + async def run_universal_migration(): logger.info("=== НАЧАЛО УНИВЕРСАЛЬНОЙ МИГРАЦИИ ===") @@ -1079,6 +1851,33 @@ async def run_universal_migration(): else: logger.warning("⚠️ Проблемы с таблицей CryptoBot payments") + logger.info("=== СОЗДАНИЕ ТАБЛИЦЫ MULEN PAY ===") + mulenpay_created = await create_mulenpay_payments_table() + if mulenpay_created: + logger.info("✅ Таблица Mulen Pay payments готова") + else: + logger.warning("⚠️ Проблемы с таблицей Mulen Pay payments") + + mulenpay_schema_ok = await ensure_mulenpay_payment_schema() + if mulenpay_schema_ok: + logger.info("✅ Схема Mulen Pay payments актуальна") + else: + logger.warning("⚠️ Не удалось обновить схему Mulen Pay payments") + + logger.info("=== СОЗДАНИЕ ТАБЛИЦЫ PAL24 ===") + pal24_created = await create_pal24_payments_table() + if pal24_created: + logger.info("✅ Таблица Pal24 payments готова") + else: + logger.warning("⚠️ Проблемы с таблицей Pal24 payments") + + logger.info("=== СОЗДАНИЕ ТАБЛИЦЫ DISCOUNT_OFFERS ===") + discount_created = await create_discount_offers_table() + if discount_created: + logger.info("✅ Таблица discount_offers готова") + else: + logger.warning("⚠️ Проблемы с таблицей discount_offers") + logger.info("=== СОЗДАНИЕ ТАБЛИЦЫ USER_MESSAGES ===") user_messages_created = await create_user_messages_table() if user_messages_created: @@ -1107,6 +1906,86 @@ async def run_universal_migration(): else: logger.warning("⚠️ Проблемы с добавлением полей блокировок в tickets") + logger.info("=== ДОБАВЛЕНИЕ ПОЛЕЙ SLA В TICKETS ===") + sla_cols_added = await add_ticket_sla_columns() + if sla_cols_added: + logger.info("✅ Поля SLA в tickets готовы") + else: + logger.warning("⚠️ Проблемы с добавлением полей SLA в tickets") + + logger.info("=== ДОБАВЛЕНИЕ КОЛОНКИ CRYPTO LINK ДЛЯ ПОДПИСОК ===") + crypto_link_added = await add_subscription_crypto_link_column() + if crypto_link_added: + logger.info("✅ Колонка subscription_crypto_link готова") + else: + logger.warning("⚠️ Проблемы с добавлением колонки subscription_crypto_link") + + logger.info("=== СОЗДАНИЕ ТАБЛИЦЫ АУДИТА ПОДДЕРЖКИ ===") + try: + async with engine.begin() as conn: + db_type = await get_database_type() + if not await check_table_exists('support_audit_logs'): + if db_type == 'sqlite': + create_sql = """ + CREATE TABLE support_audit_logs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + actor_user_id INTEGER NULL, + actor_telegram_id BIGINT NOT NULL, + is_moderator BOOLEAN NOT NULL DEFAULT 0, + action VARCHAR(50) NOT NULL, + ticket_id INTEGER NULL, + target_user_id INTEGER NULL, + details JSON NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (actor_user_id) REFERENCES users(id), + FOREIGN KEY (ticket_id) REFERENCES tickets(id), + FOREIGN KEY (target_user_id) REFERENCES users(id) + ); + CREATE INDEX idx_support_audit_logs_ticket ON support_audit_logs(ticket_id); + CREATE INDEX idx_support_audit_logs_actor ON support_audit_logs(actor_telegram_id); + CREATE INDEX idx_support_audit_logs_action ON support_audit_logs(action); + """ + elif db_type == 'postgresql': + create_sql = """ + CREATE TABLE support_audit_logs ( + id SERIAL PRIMARY KEY, + actor_user_id INTEGER NULL REFERENCES users(id) ON DELETE SET NULL, + actor_telegram_id BIGINT NOT NULL, + is_moderator BOOLEAN NOT NULL DEFAULT FALSE, + action VARCHAR(50) NOT NULL, + ticket_id INTEGER NULL REFERENCES tickets(id) ON DELETE SET NULL, + target_user_id INTEGER NULL REFERENCES users(id) ON DELETE SET NULL, + details JSON NULL, + created_at TIMESTAMP DEFAULT NOW() + ); + CREATE INDEX idx_support_audit_logs_ticket ON support_audit_logs(ticket_id); + CREATE INDEX idx_support_audit_logs_actor ON support_audit_logs(actor_telegram_id); + CREATE INDEX idx_support_audit_logs_action ON support_audit_logs(action); + """ + else: + create_sql = """ + CREATE TABLE support_audit_logs ( + id INT AUTO_INCREMENT PRIMARY KEY, + actor_user_id INT NULL, + actor_telegram_id BIGINT NOT NULL, + is_moderator BOOLEAN NOT NULL DEFAULT 0, + action VARCHAR(50) NOT NULL, + ticket_id INT NULL, + target_user_id INT NULL, + details JSON NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ); + CREATE INDEX idx_support_audit_logs_ticket ON support_audit_logs(ticket_id); + CREATE INDEX idx_support_audit_logs_actor ON support_audit_logs(actor_telegram_id); + CREATE INDEX idx_support_audit_logs_action ON support_audit_logs(action); + """ + await conn.execute(text(create_sql)) + logger.info("✅ Таблица support_audit_logs создана") + else: + logger.info("ℹ️ Таблица support_audit_logs уже существует") + except Exception as e: + logger.warning(f"⚠️ Проблемы с созданием таблицы support_audit_logs: {e}") + logger.info("=== НАСТРОЙКА ПРОМО ГРУПП ===") promo_groups_ready = await ensure_promo_groups_setup() if promo_groups_ready: @@ -1114,6 +1993,12 @@ async def run_universal_migration(): else: logger.warning("⚠️ Проблемы с настройкой промо групп") + server_promo_groups_ready = await ensure_server_promo_groups_setup() + if server_promo_groups_ready: + logger.info("✅ Доступ серверов по промогруппам настроен") + else: + logger.warning("⚠️ Проблемы с настройкой доступа серверов к промогруппам") + logger.info("=== ОБНОВЛЕНИЕ ВНЕШНИХ КЛЮЧЕЙ ===") fk_updated = await fix_foreign_keys_for_user_deletion() if fk_updated: @@ -1186,7 +2071,14 @@ async def check_migration_status(): "subscription_duplicates": False, "subscription_conversions_table": False, "promo_groups_table": False, - "users_promo_group_column": False + "server_promo_groups_table": False, + "users_promo_group_column": False, + "promo_groups_period_discounts_column": False, + "promo_groups_auto_assign_column": False, + "promo_groups_addon_discount_column": False, + "users_auto_promo_group_assigned_column": False, + "users_auto_promo_group_threshold_column": False, + "subscription_crypto_link_column": False, } status["has_made_first_topup_column"] = await check_column_exists('users', 'has_made_first_topup') @@ -1196,9 +2088,16 @@ async def check_migration_status(): status["welcome_texts_table"] = await check_table_exists('welcome_texts') status["subscription_conversions_table"] = await check_table_exists('subscription_conversions') status["promo_groups_table"] = await check_table_exists('promo_groups') + status["server_promo_groups_table"] = await check_table_exists('server_squad_promo_groups') status["welcome_texts_is_enabled_column"] = await check_column_exists('welcome_texts', 'is_enabled') status["users_promo_group_column"] = await check_column_exists('users', 'promo_group_id') + status["promo_groups_period_discounts_column"] = await check_column_exists('promo_groups', 'period_discounts') + status["promo_groups_auto_assign_column"] = await check_column_exists('promo_groups', 'auto_assign_total_spent_kopeks') + status["promo_groups_addon_discount_column"] = await check_column_exists('promo_groups', 'apply_discounts_to_addons') + status["users_auto_promo_group_assigned_column"] = await check_column_exists('users', 'auto_promo_group_assigned') + status["users_auto_promo_group_threshold_column"] = await check_column_exists('users', 'auto_promo_group_threshold_kopeks') + status["subscription_crypto_link_column"] = await check_column_exists('subscriptions', 'subscription_crypto_link') media_fields_exist = ( await check_column_exists('broadcast_history', 'has_media') and @@ -1230,7 +2129,14 @@ async def check_migration_status(): "subscription_conversions_table": "Таблица конверсий подписок", "subscription_duplicates": "Отсутствие дубликатов подписок", "promo_groups_table": "Таблица промо-групп", - "users_promo_group_column": "Колонка promo_group_id у пользователей" + "server_promo_groups_table": "Связи серверов и промогрупп", + "users_promo_group_column": "Колонка promo_group_id у пользователей", + "promo_groups_period_discounts_column": "Колонка period_discounts у промо-групп", + "promo_groups_auto_assign_column": "Колонка auto_assign_total_spent_kopeks у промо-групп", + "promo_groups_addon_discount_column": "Колонка apply_discounts_to_addons у промо-групп", + "users_auto_promo_group_assigned_column": "Флаг автоназначения промогруппы у пользователей", + "users_auto_promo_group_threshold_column": "Порог последней авто-промогруппы у пользователей", + "subscription_crypto_link_column": "Колонка subscription_crypto_link в subscriptions", } for check_key, check_status in status.items(): diff --git a/app/external/pal24_client.py b/app/external/pal24_client.py new file mode 100644 index 00000000..4c77a250 --- /dev/null +++ b/app/external/pal24_client.py @@ -0,0 +1,216 @@ +"""Async client for PayPalych (Pal24) API.""" + +from __future__ import annotations + +import asyncio +import hashlib +import logging +from dataclasses import dataclass +from decimal import Decimal, InvalidOperation +from typing import Any, Dict, Optional + +import aiohttp + +from app.config import settings + +logger = logging.getLogger(__name__) + + +class Pal24APIError(Exception): + """Base error for Pal24 API operations.""" + + +@dataclass(slots=True) +class Pal24Response: + """Wrapper for Pal24 API responses.""" + + success: bool + data: Dict[str, Any] + status: int + + @classmethod + def from_payload(cls, payload: Dict[str, Any], status: int) -> "Pal24Response": + success = bool(payload.get("success", status < 400)) + return cls(success=success, data=payload, status=status) + + def raise_for_status(self, endpoint: str) -> None: + if not self.success: + detail = self.data.get("message") or self.data.get("error") + raise Pal24APIError( + f"Pal24 API error at {endpoint}: status={self.status}, detail={detail or self.data}" + ) + + +class Pal24Client: + """Async client implementing PayPalych API methods.""" + + def __init__( + self, + *, + api_token: Optional[str] = None, + base_url: Optional[str] = None, + timeout: Optional[int] = None, + ) -> None: + self.api_token = api_token or settings.PAL24_API_TOKEN + self.base_url = (base_url or settings.PAL24_BASE_URL or "").rstrip("/") + "/" + self.timeout = timeout or settings.PAL24_REQUEST_TIMEOUT + + if not self.api_token: + logger.warning("Pal24Client initialized without API token") + + @property + def is_configured(self) -> bool: + return bool(self.api_token and self.base_url) + + async def _request( + self, + method: str, + endpoint: str, + *, + json_payload: Optional[Dict[str, Any]] = None, + params: Optional[Dict[str, Any]] = None, + ) -> Pal24Response: + if not self.is_configured: + raise Pal24APIError("Pal24 client is not configured") + + url = f"{self.base_url}{endpoint.lstrip('/')}" + headers = { + "Authorization": f"Bearer {self.api_token}", + "Content-Type": "application/json", + "Accept": "application/json", + } + + timeout = aiohttp.ClientTimeout(total=self.timeout) + + try: + async with aiohttp.ClientSession(timeout=timeout) as session: + async with session.request( + method, + url, + headers=headers, + json=json_payload, + params=params, + ) as response: + status = response.status + try: + payload = await response.json(content_type=None) + except aiohttp.ContentTypeError: + text_body = await response.text() + logger.error( + "Pal24 API returned non-JSON response for %s: %s", + endpoint, + text_body, + ) + raise Pal24APIError( + f"Pal24 API returned non-JSON response: {text_body}" + ) from None + + result = Pal24Response.from_payload(payload, status) + if status >= 400 or not result.success: + logger.error( + "Pal24 API error %s %s: %s", + status, + endpoint, + payload, + ) + result.raise_for_status(endpoint) + + return result + + except asyncio.TimeoutError as error: + logger.error("Pal24 API request timeout for %s: %s", endpoint, error) + raise Pal24APIError(f"Pal24 API request timeout for {endpoint}") from error + except aiohttp.ClientError as error: + logger.error("Pal24 API client error for %s: %s", endpoint, error) + raise Pal24APIError(str(error)) from error + + # API methods ----------------------------------------------------------------- + + async def create_bill( + self, + *, + amount: Decimal, + shop_id: str, + order_id: Optional[str] = None, + description: Optional[str] = None, + currency_in: str = "RUB", + type_: str = "normal", + **kwargs: Any, + ) -> Dict[str, Any]: + payload: Dict[str, Any] = { + "amount": str(amount), + "shop_id": shop_id, + "currency_in": currency_in, + "type": type_, + } + + if order_id: + payload["order_id"] = order_id + if description: + payload["description"] = description + + payload.update({k: v for k, v in kwargs.items() if v is not None}) + + response = await self._request("POST", "bill/create", json_payload=payload) + return response.data + + async def get_bill_status(self, bill_id: str) -> Dict[str, Any]: + response = await self._request("GET", "bill/status", params={"id": bill_id}) + return response.data + + async def toggle_bill_activity(self, bill_id: str, active: bool) -> Dict[str, Any]: + payload = {"id": bill_id, "active": 1 if active else 0} + response = await self._request("POST", "bill/toggle_activity", json_payload=payload) + return response.data + + async def search_payments(self, **params: Any) -> Dict[str, Any]: + response = await self._request("GET", "payment/search", params=params) + return response.data + + async def get_payment_status(self, payment_id: str) -> Dict[str, Any]: + response = await self._request("GET", "payment/status", params={"id": payment_id}) + return response.data + + async def get_balance(self) -> Dict[str, Any]: + response = await self._request("GET", "merchant/balance") + return response.data + + async def search_bills(self, **params: Any) -> Dict[str, Any]: + response = await self._request("GET", "bill/search", params=params) + return response.data + + async def get_bill_payments(self, bill_id: str) -> Dict[str, Any]: + response = await self._request("GET", "bill/payments", params={"id": bill_id}) + return response.data + + # Helpers --------------------------------------------------------------------- + + @staticmethod + def calculate_signature(out_sum: str, inv_id: str, api_token: Optional[str] = None) -> str: + token = api_token or settings.PAL24_SIGNATURE_TOKEN or settings.PAL24_API_TOKEN + if not token: + raise Pal24APIError("Pal24 signature token is not configured") + raw = f"{out_sum}:{inv_id}:{token}".encode("utf-8") + return hashlib.md5(raw).hexdigest().upper() + + @staticmethod + def verify_signature( + out_sum: str, + inv_id: str, + signature: str, + api_token: Optional[str] = None, + ) -> bool: + try: + expected = Pal24Client.calculate_signature(out_sum, inv_id, api_token) + except Pal24APIError: + logger.error("Pal24 signature verification failed: missing token") + return False + return expected == signature.upper() + + @staticmethod + def normalize_amount(amount_kopeks: int) -> Decimal: + try: + return (Decimal(amount_kopeks) / Decimal("100")).quantize(Decimal("0.01")) + except (InvalidOperation, TypeError) as error: + raise Pal24APIError(f"Invalid amount: {amount_kopeks}") from error + diff --git a/app/external/pal24_webhook.py b/app/external/pal24_webhook.py new file mode 100644 index 00000000..28b87ea2 --- /dev/null +++ b/app/external/pal24_webhook.py @@ -0,0 +1,155 @@ +"""Flask webhook server for PayPalych postbacks.""" + +from __future__ import annotations + +import asyncio +import json +import logging +import threading +from asyncio import AbstractEventLoop +from typing import Any, Dict, Optional + +from flask import Flask, jsonify, request +from werkzeug.serving import make_server + +from app.config import settings +from app.database.database import get_db +from app.services.pal24_service import Pal24Service, Pal24APIError +from app.services.payment_service import PaymentService + +logger = logging.getLogger(__name__) + + +def _normalize_payload() -> Dict[str, str]: + if request.is_json: + payload = request.get_json(silent=True) or {} + if isinstance(payload, dict): + return {k: str(v) for k, v in payload.items()} + logger.warning("Pal24 webhook JSON payload не является объектом: %s", payload) + return {} + + if request.form: + return {k: v for k, v in request.form.items()} + + try: + raw_body = request.data.decode("utf-8") + if raw_body: + payload = json.loads(raw_body) + if isinstance(payload, dict): + return {k: str(v) for k, v in payload.items()} + except json.JSONDecodeError: + logger.debug("Pal24 webhook body не удалось распарсить как JSON") + + return {} + + +def create_pal24_flask_app( + payment_service: PaymentService, + loop: AbstractEventLoop, +) -> Flask: + pal24_service = Pal24Service() + app = Flask(__name__) + + @app.route(settings.PAL24_WEBHOOK_PATH, methods=["POST"]) + def pal24_webhook() -> tuple: + if not pal24_service.is_configured: + logger.error("Pal24 webhook получен, но сервис не настроен") + return jsonify({"status": "error", "reason": "service_not_configured"}), 503 + + payload = _normalize_payload() + if not payload: + logger.warning("Пустой Pal24 webhook") + return jsonify({"status": "error", "reason": "empty_payload"}), 400 + + try: + parsed_payload = pal24_service.parse_postback(payload) + except Pal24APIError as error: + logger.error("Ошибка валидации Pal24 webhook: %s", error) + return jsonify({"status": "error", "reason": str(error)}), 400 + + async def process() -> bool: + async for db in get_db(): + try: + return await payment_service.process_pal24_postback(db, parsed_payload) + finally: + await db.close() + + try: + future = asyncio.run_coroutine_threadsafe(process(), loop) + processed = future.result() + except Exception as error: # pragma: no cover - defensive + logger.exception("Критическая ошибка обработки Pal24 webhook: %s", error) + return jsonify({"status": "error", "reason": "internal_error"}), 500 + + if processed: + return jsonify({"status": "ok"}), 200 + return jsonify({"status": "error", "reason": "not_processed"}), 400 + + @app.route(settings.PAL24_WEBHOOK_PATH, methods=["GET"]) + def pal24_health() -> tuple: + return jsonify({ + "status": "ok", + "service": "pal24_webhook", + "enabled": settings.is_pal24_enabled(), + }), 200 + + @app.route("/pal24/health", methods=["GET"]) + def pal24_additional_health() -> tuple: + return jsonify({ + "status": "ok", + "service": "pal24_webhook", + "path": settings.PAL24_WEBHOOK_PATH, + }), 200 + + return app + + +class Pal24WebhookServer: + """Threaded Flask server for Pal24 postbacks.""" + + def __init__(self, payment_service: PaymentService, loop: AbstractEventLoop) -> None: + self.app = create_pal24_flask_app(payment_service, loop) + self._server: Optional[Any] = None + self._thread: Optional[threading.Thread] = None + + def start(self) -> None: + if self._server: + logger.warning("Pal24 webhook server уже запущен") + return + + self._server = make_server( + host="0.0.0.0", + port=settings.PAL24_WEBHOOK_PORT, + app=self.app, + threaded=True, + ) + + def _serve() -> None: + logger.info( + "Pal24 webhook сервер запущен на %s:%s%s", + "0.0.0.0", + settings.PAL24_WEBHOOK_PORT, + settings.PAL24_WEBHOOK_PATH, + ) + self._server.serve_forever() + + self._thread = threading.Thread(target=_serve, daemon=True) + self._thread.start() + + def stop(self) -> None: + if self._server: + logger.info("Останавливаем Pal24 webhook сервер") + self._server.shutdown() + self._server = None + + if self._thread and self._thread.is_alive(): + self._thread.join(timeout=5) + self._thread = None + + +async def start_pal24_webhook_server(payment_service: PaymentService) -> Pal24WebhookServer: + loop = asyncio.get_running_loop() + server = Pal24WebhookServer(payment_service, loop) + await loop.run_in_executor(None, server.start) + return server + diff --git a/app/external/remnawave_api.py b/app/external/remnawave_api.py index ec553efb..8949dad9 100644 --- a/app/external/remnawave_api.py +++ b/app/external/remnawave_api.py @@ -58,6 +58,8 @@ class RemnaWaveUser: ss_password: Optional[str] = None first_connected_at: Optional[datetime] = None last_triggered_threshold: int = 0 + happ_link: Optional[str] = None + happ_crypto_link: Optional[str] = None @dataclass @@ -92,6 +94,8 @@ class SubscriptionInfo: ss_conf_links: Dict[str, str] subscription_url: str happ: Optional[Dict[str, str]] + happ_link: Optional[str] = None + happ_crypto_link: Optional[str] = None class RemnaWaveAPIError(Exception): @@ -584,6 +588,10 @@ class RemnaWaveAPI: def _parse_user(self, user_data: Dict) -> RemnaWaveUser: + happ_data = user_data.get('happ') or {} + happ_link = happ_data.get('link') or happ_data.get('url') + happ_crypto_link = happ_data.get('cryptoLink') or happ_data.get('crypto_link') + return RemnaWaveUser( uuid=user_data['uuid'], short_uuid=user_data['shortUuid'], @@ -612,7 +620,9 @@ class RemnaWaveAPI: vless_uuid=user_data.get('vlessUuid'), ss_password=user_data.get('ssPassword'), first_connected_at=self._parse_optional_datetime(user_data.get('firstConnectedAt')), - last_triggered_threshold=user_data.get('lastTriggeredThreshold', 0) + last_triggered_threshold=user_data.get('lastTriggeredThreshold', 0), + happ_link=happ_link, + happ_crypto_link=happ_crypto_link ) def _parse_optional_datetime(self, date_str: Optional[str]) -> Optional[datetime]: @@ -645,13 +655,19 @@ class RemnaWaveAPI: ) def _parse_subscription_info(self, data: Dict) -> SubscriptionInfo: + happ_data = data.get('happ') or {} + happ_link = happ_data.get('link') or happ_data.get('url') + happ_crypto_link = happ_data.get('cryptoLink') or happ_data.get('crypto_link') + return SubscriptionInfo( is_found=data['isFound'], user=data.get('user'), links=data.get('links', []), ss_conf_links=data.get('ssConfLinks', {}), subscription_url=data.get('subscriptionUrl', ''), - happ=data.get('happ') + happ=data.get('happ'), + happ_link=happ_link, + happ_crypto_link=happ_crypto_link ) diff --git a/app/external/webhook_server.py b/app/external/webhook_server.py index 0abe9166..9ea90945 100644 --- a/app/external/webhook_server.py +++ b/app/external/webhook_server.py @@ -1,3 +1,5 @@ +import hashlib +import hmac import logging import json from typing import Optional @@ -7,6 +9,8 @@ from aiogram import Bot from app.config import settings from app.services.tribute_service import TributeService +from app.services.payment_service import PaymentService +from app.database.database import get_db logger = logging.getLogger(__name__) @@ -25,18 +29,25 @@ class WebhookServer: self.app = web.Application() self.app.router.add_post(settings.TRIBUTE_WEBHOOK_PATH, self._tribute_webhook_handler) - + + if settings.is_mulenpay_enabled(): + self.app.router.add_post(settings.MULENPAY_WEBHOOK_PATH, self._mulenpay_webhook_handler) + if settings.is_cryptobot_enabled(): self.app.router.add_post(settings.CRYPTOBOT_WEBHOOK_PATH, self._cryptobot_webhook_handler) self.app.router.add_get('/health', self._health_check) self.app.router.add_options(settings.TRIBUTE_WEBHOOK_PATH, self._options_handler) + if settings.is_mulenpay_enabled(): + self.app.router.add_options(settings.MULENPAY_WEBHOOK_PATH, self._options_handler) if settings.is_cryptobot_enabled(): self.app.router.add_options(settings.CRYPTOBOT_WEBHOOK_PATH, self._options_handler) logger.info(f"Webhook сервер настроен:") logger.info(f" - Tribute webhook: POST {settings.TRIBUTE_WEBHOOK_PATH}") + if settings.is_mulenpay_enabled(): + logger.info(f" - Mulen Pay webhook: POST {settings.MULENPAY_WEBHOOK_PATH}") if settings.is_cryptobot_enabled(): logger.info(f" - CryptoBot webhook: POST {settings.CRYPTOBOT_WEBHOOK_PATH}") logger.info(f" - Health check: GET /health") @@ -62,6 +73,10 @@ class WebhookServer: logger.info(f"Webhook сервер запущен на порту {settings.TRIBUTE_WEBHOOK_PORT}") logger.info(f"Tribute webhook URL: http://0.0.0.0:{settings.TRIBUTE_WEBHOOK_PORT}{settings.TRIBUTE_WEBHOOK_PATH}") + if settings.is_mulenpay_enabled(): + logger.info( + f"Mulen Pay webhook URL: http://0.0.0.0:{settings.TRIBUTE_WEBHOOK_PORT}{settings.MULENPAY_WEBHOOK_PATH}" + ) if settings.is_cryptobot_enabled(): logger.info(f"CryptoBot webhook URL: http://0.0.0.0:{settings.TRIBUTE_WEBHOOK_PORT}{settings.CRYPTOBOT_WEBHOOK_PATH}") @@ -89,12 +104,81 @@ class WebhookServer: headers={ 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Methods': 'POST, GET, OPTIONS', - 'Access-Control-Allow-Headers': 'Content-Type, trbt-signature, Crypto-Pay-API-Signature', + 'Access-Control-Allow-Headers': 'Content-Type, trbt-signature, Crypto-Pay-API-Signature, X-MulenPay-Signature, Authorization', } ) - + + async def _mulenpay_webhook_handler(self, request: web.Request) -> web.Response: + try: + logger.info(f"Получен Mulen Pay webhook: {request.method} {request.path}") + raw_body = await request.read() + + if not raw_body: + logger.warning("Пустой Mulen Pay webhook") + return web.json_response({"status": "error", "reason": "empty_body"}, status=400) + + if not self._verify_mulenpay_signature(request, raw_body): + return web.json_response({"status": "error", "reason": "invalid_signature"}, status=401) + + try: + payload = json.loads(raw_body.decode('utf-8')) + except json.JSONDecodeError as error: + logger.error(f"Ошибка парсинга Mulen Pay webhook: {error}") + return web.json_response({"status": "error", "reason": "invalid_json"}, status=400) + + payment_service = PaymentService(self.bot) + + async for db in get_db(): + try: + success = await payment_service.process_mulenpay_callback(db, payload) + if success: + return web.json_response({"status": "ok"}, status=200) + return web.json_response({"status": "error", "reason": "processing_failed"}, status=400) + except Exception as error: + logger.error(f"Ошибка обработки Mulen Pay webhook: {error}", exc_info=True) + return web.json_response({"status": "error", "reason": "internal_error"}, status=500) + finally: + break + + except Exception as error: + logger.error(f"Критическая ошибка Mulen Pay webhook: {error}", exc_info=True) + return web.json_response({"status": "error", "reason": "internal_error", "message": str(error)}, status=500) + + @staticmethod + def _verify_mulenpay_signature(request: web.Request, raw_body: bytes) -> bool: + secret_key = settings.MULENPAY_SECRET_KEY + if not secret_key: + logger.error("Mulen Pay secret key is not configured") + return False + + signature = request.headers.get('X-MulenPay-Signature') + if signature: + expected_signature = hmac.new( + secret_key.encode('utf-8'), + raw_body, + hashlib.sha256, + ).hexdigest() + + if hmac.compare_digest(signature.strip().lower(), expected_signature.lower()): + return True + + logger.error("Неверная подпись Mulen Pay webhook") + return False + + authorization_header = request.headers.get('Authorization') + if authorization_header and authorization_header.startswith('Bearer '): + token = authorization_header.split(' ', 1)[1].strip() + if hmac.compare_digest(token, secret_key): + return True + + logger.error("Неверный Bearer токен Mulen Pay webhook") + return False + + logger.error("Отсутствует подпись Mulen Pay webhook") + return False + async def _tribute_webhook_handler(self, request: web.Request) -> web.Response: - + try: logger.info(f"Получен Tribute webhook: {request.method} {request.path}") logger.info(f"Headers: {dict(request.headers)}") diff --git a/app/handlers/admin/main.py b/app/handlers/admin/main.py index dfce24d7..5a7b6669 100644 --- a/app/handlers/admin/main.py +++ b/app/handlers/admin/main.py @@ -10,14 +10,18 @@ from app.keyboards.admin import ( get_admin_users_submenu_keyboard, get_admin_promo_submenu_keyboard, get_admin_communications_submenu_keyboard, + get_admin_support_submenu_keyboard, get_admin_settings_submenu_keyboard, get_admin_system_submenu_keyboard ) from app.localization.texts import get_texts from app.handlers.admin import support_settings as support_settings_handlers from app.utils.decorators import admin_required, error_handler +from app.services.support_settings_service import SupportSettingsService from app.database.crud.rules import clear_all_rules, get_rules_statistics from app.localization.texts import clear_rules_cache +from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton +from app.database.crud.ticket import TicketCRUD logger = logging.getLogger(__name__) @@ -113,6 +117,115 @@ async def show_communications_submenu( await callback.answer() +@admin_required +@error_handler +async def show_support_submenu( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession +): + texts = get_texts(db_user.language) + # Moderators have access only to tickets and not to settings + is_moderator_only = (not settings.is_admin(callback.from_user.id) and SupportSettingsService.is_moderator(callback.from_user.id)) + + from app.keyboards.admin import get_admin_support_submenu_keyboard + kb = get_admin_support_submenu_keyboard(db_user.language) + if is_moderator_only: + # Rebuild keyboard to include only tickets and back to main menu + kb = InlineKeyboardMarkup(inline_keyboard=[ + [InlineKeyboardButton(text="🎫 Тикеты поддержки", callback_data="admin_tickets")], + [InlineKeyboardButton(text="⬅️ Назад", callback_data="back_to_menu")] + ]) + await callback.message.edit_text( + "🛟 **Поддержка**\n\n" + ("Доступ к тикетам." if is_moderator_only else "Управление тикетами и настройками поддержки:"), + reply_markup=kb, + parse_mode="Markdown" + ) + await callback.answer() + + +# Moderator panel entry (from main menu quick button) +async def show_moderator_panel( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession +): + kb = InlineKeyboardMarkup(inline_keyboard=[ + [InlineKeyboardButton(text="🎫 Тикеты поддержки", callback_data="admin_tickets")], + [InlineKeyboardButton(text="⬅️ В главное меню", callback_data="back_to_menu")] + ]) + await callback.message.edit_text( + "🧑‍⚖️ Модерация поддержки\n\nДоступ к тикетам поддержки.", + parse_mode="HTML", + reply_markup=kb + ) + await callback.answer() + + +@admin_required +@error_handler +async def show_support_audit( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession +): + # pagination + page = 1 + if callback.data.startswith("admin_support_audit_page_"): + try: + page = int(callback.data.split("_")[-1]) + except Exception: + page = 1 + per_page = 10 + total = await TicketCRUD.count_support_audit(db) + total_pages = max(1, (total + per_page - 1) // per_page) + if page < 1: + page = 1 + if page > total_pages: + page = total_pages + offset = (page - 1) * per_page + logs = await TicketCRUD.list_support_audit(db, limit=per_page, offset=offset) + + lines = ["🧾 Аудит модераторов", ""] + if not logs: + lines.append("Пока пусто") + else: + for log in logs: + role = "Модератор" if getattr(log, 'is_moderator', False) else "Админ" + ts = log.created_at.strftime('%d.%m.%Y %H:%M') if getattr(log, 'created_at', None) else '' + action_map = { + 'close_ticket': 'Закрытие тикета', + 'block_user_timed': 'Блокировка (время)', + 'block_user_perm': 'Блокировка (навсегда)', + 'unblock_user': 'Снятие блока', + } + action_text = action_map.get(log.action, log.action) + ticket_part = f" тикет #{log.ticket_id}" if log.ticket_id else "" + details = log.details or {} + extra = "" + if log.action == 'block_user_timed' and 'minutes' in details: + extra = f" ({details['minutes']} мин)" + lines.append(f"{ts} • {role} {log.actor_telegram_id} — {action_text}{ticket_part}{extra}") + + # keyboard with pagination + nav_row = [] + if total_pages > 1: + if page > 1: + nav_row.append(InlineKeyboardButton(text="⬅️", callback_data=f"admin_support_audit_page_{page-1}")) + nav_row.append(InlineKeyboardButton(text=f"{page}/{total_pages}", callback_data="current_page")) + if page < total_pages: + nav_row.append(InlineKeyboardButton(text="➡️", callback_data=f"admin_support_audit_page_{page+1}")) + + kb_rows = [] + if nav_row: + kb_rows.append(nav_row) + kb_rows.append([InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_submenu_support")]) + kb = InlineKeyboardMarkup(inline_keyboard=kb_rows) + + await callback.message.edit_text("\n".join(lines), parse_mode="HTML", reply_markup=kb) + await callback.answer() + + @admin_required @error_handler async def show_settings_submenu( @@ -142,7 +255,7 @@ async def show_system_submenu( await callback.message.edit_text( "🛠️ **Системные функции**\n\n" - "Обновления, резервные копии и системные операции:", + "Отчеты, обновления, резервные копии и системные операции:", reply_markup=get_admin_system_submenu_keyboard(db_user.language), parse_mode="Markdown" ) @@ -285,6 +398,15 @@ def register_handlers(dp: Dispatcher): F.data == "admin_submenu_communications" ) + dp.callback_query.register( + show_support_submenu, + F.data == "admin_submenu_support" + ) + dp.callback_query.register( + show_support_audit, + F.data.in_(["admin_support_audit"]) | F.data.startswith("admin_support_audit_page_") + ) + dp.callback_query.register( show_settings_submenu, F.data == "admin_submenu_settings" @@ -294,6 +416,10 @@ def register_handlers(dp: Dispatcher): show_system_submenu, F.data == "admin_submenu_system" ) + dp.callback_query.register( + show_moderator_panel, + F.data == "moderator_panel" + ) # Support settings module support_settings_handlers.register_handlers(dp) diff --git a/app/handlers/admin/monitoring.py b/app/handlers/admin/monitoring.py index be876876..e707b2ac 100644 --- a/app/handlers/admin/monitoring.py +++ b/app/handlers/admin/monitoring.py @@ -2,8 +2,10 @@ import asyncio import logging from datetime import datetime, timedelta from aiogram import Router, F -from aiogram.types import Message, CallbackQuery +from aiogram.types import Message, CallbackQuery, InlineKeyboardMarkup, InlineKeyboardButton from aiogram.filters import Command +from aiogram.fsm.context import FSMContext +from aiogram.exceptions import TelegramBadRequest from app.config import settings from app.database.database import get_db @@ -12,11 +14,334 @@ from app.utils.decorators import admin_required from app.utils.pagination import paginate_list from app.keyboards.admin import get_monitoring_keyboard, get_admin_main_keyboard from app.localization.texts import get_texts +from app.services.notification_settings_service import NotificationSettingsService +from app.states import AdminStates logger = logging.getLogger(__name__) router = Router() +def _format_toggle(enabled: bool) -> str: + return "🟢 Вкл" if enabled else "🔴 Выкл" + + +def _build_notification_settings_view(language: str): + texts = get_texts(language) + config = NotificationSettingsService.get_config() + + second_percent = NotificationSettingsService.get_second_wave_discount_percent() + second_hours = NotificationSettingsService.get_second_wave_valid_hours() + third_percent = NotificationSettingsService.get_third_wave_discount_percent() + third_hours = NotificationSettingsService.get_third_wave_valid_hours() + third_days = NotificationSettingsService.get_third_wave_trigger_days() + + trial_1h_status = _format_toggle(config["trial_inactive_1h"].get("enabled", True)) + trial_24h_status = _format_toggle(config["trial_inactive_24h"].get("enabled", True)) + expired_1d_status = _format_toggle(config["expired_1d"].get("enabled", True)) + second_wave_status = _format_toggle(config["expired_second_wave"].get("enabled", True)) + third_wave_status = _format_toggle(config["expired_third_wave"].get("enabled", True)) + + summary_text = ( + "🔔 Уведомления пользователям\n\n" + f"• 1 час после триала: {trial_1h_status}\n" + f"• 24 часа после триала: {trial_24h_status}\n" + f"• 1 день после истечения: {expired_1d_status}\n" + f"• 2-3 дня (скидка {second_percent}% / {second_hours} ч): {second_wave_status}\n" + f"• {third_days} дней (скидка {third_percent}% / {third_hours} ч): {third_wave_status}" + ) + + from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton + + keyboard = InlineKeyboardMarkup(inline_keyboard=[ + [InlineKeyboardButton(text=f"{trial_1h_status} • 1 час после триала", callback_data="admin_mon_notify_toggle_trial_1h")], + [InlineKeyboardButton(text="🧪 Тест: 1 час после триала", callback_data="admin_mon_notify_preview_trial_1h")], + [InlineKeyboardButton(text=f"{trial_24h_status} • 24 часа после триала", callback_data="admin_mon_notify_toggle_trial_24h")], + [InlineKeyboardButton(text="🧪 Тест: 24 часа после триала", callback_data="admin_mon_notify_preview_trial_24h")], + [InlineKeyboardButton(text=f"{expired_1d_status} • 1 день после истечения", callback_data="admin_mon_notify_toggle_expired_1d")], + [InlineKeyboardButton(text="🧪 Тест: 1 день после истечения", callback_data="admin_mon_notify_preview_expired_1d")], + [InlineKeyboardButton(text=f"{second_wave_status} • 2-3 дня со скидкой", callback_data="admin_mon_notify_toggle_expired_2d")], + [InlineKeyboardButton(text="🧪 Тест: скидка 2-3 день", callback_data="admin_mon_notify_preview_expired_2d")], + [InlineKeyboardButton(text=f"✏️ Скидка 2-3 дня: {second_percent}%", callback_data="admin_mon_notify_edit_2d_percent")], + [InlineKeyboardButton(text=f"⏱️ Срок скидки 2-3 дня: {second_hours} ч", callback_data="admin_mon_notify_edit_2d_hours")], + [InlineKeyboardButton(text=f"{third_wave_status} • {third_days} дней со скидкой", callback_data="admin_mon_notify_toggle_expired_nd")], + [InlineKeyboardButton(text="🧪 Тест: скидка спустя дни", callback_data="admin_mon_notify_preview_expired_nd")], + [InlineKeyboardButton(text=f"✏️ Скидка {third_days} дней: {third_percent}%", callback_data="admin_mon_notify_edit_nd_percent")], + [InlineKeyboardButton(text=f"⏱️ Срок скидки {third_days} дней: {third_hours} ч", callback_data="admin_mon_notify_edit_nd_hours")], + [InlineKeyboardButton(text=f"📆 Порог уведомления: {third_days} дн.", callback_data="admin_mon_notify_edit_nd_threshold")], + [InlineKeyboardButton(text="🧪 Отправить все тесты", callback_data="admin_mon_notify_preview_all")], + [InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_mon_settings")], + ]) + + return summary_text, keyboard + + +def _build_notification_preview_message(language: str, notification_type: str): + texts = get_texts(language) + now = datetime.now() + price_30_days = settings.format_price(settings.PRICE_30_DAYS) + + from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton + + header = "🧪 Тестовое уведомление мониторинга\n\n" + + if notification_type == "trial_inactive_1h": + template = texts.get( + "TRIAL_INACTIVE_1H", + ( + "⏳ Прошёл час, а подключения нет\n\n" + "Если возникли сложности с запуском — воспользуйтесь инструкциями." + ), + ) + message = template.format( + price=price_30_days, + end_date=(now + timedelta(days=settings.TRIAL_DURATION_DAYS)).strftime("%d.%m.%Y %H:%M"), + ) + keyboard = InlineKeyboardMarkup( + inline_keyboard=[ + [ + InlineKeyboardButton( + text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"), + callback_data="subscription_connect", + ) + ], + [ + InlineKeyboardButton( + text=texts.t("MY_SUBSCRIPTION_BUTTON", "📱 Моя подписка"), + callback_data="menu_subscription", + ) + ], + [ + InlineKeyboardButton( + text=texts.t("SUPPORT_BUTTON", "🆘 Поддержка"), + callback_data="menu_support", + ) + ], + ] + ) + elif notification_type == "trial_inactive_24h": + template = texts.get( + "TRIAL_INACTIVE_24H", + ( + "⏳ Вы ещё не подключились к VPN\n\n" + "Прошли сутки с активации тестового периода, но трафик не зафиксирован." + "\n\nНажмите кнопку ниже, чтобы подключиться." + ), + ) + message = template.format( + price=price_30_days, + end_date=(now + timedelta(days=1)).strftime("%d.%m.%Y %H:%M"), + ) + keyboard = InlineKeyboardMarkup( + inline_keyboard=[ + [ + InlineKeyboardButton( + text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"), + callback_data="subscription_connect", + ) + ], + [ + InlineKeyboardButton( + text=texts.t("MY_SUBSCRIPTION_BUTTON", "📱 Моя подписка"), + callback_data="menu_subscription", + ) + ], + [ + InlineKeyboardButton( + text=texts.t("SUPPORT_BUTTON", "🆘 Поддержка"), + callback_data="menu_support", + ) + ], + ] + ) + elif notification_type == "expired_1d": + template = texts.get( + "SUBSCRIPTION_EXPIRED_1D", + ( + "⛔ Подписка закончилась\n\n" + "Доступ был отключён {end_date}. Продлите подписку, чтобы вернуться в сервис." + ), + ) + message = template.format( + end_date=(now - timedelta(days=1)).strftime("%d.%m.%Y %H:%M"), + price=price_30_days, + ) + keyboard = InlineKeyboardMarkup( + inline_keyboard=[ + [ + InlineKeyboardButton( + text=texts.t("SUBSCRIPTION_EXTEND", "💎 Продлить подписку"), + callback_data="subscription_extend", + ) + ], + [ + InlineKeyboardButton( + text=texts.t("BALANCE_TOPUP", "💳 Пополнить баланс"), + callback_data="balance_topup", + ) + ], + [ + InlineKeyboardButton( + text=texts.t("SUPPORT_BUTTON", "🆘 Поддержка"), + callback_data="menu_support", + ) + ], + ] + ) + elif notification_type == "expired_2d": + percent = NotificationSettingsService.get_second_wave_discount_percent() + valid_hours = NotificationSettingsService.get_second_wave_valid_hours() + bonus_amount = settings.PRICE_30_DAYS * percent // 100 + template = texts.get( + "SUBSCRIPTION_EXPIRED_SECOND_WAVE", + ( + "🔥 Скидка {percent}% на продление\n\n" + "Нажмите «Получить скидку», и мы начислим {bonus} на баланс. " + "Предложение действует до {expires_at}." + ), + ) + message = template.format( + percent=percent, + bonus=settings.format_price(bonus_amount), + expires_at=(now + timedelta(hours=valid_hours)).strftime("%d.%m.%Y %H:%M"), + trigger_days=3, + ) + keyboard = InlineKeyboardMarkup( + inline_keyboard=[ + [ + InlineKeyboardButton( + text="🎁 Получить скидку", + callback_data="claim_discount_preview", + ) + ], + [ + InlineKeyboardButton( + text=texts.t("SUBSCRIPTION_EXTEND", "💎 Продлить подписку"), + callback_data="subscription_extend", + ) + ], + [ + InlineKeyboardButton( + text=texts.t("BALANCE_TOPUP", "💳 Пополнить баланс"), + callback_data="balance_topup", + ) + ], + [ + InlineKeyboardButton( + text=texts.t("SUPPORT_BUTTON", "🆘 Поддержка"), + callback_data="menu_support", + ) + ], + ] + ) + elif notification_type == "expired_nd": + percent = NotificationSettingsService.get_third_wave_discount_percent() + valid_hours = NotificationSettingsService.get_third_wave_valid_hours() + trigger_days = NotificationSettingsService.get_third_wave_trigger_days() + bonus_amount = settings.PRICE_30_DAYS * percent // 100 + template = texts.get( + "SUBSCRIPTION_EXPIRED_THIRD_WAVE", + ( + "🎁 Индивидуальная скидка {percent}%\n\n" + "Прошло {trigger_days} дней без подписки — возвращайтесь, и мы добавим {bonus} на баланс. " + "Скидка действует до {expires_at}." + ), + ) + message = template.format( + percent=percent, + bonus=settings.format_price(bonus_amount), + trigger_days=trigger_days, + expires_at=(now + timedelta(hours=valid_hours)).strftime("%d.%m.%Y %H:%M"), + ) + keyboard = InlineKeyboardMarkup( + inline_keyboard=[ + [ + InlineKeyboardButton( + text="🎁 Получить скидку", + callback_data="claim_discount_preview", + ) + ], + [ + InlineKeyboardButton( + text=texts.t("SUBSCRIPTION_EXTEND", "💎 Продлить подписку"), + callback_data="subscription_extend", + ) + ], + [ + InlineKeyboardButton( + text=texts.t("BALANCE_TOPUP", "💳 Пополнить баланс"), + callback_data="balance_topup", + ) + ], + [ + InlineKeyboardButton( + text=texts.t("SUPPORT_BUTTON", "🆘 Поддержка"), + callback_data="menu_support", + ) + ], + ] + ) + else: + raise ValueError(f"Unsupported notification type: {notification_type}") + + footer = "\n\nСообщение отправлено только вам для проверки оформления." + return header + message + footer, keyboard + + +async def _send_notification_preview(bot, chat_id: int, language: str, notification_type: str) -> None: + message, keyboard = _build_notification_preview_message(language, notification_type) + await bot.send_message( + chat_id, + message, + parse_mode="HTML", + reply_markup=keyboard, + ) + + +async def _render_notification_settings(callback: CallbackQuery) -> None: + language = (callback.from_user.language_code or settings.DEFAULT_LANGUAGE) + text, keyboard = _build_notification_settings_view(language) + await callback.message.edit_text(text, parse_mode="HTML", reply_markup=keyboard) + + +async def _render_notification_settings_for_state( + bot, + chat_id: int, + message_id: int, + language: str, + business_connection_id: str | None = None, +) -> None: + text, keyboard = _build_notification_settings_view(language) + + edit_kwargs = { + "text": text, + "chat_id": chat_id, + "message_id": message_id, + "parse_mode": "HTML", + "reply_markup": keyboard, + } + + if business_connection_id: + edit_kwargs["business_connection_id"] = business_connection_id + + try: + await bot.edit_message_text(**edit_kwargs) + except TelegramBadRequest as exc: + if "no text in the message to edit" in (exc.message or "").lower(): + caption_kwargs = { + "chat_id": chat_id, + "message_id": message_id, + "caption": text, + "parse_mode": "HTML", + "reply_markup": keyboard, + } + + if business_connection_id: + caption_kwargs["business_connection_id"] = business_connection_id + + await bot.edit_message_caption(**caption_kwargs) + else: + raise + @router.callback_query(F.data == "admin_monitoring") @admin_required async def admin_monitoring_menu(callback: CallbackQuery): @@ -52,6 +377,265 @@ async def admin_monitoring_menu(callback: CallbackQuery): await callback.answer("❌ Ошибка получения данных", show_alert=True) +@router.callback_query(F.data == "admin_mon_settings") +@admin_required +async def admin_monitoring_settings(callback: CallbackQuery): + try: + language = callback.from_user.language_code or settings.DEFAULT_LANGUAGE + global_status = "🟢 Включены" if NotificationSettingsService.are_notifications_globally_enabled() else "🔴 Отключены" + second_percent = NotificationSettingsService.get_second_wave_discount_percent() + third_percent = NotificationSettingsService.get_third_wave_discount_percent() + third_days = NotificationSettingsService.get_third_wave_trigger_days() + + text = ( + "⚙️ Настройки мониторинга\n\n" + f"🔔 Уведомления пользователям: {global_status}\n" + f"• Скидка 2-3 дня: {second_percent}%\n" + f"• Скидка после {third_days} дней: {third_percent}%\n\n" + "Выберите раздел для настройки." + ) + + from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton + + keyboard = InlineKeyboardMarkup(inline_keyboard=[ + [InlineKeyboardButton(text="🔔 Уведомления пользователям", callback_data="admin_mon_notify_settings")], + [InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_submenu_settings")], + ]) + + await callback.message.edit_text(text, parse_mode="HTML", reply_markup=keyboard) + + except Exception as e: + logger.error(f"Ошибка отображения настроек мониторинга: {e}") + await callback.answer("❌ Не удалось открыть настройки", show_alert=True) + + +@router.callback_query(F.data == "admin_mon_notify_settings") +@admin_required +async def admin_notify_settings(callback: CallbackQuery): + try: + await _render_notification_settings(callback) + except Exception as e: + logger.error(f"Ошибка отображения настроек уведомлений: {e}") + await callback.answer("❌ Не удалось загрузить настройки", show_alert=True) + + +@router.callback_query(F.data == "admin_mon_notify_toggle_trial_1h") +@admin_required +async def toggle_trial_1h_notification(callback: CallbackQuery): + enabled = NotificationSettingsService.is_trial_inactive_1h_enabled() + NotificationSettingsService.set_trial_inactive_1h_enabled(not enabled) + await callback.answer("✅ Включено" if not enabled else "⏸️ Отключено") + await _render_notification_settings(callback) + + +@router.callback_query(F.data == "admin_mon_notify_preview_trial_1h") +@admin_required +async def preview_trial_1h_notification(callback: CallbackQuery): + try: + language = callback.from_user.language_code or settings.DEFAULT_LANGUAGE + await _send_notification_preview(callback.bot, callback.from_user.id, language, "trial_inactive_1h") + await callback.answer("✅ Пример отправлен") + except Exception as exc: + logger.error("Failed to send trial 1h preview: %s", exc) + await callback.answer("❌ Не удалось отправить тест", show_alert=True) + + +@router.callback_query(F.data == "admin_mon_notify_toggle_trial_24h") +@admin_required +async def toggle_trial_24h_notification(callback: CallbackQuery): + enabled = NotificationSettingsService.is_trial_inactive_24h_enabled() + NotificationSettingsService.set_trial_inactive_24h_enabled(not enabled) + await callback.answer("✅ Включено" if not enabled else "⏸️ Отключено") + await _render_notification_settings(callback) + + +@router.callback_query(F.data == "admin_mon_notify_preview_trial_24h") +@admin_required +async def preview_trial_24h_notification(callback: CallbackQuery): + try: + language = callback.from_user.language_code or settings.DEFAULT_LANGUAGE + await _send_notification_preview(callback.bot, callback.from_user.id, language, "trial_inactive_24h") + await callback.answer("✅ Пример отправлен") + except Exception as exc: + logger.error("Failed to send trial 24h preview: %s", exc) + await callback.answer("❌ Не удалось отправить тест", show_alert=True) + + +@router.callback_query(F.data == "admin_mon_notify_toggle_expired_1d") +@admin_required +async def toggle_expired_1d_notification(callback: CallbackQuery): + enabled = NotificationSettingsService.is_expired_1d_enabled() + NotificationSettingsService.set_expired_1d_enabled(not enabled) + await callback.answer("✅ Включено" if not enabled else "⏸️ Отключено") + await _render_notification_settings(callback) + + +@router.callback_query(F.data == "admin_mon_notify_preview_expired_1d") +@admin_required +async def preview_expired_1d_notification(callback: CallbackQuery): + try: + language = callback.from_user.language_code or settings.DEFAULT_LANGUAGE + await _send_notification_preview(callback.bot, callback.from_user.id, language, "expired_1d") + await callback.answer("✅ Пример отправлен") + except Exception as exc: + logger.error("Failed to send expired 1d preview: %s", exc) + await callback.answer("❌ Не удалось отправить тест", show_alert=True) + + +@router.callback_query(F.data == "admin_mon_notify_toggle_expired_2d") +@admin_required +async def toggle_second_wave_notification(callback: CallbackQuery): + enabled = NotificationSettingsService.is_second_wave_enabled() + NotificationSettingsService.set_second_wave_enabled(not enabled) + await callback.answer("✅ Включено" if not enabled else "⏸️ Отключено") + await _render_notification_settings(callback) + + +@router.callback_query(F.data == "admin_mon_notify_preview_expired_2d") +@admin_required +async def preview_second_wave_notification(callback: CallbackQuery): + try: + language = callback.from_user.language_code or settings.DEFAULT_LANGUAGE + await _send_notification_preview(callback.bot, callback.from_user.id, language, "expired_2d") + await callback.answer("✅ Пример отправлен") + except Exception as exc: + logger.error("Failed to send second wave preview: %s", exc) + await callback.answer("❌ Не удалось отправить тест", show_alert=True) + + +@router.callback_query(F.data == "admin_mon_notify_toggle_expired_nd") +@admin_required +async def toggle_third_wave_notification(callback: CallbackQuery): + enabled = NotificationSettingsService.is_third_wave_enabled() + NotificationSettingsService.set_third_wave_enabled(not enabled) + await callback.answer("✅ Включено" if not enabled else "⏸️ Отключено") + await _render_notification_settings(callback) + + +@router.callback_query(F.data == "admin_mon_notify_preview_expired_nd") +@admin_required +async def preview_third_wave_notification(callback: CallbackQuery): + try: + language = callback.from_user.language_code or settings.DEFAULT_LANGUAGE + await _send_notification_preview(callback.bot, callback.from_user.id, language, "expired_nd") + await callback.answer("✅ Пример отправлен") + except Exception as exc: + logger.error("Failed to send third wave preview: %s", exc) + await callback.answer("❌ Не удалось отправить тест", show_alert=True) + + +@router.callback_query(F.data == "admin_mon_notify_preview_all") +@admin_required +async def preview_all_notifications(callback: CallbackQuery): + try: + language = callback.from_user.language_code or settings.DEFAULT_LANGUAGE + chat_id = callback.from_user.id + for notification_type in [ + "trial_inactive_1h", + "trial_inactive_24h", + "expired_1d", + "expired_2d", + "expired_nd", + ]: + await _send_notification_preview(callback.bot, chat_id, language, notification_type) + await callback.answer("✅ Все тестовые уведомления отправлены") + except Exception as exc: + logger.error("Failed to send all notification previews: %s", exc) + await callback.answer("❌ Не удалось отправить тесты", show_alert=True) + + +async def _start_notification_value_edit( + callback: CallbackQuery, + state: FSMContext, + setting_key: str, + field: str, + prompt_key: str, + default_prompt: str, +): + language = callback.from_user.language_code or settings.DEFAULT_LANGUAGE + await state.set_state(AdminStates.editing_notification_value) + await state.update_data( + notification_setting_key=setting_key, + notification_setting_field=field, + settings_message_chat=callback.message.chat.id, + settings_message_id=callback.message.message_id, + settings_business_connection_id=( + str(getattr(callback.message, "business_connection_id", None)) + if getattr(callback.message, "business_connection_id", None) is not None + else None + ), + settings_language=language, + ) + texts = get_texts(language) + await callback.answer() + await callback.message.answer(texts.get(prompt_key, default_prompt)) + + +@router.callback_query(F.data == "admin_mon_notify_edit_2d_percent") +@admin_required +async def edit_second_wave_percent(callback: CallbackQuery, state: FSMContext): + await _start_notification_value_edit( + callback, + state, + "expired_second_wave", + "percent", + "NOTIFY_PROMPT_SECOND_PERCENT", + "Введите новый процент скидки для уведомления через 2-3 дня (0-100):", + ) + + +@router.callback_query(F.data == "admin_mon_notify_edit_2d_hours") +@admin_required +async def edit_second_wave_hours(callback: CallbackQuery, state: FSMContext): + await _start_notification_value_edit( + callback, + state, + "expired_second_wave", + "hours", + "NOTIFY_PROMPT_SECOND_HOURS", + "Введите количество часов действия скидки (1-168):", + ) + + +@router.callback_query(F.data == "admin_mon_notify_edit_nd_percent") +@admin_required +async def edit_third_wave_percent(callback: CallbackQuery, state: FSMContext): + await _start_notification_value_edit( + callback, + state, + "expired_third_wave", + "percent", + "NOTIFY_PROMPT_THIRD_PERCENT", + "Введите новый процент скидки для позднего предложения (0-100):", + ) + + +@router.callback_query(F.data == "admin_mon_notify_edit_nd_hours") +@admin_required +async def edit_third_wave_hours(callback: CallbackQuery, state: FSMContext): + await _start_notification_value_edit( + callback, + state, + "expired_third_wave", + "hours", + "NOTIFY_PROMPT_THIRD_HOURS", + "Введите количество часов действия скидки (1-168):", + ) + + +@router.callback_query(F.data == "admin_mon_notify_edit_nd_threshold") +@admin_required +async def edit_third_wave_threshold(callback: CallbackQuery, state: FSMContext): + await _start_notification_value_edit( + callback, + state, + "expired_third_wave", + "trigger", + "NOTIFY_PROMPT_THIRD_DAYS", + "Через сколько дней после истечения отправлять предложение? (минимум 2):", + ) + + @router.callback_query(F.data == "admin_mon_start") @admin_required async def start_monitoring_callback(callback: CallbackQuery): @@ -366,5 +950,74 @@ async def monitoring_command(message: Message): await message.answer(f"❌ Ошибка: {str(e)}") +@router.message(AdminStates.editing_notification_value) +async def process_notification_value_input(message: Message, state: FSMContext): + data = await state.get_data() + if not data: + await state.clear() + await message.answer("ℹ️ Контекст утерян, попробуйте снова из меню настроек.") + return + + raw_value = (message.text or "").strip() + try: + value = int(raw_value) + except (TypeError, ValueError): + language = data.get("settings_language") or message.from_user.language_code or settings.DEFAULT_LANGUAGE + texts = get_texts(language) + await message.answer(texts.get("NOTIFICATION_VALUE_INVALID", "❌ Введите целое число.")) + return + + key = data.get("notification_setting_key") + field = data.get("notification_setting_field") + language = data.get("settings_language") or message.from_user.language_code or settings.DEFAULT_LANGUAGE + texts = get_texts(language) + + success = False + if key == "expired_second_wave" and field == "percent": + success = NotificationSettingsService.set_second_wave_discount_percent(value) + elif key == "expired_second_wave" and field == "hours": + success = NotificationSettingsService.set_second_wave_valid_hours(value) + elif key == "expired_third_wave" and field == "percent": + success = NotificationSettingsService.set_third_wave_discount_percent(value) + elif key == "expired_third_wave" and field == "hours": + success = NotificationSettingsService.set_third_wave_valid_hours(value) + elif key == "expired_third_wave" and field == "trigger": + success = NotificationSettingsService.set_third_wave_trigger_days(value) + + if not success: + await message.answer(texts.get("NOTIFICATION_VALUE_INVALID", "❌ Некорректное значение, попробуйте снова.")) + return + + back_keyboard = InlineKeyboardMarkup( + inline_keyboard=[ + [ + InlineKeyboardButton( + text=texts.get("BACK", "⬅️ Назад"), + callback_data="admin_mon_notify_settings", + ) + ] + ] + ) + + await message.answer( + texts.get("NOTIFICATION_VALUE_UPDATED", "✅ Настройки обновлены."), + reply_markup=back_keyboard, + ) + + chat_id = data.get("settings_message_chat") + message_id = data.get("settings_message_id") + business_connection_id = data.get("settings_business_connection_id") + if chat_id and message_id: + await _render_notification_settings_for_state( + message.bot, + chat_id, + message_id, + language, + business_connection_id=business_connection_id, + ) + + await state.clear() + + def register_handlers(dp): dp.include_router(router) \ No newline at end of file diff --git a/app/handlers/admin/promo_groups.py b/app/handlers/admin/promo_groups.py index ef927f94..1cb1df73 100644 --- a/app/handlers/admin/promo_groups.py +++ b/app/handlers/admin/promo_groups.py @@ -1,10 +1,13 @@ import logging -from typing import Optional +from decimal import Decimal, InvalidOperation, ROUND_HALF_UP +from typing import Dict, Optional, Tuple from aiogram import Dispatcher, types, F +from aiogram.exceptions import TelegramBadRequest from aiogram.fsm.context import FSMContext from sqlalchemy.ext.asyncio import AsyncSession +from app.config import settings from app.database.crud.promo_group import ( get_promo_groups_with_counts, get_promo_group_by_id, @@ -22,18 +25,404 @@ from app.keyboards.admin import ( get_admin_pagination_keyboard, get_confirmation_keyboard, ) - +from app.utils.pricing_utils import format_period_description logger = logging.getLogger(__name__) -def _format_discount_line(texts, group) -> str: +def _format_discount_lines(texts, group) -> list[str]: + return [ + texts.t( + "ADMIN_PROMO_GROUP_DISCOUNTS_HEADER", + "💸 Скидки промогруппы:", + ), + texts.t( + "ADMIN_PROMO_GROUP_DISCOUNT_LINE_SERVERS", + "• Серверы: {percent}%", + ).format(percent=group.server_discount_percent), + texts.t( + "ADMIN_PROMO_GROUP_DISCOUNT_LINE_TRAFFIC", + "• Трафик: {percent}%", + ).format(percent=group.traffic_discount_percent), + texts.t( + "ADMIN_PROMO_GROUP_DISCOUNT_LINE_DEVICES", + "• Устройства: {percent}%", + ).format(percent=group.device_discount_percent), + ] + + +def _format_addon_discounts_line(texts, group: PromoGroup) -> str: + enabled = getattr(group, "apply_discounts_to_addons", True) + if enabled: + return texts.t( + "ADMIN_PROMO_GROUP_ADDON_DISCOUNT_ENABLED", + "🧩 Скидки на доп. услуги: включены", + ) return texts.t( - "ADMIN_PROMO_GROUPS_DISCOUNTS", - "Скидки — серверы: {servers}%, трафик: {traffic}%, устройства: {devices}%", - ).format( - servers=group.server_discount_percent, - traffic=group.traffic_discount_percent, - devices=group.device_discount_percent, + "ADMIN_PROMO_GROUP_ADDON_DISCOUNT_DISABLED", + "🧩 Скидки на доп. услуги: отключены", + ) + + +def _get_addon_discounts_button_text(texts, group: PromoGroup) -> str: + enabled = getattr(group, "apply_discounts_to_addons", True) + if enabled: + return texts.t( + "ADMIN_PROMO_GROUP_TOGGLE_ADDON_DISCOUNT_DISABLE", + "🧩 Отключить скидки на доп. услуги", + ) + return texts.t( + "ADMIN_PROMO_GROUP_TOGGLE_ADDON_DISCOUNT_ENABLE", + "🧩 Включить скидки на доп. услуги", + ) + + +def _normalize_periods_dict(raw: Optional[Dict]) -> Dict[int, int]: + if not raw or not isinstance(raw, dict): + return {} + + normalized: Dict[int, int] = {} + + for key, value in raw.items(): + try: + period = int(key) + percent = int(value) + except (TypeError, ValueError): + continue + + normalized[period] = max(0, min(100, percent)) + + return normalized + + +def _collect_period_discounts(group: PromoGroup) -> Dict[int, int]: + discounts = _normalize_periods_dict(getattr(group, "period_discounts", None)) + + if discounts: + return dict(sorted(discounts.items())) + + if group.is_default and settings.is_base_promo_group_period_discount_enabled(): + try: + base_discounts = settings.get_base_promo_group_period_discounts() + normalized = _normalize_periods_dict(base_discounts) + return dict(sorted(normalized.items())) + except Exception: + return {} + + return {} + + +def _format_period_discounts_lines(texts, group: PromoGroup, language: str) -> list: + discounts = _collect_period_discounts(group) + + if not discounts: + return [] + + header = texts.t( + "ADMIN_PROMO_GROUP_PERIOD_DISCOUNTS_HEADER", + "⏳ Скидки по периодам:", + ) + + lines = [header] + + for period_days, percent in discounts.items(): + period_display = format_period_description(period_days, language) + lines.append( + texts.t("PROMO_GROUP_PERIOD_DISCOUNT_ITEM", "{period} — {percent}%").format( + period=period_display, + percent=percent, + ) + ) + + return lines + + +def _format_period_discounts_value(discounts: Dict[int, int]) -> str: + if not discounts: + return "0" + + return ", ".join( + f"{period}:{percent}" + for period, percent in sorted(discounts.items()) + ) + + +def _parse_period_discounts_input(value: str) -> Dict[int, int]: + cleaned = (value or "").strip() + + if not cleaned or cleaned in {"0", "-"}: + return {} + + cleaned = cleaned.replace(";", ",").replace("\n", ",") + parts = [part.strip() for part in cleaned.split(",") if part.strip()] + + if not parts: + return {} + + discounts: Dict[int, int] = {} + + for part in parts: + if ":" not in part: + raise ValueError + + period_raw, percent_raw = part.split(":", 1) + + period = int(period_raw.strip()) + percent = int(percent_raw.strip()) + + if period <= 0: + raise ValueError + + discounts[period] = max(0, min(100, percent)) + + return discounts + + +async def _prompt_for_period_discounts( + message: types.Message, + state: FSMContext, + prompt_key: str, + default_text: str, + *, + current_value: Optional[str] = None, +): + data = await state.get_data() + texts = get_texts(data.get("language", "ru")) + prompt_text = texts.t(prompt_key, default_text) + + if current_value is not None: + try: + prompt_text = prompt_text.format(current=current_value) + except KeyError: + pass + + await message.answer(prompt_text) + + +def _format_rubles(amount_kopeks: int) -> str: + if amount_kopeks <= 0: + return "0" + + rubles = Decimal(amount_kopeks) / Decimal(100) + if rubles == rubles.to_integral_value(): + formatted = f"{rubles:,.0f}" + else: + formatted = f"{rubles:,.2f}" + + return formatted.replace(",", " ") + + +def _format_auto_assign_line(texts, group: PromoGroup) -> str: + threshold = getattr(group, "auto_assign_total_spent_kopeks", 0) or 0 + + if threshold <= 0: + return texts.t( + "ADMIN_PROMO_GROUP_AUTO_ASSIGN_DISABLED", + "Автовыдача по суммарным тратам: отключена", + ) + + amount = _format_rubles(threshold) + return texts.t( + "ADMIN_PROMO_GROUP_AUTO_ASSIGN_LINE", + "Автовыдача по суммарным тратам: от {amount} ₽", + ).format(amount=amount) + + +def _format_auto_assign_value(value_kopeks: Optional[int]) -> str: + if not value_kopeks or value_kopeks <= 0: + return "0" + + rubles = Decimal(value_kopeks) / Decimal(100) + quantized = ( + rubles.quantize(Decimal("1")) + if rubles == rubles.to_integral_value() + else rubles.quantize(Decimal("0.01")) + ) + return str(quantized) + + +def _parse_auto_assign_threshold_input(value: str) -> int: + cleaned = (value or "").strip() + + if not cleaned or cleaned in {"0", "-", "off", "нет"}: + return 0 + + normalized = cleaned.replace(" ", "").replace(",", ".") + + try: + amount = Decimal(normalized) + except InvalidOperation: + raise ValueError + + if amount < 0: + raise ValueError + + kopeks = int((amount * 100).quantize(Decimal("1"), rounding=ROUND_HALF_UP)) + return max(0, kopeks) + + +async def _prompt_for_auto_assign_threshold( + message: types.Message, + state: FSMContext, + prompt_key: str, + default_text: str, + *, + current_value: Optional[str] = None, +): + data = await state.get_data() + texts = get_texts(data.get("language", "ru")) + prompt_text = texts.t(prompt_key, default_text) + + if current_value is not None: + try: + prompt_text = prompt_text.format(current=current_value) + except KeyError: + pass + + await message.answer(prompt_text) + + +def _build_edit_menu_content( + texts, + group: PromoGroup, + language: str, +) -> Tuple[str, types.InlineKeyboardMarkup]: + header = texts.t( + "ADMIN_PROMO_GROUP_EDIT_MENU_TITLE", + "✏️ Настройки промогруппы «{name}»", + ).format(name=group.name) + + lines = [header] + lines.extend(_format_discount_lines(texts, group)) + lines.append(_format_addon_discounts_line(texts, group)) + lines.append(_format_auto_assign_line(texts, group)) + + period_lines = _format_period_discounts_lines(texts, group, language) + lines.extend(period_lines) + + lines.append( + texts.t( + "ADMIN_PROMO_GROUP_EDIT_MENU_HINT", + "Выберите параметр для изменения:", + ) + ) + + text = "\n".join(line for line in lines if line) + + keyboard_rows = [ + [ + types.InlineKeyboardButton( + text=texts.t( + "ADMIN_PROMO_GROUP_EDIT_FIELD_NAME", + "✏️ Изменить название", + ), + callback_data=f"promo_group_edit_field_{group.id}_name", + ) + ], + [ + types.InlineKeyboardButton( + text=texts.t( + "ADMIN_PROMO_GROUP_EDIT_FIELD_TRAFFIC", + "🌐 Скидка на трафик", + ), + callback_data=f"promo_group_edit_field_{group.id}_traffic", + ) + ], + [ + types.InlineKeyboardButton( + text=texts.t( + "ADMIN_PROMO_GROUP_EDIT_FIELD_SERVERS", + "🖥 Скидка на серверы", + ), + callback_data=f"promo_group_edit_field_{group.id}_servers", + ) + ], + [ + types.InlineKeyboardButton( + text=texts.t( + "ADMIN_PROMO_GROUP_EDIT_FIELD_DEVICES", + "📱 Скидка на устройства", + ), + callback_data=f"promo_group_edit_field_{group.id}_devices", + ) + ], + [ + types.InlineKeyboardButton( + text=texts.t( + "ADMIN_PROMO_GROUP_EDIT_FIELD_PERIODS", + "⏳ Скидки по периодам", + ), + callback_data=f"promo_group_edit_field_{group.id}_periods", + ) + ], + [ + types.InlineKeyboardButton( + text=_get_addon_discounts_button_text(texts, group), + callback_data=f"promo_group_toggle_addons_{group.id}", + ) + ], + [ + types.InlineKeyboardButton( + text=texts.t( + "ADMIN_PROMO_GROUP_EDIT_FIELD_AUTO_ASSIGN", + "🤖 Автовыдача по тратам", + ), + callback_data=f"promo_group_edit_field_{group.id}_auto", + ) + ], + [ + types.InlineKeyboardButton( + text=texts.BACK, + callback_data=f"promo_group_manage_{group.id}", + ) + ], + ] + + keyboard = types.InlineKeyboardMarkup(inline_keyboard=keyboard_rows) + return text, keyboard + + +def _get_edit_prompt_keyboard(group_id: int, texts) -> types.InlineKeyboardMarkup: + return types.InlineKeyboardMarkup( + inline_keyboard=[ + [ + types.InlineKeyboardButton( + text=texts.BACK, + callback_data=f"promo_group_edit_{group_id}", + ) + ] + ] + ) + + +async def _send_edit_menu_after_update( + message: types.Message, + texts, + group: PromoGroup, + language: str, + success_message: Optional[str] = None, +): + menu_text, keyboard = _build_edit_menu_content(texts, group, language) + parts = [part for part in [success_message, menu_text] if part] + + text = "\n\n".join(parts) + + from_user = getattr(message, "from_user", None) + + if getattr(from_user, "is_bot", False): + try: + await message.edit_text( + text, + reply_markup=keyboard, + parse_mode="HTML", + ) + return + except TelegramBadRequest: + pass + + await message.answer( + text, + reply_markup=keyboard, + parse_mode="HTML", ) @@ -64,17 +453,23 @@ async def show_promo_groups_menu( if group.is_default else "" ) - lines.extend( - [ - f"{'⭐' if group.is_default else '🎯'} {group.name}{default_suffix}", - _format_discount_line(texts, group), - texts.t( - "ADMIN_PROMO_GROUPS_MEMBERS_COUNT", - "Участников: {count}", - ).format(count=member_count), - "", - ] + group_lines = [ + f"{'⭐' if group.is_default else '🎯'} {group.name}{default_suffix}", + ] + group_lines.extend(_format_discount_lines(texts, group)) + group_lines.append(_format_auto_assign_line(texts, group)) + group_lines.append( + texts.t( + "ADMIN_PROMO_GROUPS_MEMBERS_COUNT", + "Участников: {count}", + ).format(count=member_count) ) + + period_lines = _format_period_discounts_lines(texts, group, db_user.language) + group_lines.extend(period_lines) + group_lines.append("") + + lines.extend(group_lines) keyboard_rows.append([ types.InlineKeyboardButton( text=f"{'⭐' if group.is_default else '🎯'} {group.name}", @@ -127,26 +522,34 @@ async def show_promo_group_details( member_count = await count_promo_group_members(db, group.id) default_note = ( - "\n" + texts.t("ADMIN_PROMO_GROUP_DETAILS_DEFAULT", "Это базовая группа.") + texts.t("ADMIN_PROMO_GROUP_DETAILS_DEFAULT", "Это базовая группа.") if group.is_default else "" ) - text = "\n".join( - [ - texts.t( - "ADMIN_PROMO_GROUP_DETAILS_TITLE", - "💳 Промогруппа: {name}", - ).format(name=group.name), - _format_discount_line(texts, group), - texts.t( - "ADMIN_PROMO_GROUP_DETAILS_MEMBERS", - "Участников: {count}", - ).format(count=member_count), - default_note, - ] + lines = [ + texts.t( + "ADMIN_PROMO_GROUP_DETAILS_TITLE", + "💳 Промогруппа: {name}", + ).format(name=group.name) + ] + lines.extend(_format_discount_lines(texts, group)) + lines.append(_format_auto_assign_line(texts, group)) + lines.append( + texts.t( + "ADMIN_PROMO_GROUP_DETAILS_MEMBERS", + "Участников: {count}", + ).format(count=member_count) ) + period_lines = _format_period_discounts_lines(texts, group, db_user.language) + lines.extend(period_lines) + + if default_note: + lines.append(default_note) + + text = "\n".join(line for line in lines if line) + keyboard_rows = [] if member_count > 0: keyboard_rows.append( @@ -299,13 +702,81 @@ async def process_create_group_devices( await message.answer(texts.t("ADMIN_PROMO_GROUP_INVALID_PERCENT", "Введите число от 0 до 100.")) return + await state.update_data(new_group_devices=devices_discount) + await state.set_state(AdminStates.creating_promo_group_period_discount) + + await _prompt_for_period_discounts( + message, + state, + "ADMIN_PROMO_GROUP_CREATE_PERIOD_PROMPT", + "Введите скидки на периоды подписки (например, 30:10, 90:15). Отправьте 0, если без скидок.", + ) + + +@admin_required +@error_handler +async def process_create_group_period_discounts( + message: types.Message, + state: FSMContext, + db_user, + db: AsyncSession, +): + data = await state.get_data() + texts = get_texts(data.get("language", db_user.language)) + + try: + period_discounts = _parse_period_discounts_input(message.text) + except ValueError: + await message.answer( + texts.t( + "ADMIN_PROMO_GROUP_INVALID_PERIOD_DISCOUNTS", + "Введите пары период:скидка через запятую, например 30:10, 90:15, или 0.", + ) + ) + return + + await state.update_data(new_group_period_discounts=period_discounts) + await state.set_state(AdminStates.creating_promo_group_auto_assign) + + await _prompt_for_auto_assign_threshold( + message, + state, + "ADMIN_PROMO_GROUP_CREATE_AUTO_ASSIGN_PROMPT", + "Введите сумму общих трат (в ₽) для автоматической выдачи этой группы. Отправьте 0, чтобы отключить.", + ) + + +@admin_required +@error_handler +async def process_create_group_auto_assign( + message: types.Message, + state: FSMContext, + db_user, + db: AsyncSession, +): + data = await state.get_data() + texts = get_texts(data.get("language", db_user.language)) + + try: + auto_assign_kopeks = _parse_auto_assign_threshold_input(message.text) + except ValueError: + await message.answer( + texts.t( + "ADMIN_PROMO_GROUP_INVALID_AUTO_ASSIGN", + "Введите неотрицательное число в рублях или 0 для отключения.", + ) + ) + return + try: group = await create_promo_group( db, data["new_group_name"], traffic_discount_percent=data["new_group_traffic"], server_discount_percent=data["new_group_servers"], - device_discount_percent=devices_discount, + device_discount_percent=data["new_group_devices"], + period_discounts=data.get("new_group_period_discounts"), + auto_assign_total_spent_kopeks=auto_assign_kopeks, ) except Exception as e: logger.error(f"Не удалось создать промогруппу: {e}") @@ -347,73 +818,190 @@ async def start_edit_promo_group( return texts = get_texts(db_user.language) - await state.set_state(AdminStates.editing_promo_group_name) await state.update_data(edit_group_id=group.id, language=db_user.language) + await state.set_state(AdminStates.editing_promo_group_menu) + text, keyboard = _build_edit_menu_content(texts, group, db_user.language) await callback.message.edit_text( - texts.t( - "ADMIN_PROMO_GROUP_EDIT_NAME_PROMPT", - "Введите новое название промогруппы (текущее: {name}):", - ).format(name=group.name), - reply_markup=types.InlineKeyboardMarkup( - inline_keyboard=[ - [types.InlineKeyboardButton(text=texts.BACK, callback_data=f"promo_group_manage_{group.id}")] - ] - ), + text, + reply_markup=keyboard, + parse_mode="HTML", ) await callback.answer() -async def process_edit_group_name(message: types.Message, state: FSMContext): +@admin_required +@error_handler +async def prompt_edit_promo_group_field( + callback: types.CallbackQuery, + db_user, + state: FSMContext, + db: AsyncSession, +): + parts = callback.data.split("_") + if len(parts) < 6: + await callback.answer("❌ Неверная команда", show_alert=True) + return + + group_id = int(parts[4]) + field = parts[5] + + group = await get_promo_group_by_id(db, group_id) + if not group: + await callback.answer("❌ Промогруппа не найдена", show_alert=True) + return + + await state.update_data(edit_group_id=group.id, language=db_user.language) + + texts = get_texts(db_user.language) + reply_markup = _get_edit_prompt_keyboard(group.id, texts) + + if field == "name": + await state.set_state(AdminStates.editing_promo_group_name) + prompt = texts.t( + "ADMIN_PROMO_GROUP_EDIT_NAME_PROMPT", + "Введите новое название промогруппы (текущее: {name}):", + ).format(name=group.name) + elif field == "traffic": + await state.set_state(AdminStates.editing_promo_group_traffic_discount) + prompt = texts.t( + "ADMIN_PROMO_GROUP_EDIT_TRAFFIC_PROMPT", + "Введите новую скидку на трафик (текущее значение: {current}%):", + ).format(current=group.traffic_discount_percent) + elif field == "servers": + await state.set_state(AdminStates.editing_promo_group_server_discount) + prompt = texts.t( + "ADMIN_PROMO_GROUP_EDIT_SERVERS_PROMPT", + "Введите новую скидку на серверы (текущее значение: {current}%):", + ).format(current=group.server_discount_percent) + elif field == "devices": + await state.set_state(AdminStates.editing_promo_group_device_discount) + prompt = texts.t( + "ADMIN_PROMO_GROUP_EDIT_DEVICES_PROMPT", + "Введите новую скидку на устройства (текущее значение: {current}%):", + ).format(current=group.device_discount_percent) + elif field == "periods": + await state.set_state(AdminStates.editing_promo_group_period_discount) + current_discounts = _normalize_periods_dict(getattr(group, "period_discounts", None)) + prompt = texts.t( + "ADMIN_PROMO_GROUP_EDIT_PERIOD_PROMPT", + "Введите новые скидки на периоды (текущие: {current}). Отправьте 0, если без скидок.", + ).format(current=_format_period_discounts_value(current_discounts)) + elif field == "auto": + await state.set_state(AdminStates.editing_promo_group_auto_assign) + prompt = texts.t( + "ADMIN_PROMO_GROUP_EDIT_AUTO_ASSIGN_PROMPT", + "Введите сумму общих трат (в ₽) для автовыдачи. Текущее значение: {current}.", + ).format(current=_format_auto_assign_value(group.auto_assign_total_spent_kopeks)) + else: + await callback.answer("❌ Неизвестный параметр", show_alert=True) + return + + await callback.message.edit_text(prompt, reply_markup=reply_markup) + await callback.answer() + + +@admin_required +@error_handler +async def process_edit_group_name( + message: types.Message, + state: FSMContext, + db_user, + db: AsyncSession, +): + data = await state.get_data() + texts = get_texts(data.get("language", db_user.language)) + name = message.text.strip() if not name: - texts = get_texts((await state.get_data()).get("language", "ru")) await message.answer(texts.t("ADMIN_PROMO_GROUP_INVALID_NAME", "Название не может быть пустым.")) return - await state.update_data(edit_group_name=name) - await state.set_state(AdminStates.editing_promo_group_traffic_discount) - await _prompt_for_discount( + group = await get_promo_group_by_id(db, data.get("edit_group_id")) + if not group: + await message.answer("❌ Промогруппа не найдена") + await state.clear() + return + + group = await update_promo_group(db, group, name=name) + await state.set_state(AdminStates.editing_promo_group_menu) + + await _send_edit_menu_after_update( message, - state, - "ADMIN_PROMO_GROUP_EDIT_TRAFFIC_PROMPT", - "Введите новую скидку на трафик (0-100):", + texts, + group, + data.get("language", db_user.language), + texts.t("ADMIN_PROMO_GROUP_UPDATED", "Промогруппа «{name}» обновлена.").format(name=group.name), ) -async def process_edit_group_traffic(message: types.Message, state: FSMContext): - texts = get_texts((await state.get_data()).get("language", "ru")) +@admin_required +@error_handler +async def process_edit_group_traffic( + message: types.Message, + state: FSMContext, + db_user, + db: AsyncSession, +): + data = await state.get_data() + texts = get_texts(data.get("language", db_user.language)) + try: value = _validate_percent(message.text) except (ValueError, TypeError): await message.answer(texts.t("ADMIN_PROMO_GROUP_INVALID_PERCENT", "Введите число от 0 до 100.")) return - await state.update_data(edit_group_traffic=value) - await state.set_state(AdminStates.editing_promo_group_server_discount) - await _prompt_for_discount( + group = await get_promo_group_by_id(db, data.get("edit_group_id")) + if not group: + await message.answer("❌ Промогруппа не найдена") + await state.clear() + return + + group = await update_promo_group(db, group, traffic_discount_percent=value) + await state.set_state(AdminStates.editing_promo_group_menu) + + await _send_edit_menu_after_update( message, - state, - "ADMIN_PROMO_GROUP_EDIT_SERVERS_PROMPT", - "Введите новую скидку на серверы (0-100):", + texts, + group, + data.get("language", db_user.language), + texts.t("ADMIN_PROMO_GROUP_UPDATED", "Промогруппа «{name}» обновлена.").format(name=group.name), ) -async def process_edit_group_servers(message: types.Message, state: FSMContext): - texts = get_texts((await state.get_data()).get("language", "ru")) +@admin_required +@error_handler +async def process_edit_group_servers( + message: types.Message, + state: FSMContext, + db_user, + db: AsyncSession, +): + data = await state.get_data() + texts = get_texts(data.get("language", db_user.language)) + try: value = _validate_percent(message.text) except (ValueError, TypeError): await message.answer(texts.t("ADMIN_PROMO_GROUP_INVALID_PERCENT", "Введите число от 0 до 100.")) return - await state.update_data(edit_group_servers=value) - await state.set_state(AdminStates.editing_promo_group_device_discount) - await _prompt_for_discount( + group = await get_promo_group_by_id(db, data.get("edit_group_id")) + if not group: + await message.answer("❌ Промогруппа не найдена") + await state.clear() + return + + group = await update_promo_group(db, group, server_discount_percent=value) + await state.set_state(AdminStates.editing_promo_group_menu) + + await _send_edit_menu_after_update( message, - state, - "ADMIN_PROMO_GROUP_EDIT_DEVICES_PROMPT", - "Введите новую скидку на устройства (0-100):", + texts, + group, + data.get("language", db_user.language), + texts.t("ADMIN_PROMO_GROUP_UPDATED", "Промогруппа «{name}» обновлена.").format(name=group.name), ) @@ -434,24 +1022,105 @@ async def process_edit_group_devices( await message.answer(texts.t("ADMIN_PROMO_GROUP_INVALID_PERCENT", "Введите число от 0 до 100.")) return - group = await get_promo_group_by_id(db, data["edit_group_id"]) + group = await get_promo_group_by_id(db, data.get("edit_group_id")) if not group: await message.answer("❌ Промогруппа не найдена") await state.clear() return - await update_promo_group( - db, + group = await update_promo_group(db, group, device_discount_percent=devices_discount) + await state.set_state(AdminStates.editing_promo_group_menu) + + await _send_edit_menu_after_update( + message, + texts, group, - name=data["edit_group_name"], - traffic_discount_percent=data["edit_group_traffic"], - server_discount_percent=data["edit_group_servers"], - device_discount_percent=devices_discount, + data.get("language", db_user.language), + texts.t("ADMIN_PROMO_GROUP_UPDATED", "Промогруппа «{name}» обновлена.").format(name=group.name), ) - await state.clear() - await message.answer( - texts.t("ADMIN_PROMO_GROUP_UPDATED", "Промогруппа «{name}» обновлена.").format(name=group.name) + +@admin_required +@error_handler +async def process_edit_group_period_discounts( + message: types.Message, + state: FSMContext, + db_user, + db: AsyncSession, +): + data = await state.get_data() + texts = get_texts(data.get("language", db_user.language)) + + try: + period_discounts = _parse_period_discounts_input(message.text) + except ValueError: + await message.answer( + texts.t( + "ADMIN_PROMO_GROUP_INVALID_PERIOD_DISCOUNTS", + "Введите пары период:скидка через запятую, например 30:10, 90:15, или 0.", + ) + ) + return + + group = await get_promo_group_by_id(db, data.get("edit_group_id")) + if not group: + await message.answer("❌ Промогруппа не найдена") + await state.clear() + return + + group = await update_promo_group(db, group, period_discounts=period_discounts) + await state.set_state(AdminStates.editing_promo_group_menu) + + await _send_edit_menu_after_update( + message, + texts, + group, + data.get("language", db_user.language), + texts.t("ADMIN_PROMO_GROUP_UPDATED", "Промогруппа «{name}» обновлена.").format(name=group.name), + ) + + +@admin_required +@error_handler +async def process_edit_group_auto_assign( + message: types.Message, + state: FSMContext, + db_user, + db: AsyncSession, +): + data = await state.get_data() + texts = get_texts(data.get("language", db_user.language)) + + try: + auto_assign_kopeks = _parse_auto_assign_threshold_input(message.text) + except ValueError: + await message.answer( + texts.t( + "ADMIN_PROMO_GROUP_INVALID_AUTO_ASSIGN", + "Введите неотрицательное число в рублях или 0 для отключения.", + ) + ) + return + + group = await get_promo_group_by_id(db, data.get("edit_group_id")) + if not group: + await message.answer("❌ Промогруппа не найдена") + await state.clear() + return + + group = await update_promo_group( + db, + group, + auto_assign_total_spent_kopeks=auto_assign_kopeks, + ) + await state.set_state(AdminStates.editing_promo_group_menu) + + await _send_edit_menu_after_update( + message, + texts, + group, + data.get("language", db_user.language), + texts.t("ADMIN_PROMO_GROUP_UPDATED", "Промогруппа «{name}» обновлена.").format(name=group.name), ) @@ -584,11 +1253,61 @@ async def delete_promo_group_confirmed( await callback.answer() +@admin_required +@error_handler +async def toggle_promo_group_addon_discounts( + callback: types.CallbackQuery, + db_user, + db: AsyncSession, +): + group = await _get_group_or_alert(callback, db) + if not group: + return + + texts = get_texts(db_user.language) + + new_value = not getattr(group, "apply_discounts_to_addons", True) + + group = await update_promo_group( + db, + group, + apply_discounts_to_addons=new_value, + ) + + status_text = texts.t( + "ADMIN_PROMO_GROUP_ADDON_DISCOUNT_UPDATED_ENABLED" + if new_value + else "ADMIN_PROMO_GROUP_ADDON_DISCOUNT_UPDATED_DISABLED", + "🧩 Скидки на докупку доп. услуг {status}.", + ).format(status="включены" if new_value else "отключены") + + await _send_edit_menu_after_update( + callback.message, + texts, + group, + db_user.language, + status_text, + ) + + await callback.answer() + + def register_handlers(dp: Dispatcher): dp.callback_query.register(show_promo_groups_menu, F.data == "admin_promo_groups") dp.callback_query.register(show_promo_group_details, F.data.startswith("promo_group_manage_")) dp.callback_query.register(start_create_promo_group, F.data == "admin_promo_group_create") - dp.callback_query.register(start_edit_promo_group, F.data.startswith("promo_group_edit_")) + dp.callback_query.register( + prompt_edit_promo_group_field, + F.data.startswith("promo_group_edit_field_"), + ) + dp.callback_query.register( + toggle_promo_group_addon_discounts, + F.data.startswith("promo_group_toggle_addons_"), + ) + dp.callback_query.register( + start_edit_promo_group, + F.data.regexp(r"^promo_group_edit_\d+$"), + ) dp.callback_query.register( request_delete_promo_group, F.data.startswith("promo_group_delete_") @@ -616,6 +1335,14 @@ def register_handlers(dp: Dispatcher): process_create_group_devices, AdminStates.creating_promo_group_device_discount, ) + dp.message.register( + process_create_group_period_discounts, + AdminStates.creating_promo_group_period_discount, + ) + dp.message.register( + process_create_group_auto_assign, + AdminStates.creating_promo_group_auto_assign, + ) dp.message.register(process_edit_group_name, AdminStates.editing_promo_group_name) dp.message.register( @@ -630,3 +1357,11 @@ def register_handlers(dp: Dispatcher): process_edit_group_devices, AdminStates.editing_promo_group_device_discount, ) + dp.message.register( + process_edit_group_period_discounts, + AdminStates.editing_promo_group_period_discount, + ) + dp.message.register( + process_edit_group_auto_assign, + AdminStates.editing_promo_group_auto_assign, + ) diff --git a/app/handlers/admin/reports.py b/app/handlers/admin/reports.py new file mode 100644 index 00000000..44049276 --- /dev/null +++ b/app/handlers/admin/reports.py @@ -0,0 +1,117 @@ +import logging +from aiogram import Dispatcher, F, types +from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError +from sqlalchemy.ext.asyncio import AsyncSession + +from app.database.models import User +from app.keyboards.admin import ( + get_admin_report_result_keyboard, + get_admin_reports_keyboard, +) +from app.localization.texts import get_texts +from app.services.reporting_service import ( + ReportPeriod, + ReportingServiceError, + reporting_service, +) +from app.utils.decorators import admin_required, error_handler + + +logger = logging.getLogger(__name__) + + +@admin_required +@error_handler +async def show_reports_menu( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession, +) -> None: + await callback.message.edit_text( + "📊 Отчеты\n\n" + "Выберите период, чтобы отправить отчет в админский топик.", + reply_markup=get_admin_reports_keyboard(db_user.language), + parse_mode="HTML", + ) + await callback.answer() + + +@admin_required +@error_handler +async def send_daily_report( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession, +) -> None: + await _send_report(callback, ReportPeriod.DAILY, db_user.language) + + +@admin_required +@error_handler +async def send_weekly_report( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession, +) -> None: + await _send_report(callback, ReportPeriod.WEEKLY, db_user.language) + + +@admin_required +@error_handler +async def send_monthly_report( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession, +) -> None: + await _send_report(callback, ReportPeriod.MONTHLY, db_user.language) + + +async def _send_report( + callback: types.CallbackQuery, + period: ReportPeriod, + language: str, +) -> None: + try: + report_text = await reporting_service.send_report(period, send_to_topic=True) + except ReportingServiceError as exc: + logger.warning("Не удалось отправить отчет: %s", exc) + await callback.answer(str(exc), show_alert=True) + return + except Exception as exc: # noqa: BLE001 + logger.error("Непредвиденная ошибка при отправке отчета: %s", exc) + await callback.answer("Не удалось отправить отчет. Попробуйте позже.", show_alert=True) + return + + await callback.message.answer( + report_text, + reply_markup=get_admin_report_result_keyboard(language), + ) + await callback.answer("Отчет отправлен в топик") + + +@admin_required +@error_handler +async def close_report_message( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession, +) -> None: + texts = get_texts(db_user.language) + + try: + await callback.message.delete() + except (TelegramBadRequest, TelegramForbiddenError) as exc: + logger.warning("Не удалось закрыть сообщение отчета: %s", exc) + await callback.answer(texts.t("REPORT_CLOSE_ERROR", "Не удалось закрыть отчет."), show_alert=True) + return + + await callback.answer(texts.t("REPORT_CLOSED", "Отчет закрыт.")) + + +def register_handlers(dp: Dispatcher) -> None: + dp.callback_query.register(show_reports_menu, F.data == "admin_reports") + dp.callback_query.register(send_daily_report, F.data == "admin_reports_daily") + dp.callback_query.register(send_weekly_report, F.data == "admin_reports_weekly") + dp.callback_query.register(send_monthly_report, F.data == "admin_reports_monthly") + dp.callback_query.register(close_report_message, F.data == "admin_close_report") + diff --git a/app/handlers/admin/servers.py b/app/handlers/admin/servers.py index f5ae0023..9edc3af0 100644 --- a/app/handlers/admin/servers.py +++ b/app/handlers/admin/servers.py @@ -6,10 +6,17 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.states import AdminStates from app.database.models import User from app.database.crud.server_squad import ( - get_all_server_squads, get_server_squad_by_id, update_server_squad, - delete_server_squad, sync_with_remnawave, get_server_statistics, - create_server_squad, get_available_server_squads + get_all_server_squads, + get_server_squad_by_id, + update_server_squad, + delete_server_squad, + sync_with_remnawave, + get_server_statistics, + create_server_squad, + get_available_server_squads, + update_server_squad_promo_groups, ) +from app.database.crud.promo_group import get_promo_groups_with_counts from app.services.remnawave_service import RemnaWaveService from app.utils.decorators import admin_required, error_handler from app.utils.cache import cache @@ -17,6 +24,111 @@ from app.utils.cache import cache logger = logging.getLogger(__name__) +def _build_server_edit_view(server): + status_emoji = "✅ Доступен" if server.is_available else "❌ Недоступен" + price_text = f"{int(server.price_rubles)} ₽" if server.price_kopeks > 0 else "Бесплатно" + promo_groups_text = ( + ", ".join(sorted(pg.name for pg in server.allowed_promo_groups)) + if server.allowed_promo_groups + else "Не выбраны" + ) + + text = f""" +🌐 Редактирование сервера + +Информация: +• ID: {server.id} +• UUID: {server.squad_uuid} +• Название: {server.display_name} +• Оригинальное: {server.original_name or 'Не указано'} +• Статус: {status_emoji} + +Настройки: +• Цена: {price_text} +• Код страны: {server.country_code or 'Не указан'} +• Лимит пользователей: {server.max_users or 'Без лимита'} +• Текущих пользователей: {server.current_users} +• Промогруппы: {promo_groups_text} + +Описание: +{server.description or 'Не указано'} + +Выберите что изменить: +""" + + keyboard = [ + [ + types.InlineKeyboardButton( + text="✏️ Название", callback_data=f"admin_server_edit_name_{server.id}" + ), + types.InlineKeyboardButton( + text="💰 Цена", callback_data=f"admin_server_edit_price_{server.id}" + ), + ], + [ + types.InlineKeyboardButton( + text="🌍 Страна", callback_data=f"admin_server_edit_country_{server.id}" + ), + types.InlineKeyboardButton( + text="👥 Лимит", callback_data=f"admin_server_edit_limit_{server.id}" + ), + ], + [ + types.InlineKeyboardButton( + text="🎯 Промогруппы", callback_data=f"admin_server_edit_promo_{server.id}" + ), + types.InlineKeyboardButton( + text="📝 Описание", callback_data=f"admin_server_edit_desc_{server.id}" + ), + ], + [ + types.InlineKeyboardButton( + text="❌ Отключить" if server.is_available else "✅ Включить", + callback_data=f"admin_server_toggle_{server.id}", + ) + ], + [ + types.InlineKeyboardButton( + text="🗑️ Удалить", callback_data=f"admin_server_delete_{server.id}" + ), + types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_servers_list"), + ], + ] + + return text, types.InlineKeyboardMarkup(inline_keyboard=keyboard) + + +def _build_server_promo_groups_keyboard(server_id: int, promo_groups, selected_ids): + keyboard = [] + for group in promo_groups: + emoji = "✅" if group["id"] in selected_ids else "⚪" + keyboard.append( + [ + types.InlineKeyboardButton( + text=f"{emoji} {group['name']}", + callback_data=f"admin_server_promo_toggle_{server_id}_{group['id']}", + ) + ] + ) + + keyboard.append( + [ + types.InlineKeyboardButton( + text="💾 Сохранить", callback_data=f"admin_server_promo_save_{server_id}" + ) + ] + ) + keyboard.append( + [ + types.InlineKeyboardButton( + text="⬅️ Назад", callback_data=f"admin_server_edit_{server_id}" + ) + ] + ) + + return types.InlineKeyboardMarkup(inline_keyboard=keyboard) + + @admin_required @error_handler async def show_servers_menu( @@ -166,7 +278,7 @@ async def sync_servers_with_remnawave( created, updated, disabled = await sync_with_remnawave(db, squads) - await cache.delete("available_countries") + await cache.delete_pattern("available_countries*") text = f""" ✅ Синхронизация завершена @@ -216,63 +328,16 @@ async def show_server_edit_menu( server_id = int(callback.data.split('_')[-1]) server = await get_server_squad_by_id(db, server_id) - + if not server: await callback.answer("❌ Сервер не найден!", show_alert=True) return - - status_emoji = "✅ Доступен" if server.is_available else "❌ Недоступен" - price_text = f"{int(server.price_rubles)} ₽" if server.price_kopeks > 0 else "Бесплатно" - - text = f""" -🌐 Редактирование сервера -Информация: -• ID: {server.id} -• UUID: {server.squad_uuid} -• Название: {server.display_name} -• Оригинальное: {server.original_name or 'Не указано'} -• Статус: {status_emoji} + text, keyboard = _build_server_edit_view(server) -Настройки: -• Цена: {price_text} -• Код страны: {server.country_code or 'Не указан'} -• Лимит пользователей: {server.max_users or 'Без лимита'} -• Текущих пользователей: {server.current_users} - -Описание: -{server.description or 'Не указано'} - -Выберите что изменить: -""" - - keyboard = [ - [ - types.InlineKeyboardButton(text="✏️ Название", callback_data=f"admin_server_edit_name_{server.id}"), - types.InlineKeyboardButton(text="💰 Цена", callback_data=f"admin_server_edit_price_{server.id}") - ], - [ - types.InlineKeyboardButton(text="🌍 Страна", callback_data=f"admin_server_edit_country_{server.id}"), - types.InlineKeyboardButton(text="👥 Лимит", callback_data=f"admin_server_edit_limit_{server.id}") - ], - [ - types.InlineKeyboardButton(text="📝 Описание", callback_data=f"admin_server_edit_desc_{server.id}") - ], - [ - types.InlineKeyboardButton( - text="❌ Отключить" if server.is_available else "✅ Включить", - callback_data=f"admin_server_toggle_{server.id}" - ) - ], - [ - types.InlineKeyboardButton(text="🗑️ Удалить", callback_data=f"admin_server_delete_{server.id}"), - types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_servers_list") - ] - ] - await callback.message.edit_text( text, - reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard), + reply_markup=keyboard, parse_mode="HTML" ) await callback.answer() @@ -296,65 +361,18 @@ async def toggle_server_availability( new_status = not server.is_available await update_server_squad(db, server_id, is_available=new_status) - await cache.delete("available_countries") + await cache.delete_pattern("available_countries*") status_text = "включен" if new_status else "отключен" await callback.answer(f"✅ Сервер {status_text}!") server = await get_server_squad_by_id(db, server_id) - status_emoji = "✅ Доступен" if server.is_available else "❌ Недоступен" - price_text = f"{int(server.price_rubles)} ₽" if server.price_kopeks > 0 else "Бесплатно" - - text = f""" -🌐 Редактирование сервера + text, keyboard = _build_server_edit_view(server) -Информация: -• ID: {server.id} -• UUID: {server.squad_uuid} -• Название: {server.display_name} -• Оригинальное: {server.original_name or 'Не указано'} -• Статус: {status_emoji} - -Настройки: -• Цена: {price_text} -• Код страны: {server.country_code or 'Не указан'} -• Лимит пользователей: {server.max_users or 'Без лимита'} -• Текущих пользователей: {server.current_users} - -Описание: -{server.description or 'Не указано'} - -Выберите что изменить: -""" - - keyboard = [ - [ - types.InlineKeyboardButton(text="✏️ Название", callback_data=f"admin_server_edit_name_{server.id}"), - types.InlineKeyboardButton(text="💰 Цена", callback_data=f"admin_server_edit_price_{server.id}") - ], - [ - types.InlineKeyboardButton(text="🌍 Страна", callback_data=f"admin_server_edit_country_{server.id}"), - types.InlineKeyboardButton(text="👥 Лимит", callback_data=f"admin_server_edit_limit_{server.id}") - ], - [ - types.InlineKeyboardButton(text="📝 Описание", callback_data=f"admin_server_edit_desc_{server.id}") - ], - [ - types.InlineKeyboardButton( - text="❌ Отключить" if server.is_available else "✅ Включить", - callback_data=f"admin_server_toggle_{server.id}" - ) - ], - [ - types.InlineKeyboardButton(text="🗑️ Удалить", callback_data=f"admin_server_delete_{server.id}"), - types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_servers_list") - ] - ] - await callback.message.edit_text( text, - reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard), + reply_markup=keyboard, parse_mode="HTML" ) @@ -422,7 +440,7 @@ async def process_server_price_edit( if server: await state.clear() - await cache.delete("available_countries") + await cache.delete_pattern("available_countries*") price_text = f"{int(price_rubles)} ₽" if price_kopeks > 0 else "Бесплатно" await message.answer( @@ -497,7 +515,7 @@ async def process_server_name_edit( if server: await state.clear() - await cache.delete("available_countries") + await cache.delete_pattern("available_countries*") await message.answer( f"✅ Название сервера изменено на: {new_name}", @@ -570,7 +588,7 @@ async def delete_server_execute( success = await delete_server_squad(db, server_id) if success: - await cache.delete("available_countries") + await cache.delete_pattern("available_countries*") await callback.message.edit_text( f"✅ Сервер {server.display_name} успешно удален!", @@ -701,7 +719,7 @@ async def process_server_country_edit( if server: await state.clear() - await cache.delete("available_countries") + await cache.delete_pattern("available_countries*") country_text = new_country or "Удален" await message.answer( @@ -847,11 +865,12 @@ async def process_server_description_edit( return server = await update_server_squad(db, server_id, description=new_description) - + if server: await state.clear() - + desc_text = new_description or "Удалено" + await cache.delete_pattern("available_countries*") await message.answer( f"✅ Описание сервера изменено:\n\n{desc_text}", reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[ @@ -862,6 +881,147 @@ async def process_server_description_edit( else: await message.answer("❌ Ошибка при обновлении сервера") + +@admin_required +@error_handler +async def start_server_edit_promo_groups( + callback: types.CallbackQuery, + state: FSMContext, + db_user: User, + db: AsyncSession, +): + + server_id = int(callback.data.split('_')[-1]) + server = await get_server_squad_by_id(db, server_id) + + if not server: + await callback.answer("❌ Сервер не найден!", show_alert=True) + return + + promo_groups_data = await get_promo_groups_with_counts(db) + promo_groups = [ + {"id": group.id, "name": group.name, "is_default": group.is_default} + for group, _ in promo_groups_data + ] + + if not promo_groups: + await callback.answer("❌ Не найдены промогруппы", show_alert=True) + return + + selected_ids = {pg.id for pg in server.allowed_promo_groups} + if not selected_ids: + default_group = next((pg for pg in promo_groups if pg["is_default"]), None) + if default_group: + selected_ids.add(default_group["id"]) + + await state.set_state(AdminStates.editing_server_promo_groups) + await state.set_data( + { + "server_id": server_id, + "promo_groups": promo_groups, + "selected_promo_groups": list(selected_ids), + "server_name": server.display_name, + } + ) + + text = ( + "🎯 Настройка промогрупп\n\n" + f"Сервер: {server.display_name}\n\n" + "Выберите промогруппы, которым будет доступен этот сервер.\n" + "Должна быть выбрана минимум одна промогруппа." + ) + + await callback.message.edit_text( + text, + reply_markup=_build_server_promo_groups_keyboard(server_id, promo_groups, selected_ids), + parse_mode="HTML", + ) + await callback.answer() + + +@admin_required +@error_handler +async def toggle_server_promo_group( + callback: types.CallbackQuery, + state: FSMContext, + db_user: User, + db: AsyncSession, +): + + parts = callback.data.split('_') + server_id = int(parts[4]) + group_id = int(parts[5]) + + data = await state.get_data() + if not data or data.get("server_id") != server_id: + await callback.answer("⚠️ Сессия редактирования устарела", show_alert=True) + return + + selected = set(int(pg_id) for pg_id in data.get("selected_promo_groups", [])) + promo_groups = data.get("promo_groups", []) + + if group_id in selected: + if len(selected) == 1: + await callback.answer("⚠️ Нельзя отключить последнюю промогруппу", show_alert=True) + return + selected.remove(group_id) + message = "Промогруппа отключена" + else: + selected.add(group_id) + message = "Промогруппа добавлена" + + await state.update_data(selected_promo_groups=list(selected)) + + await callback.message.edit_reply_markup( + reply_markup=_build_server_promo_groups_keyboard(server_id, promo_groups, selected) + ) + await callback.answer(message) + + +@admin_required +@error_handler +async def save_server_promo_groups( + callback: types.CallbackQuery, + state: FSMContext, + db_user: User, + db: AsyncSession, +): + + data = await state.get_data() + if not data: + await callback.answer("⚠️ Нет данных для сохранения", show_alert=True) + return + + server_id = data.get("server_id") + selected = data.get("selected_promo_groups", []) + + if not selected: + await callback.answer("❌ Выберите хотя бы одну промогруппу", show_alert=True) + return + + try: + server = await update_server_squad_promo_groups(db, server_id, selected) + except ValueError as exc: + await callback.answer(f"❌ {exc}", show_alert=True) + return + + if not server: + await callback.answer("❌ Сервер не найден", show_alert=True) + return + + await cache.delete_pattern("available_countries*") + await state.clear() + + text, keyboard = _build_server_edit_view(server) + + await callback.message.edit_text( + text, + reply_markup=keyboard, + parse_mode="HTML", + ) + await callback.answer("✅ Промогруппы обновлены!") + + @admin_required @error_handler async def sync_server_user_counts_handler( @@ -934,12 +1094,22 @@ def register_handlers(dp: Dispatcher): dp.callback_query.register(sync_server_user_counts_handler, F.data == "admin_servers_sync_counts") dp.callback_query.register(show_server_detailed_stats, F.data == "admin_servers_stats") - dp.callback_query.register(show_server_edit_menu, F.data.startswith("admin_server_edit_") & ~F.data.contains("name") & ~F.data.contains("price") & ~F.data.contains("country") & ~F.data.contains("limit") & ~F.data.contains("desc")) + dp.callback_query.register( + show_server_edit_menu, + F.data.startswith("admin_server_edit_") + & ~F.data.contains("name") + & ~F.data.contains("price") + & ~F.data.contains("country") + & ~F.data.contains("limit") + & ~F.data.contains("desc") + & ~F.data.contains("promo"), + ) dp.callback_query.register(toggle_server_availability, F.data.startswith("admin_server_toggle_")) - + dp.callback_query.register(start_server_edit_name, F.data.startswith("admin_server_edit_name_")) dp.callback_query.register(start_server_edit_price, F.data.startswith("admin_server_edit_price_")) - dp.callback_query.register(start_server_edit_country, F.data.startswith("admin_server_edit_country_")) + dp.callback_query.register(start_server_edit_country, F.data.startswith("admin_server_edit_country_")) + dp.callback_query.register(start_server_edit_promo_groups, F.data.startswith("admin_server_edit_promo_")) dp.callback_query.register(start_server_edit_limit, F.data.startswith("admin_server_edit_limit_")) dp.callback_query.register(start_server_edit_description, F.data.startswith("admin_server_edit_desc_")) @@ -947,7 +1117,9 @@ def register_handlers(dp: Dispatcher): dp.message.register(process_server_price_edit, AdminStates.editing_server_price) dp.message.register(process_server_country_edit, AdminStates.editing_server_country) dp.message.register(process_server_limit_edit, AdminStates.editing_server_limit) - dp.message.register(process_server_description_edit, AdminStates.editing_server_description) + dp.message.register(process_server_description_edit, AdminStates.editing_server_description) + dp.callback_query.register(toggle_server_promo_group, F.data.startswith("admin_server_promo_toggle_")) + dp.callback_query.register(save_server_promo_groups, F.data.startswith("admin_server_promo_save_")) dp.callback_query.register(delete_server_confirm, F.data.startswith("admin_server_delete_") & ~F.data.contains("confirm")) dp.callback_query.register(delete_server_execute, F.data.startswith("admin_server_delete_confirm_")) diff --git a/app/handlers/admin/support_settings.py b/app/handlers/admin/support_settings.py index eb93e841..adbf3ea7 100644 --- a/app/handlers/admin/support_settings.py +++ b/app/handlers/admin/support_settings.py @@ -7,6 +7,7 @@ from aiogram.fsm.context import FSMContext from sqlalchemy.ext.asyncio import AsyncSession from app.database.models import User +from app.config import settings from app.localization.texts import get_texts from app.utils.decorators import admin_required, error_handler from app.services.support_settings_service import SupportSettingsService @@ -20,6 +21,10 @@ def _get_support_settings_keyboard(language: str) -> types.InlineKeyboardMarkup: texts = get_texts(language) mode = SupportSettingsService.get_system_mode() menu_enabled = SupportSettingsService.is_support_menu_enabled() + admin_notif = SupportSettingsService.get_admin_ticket_notifications_enabled() + user_notif = SupportSettingsService.get_user_ticket_notifications_enabled() + sla_enabled = SupportSettingsService.get_sla_enabled() + sla_minutes = SupportSettingsService.get_sla_minutes() rows: list[list[types.InlineKeyboardButton]] = [] @@ -40,8 +45,53 @@ def _get_support_settings_keyboard(language: str) -> types.InlineKeyboardMarkup: types.InlineKeyboardButton(text="📝 Изменить описание", callback_data="admin_support_edit_desc") ]) + # Notifications block rows.append([ - types.InlineKeyboardButton(text=texts.BACK, callback_data="admin_submenu_communications") + types.InlineKeyboardButton( + text=("🔔 Админ-уведомления: Включены" if admin_notif else "🔕 Админ-уведомления: Отключены"), + callback_data="admin_support_toggle_admin_notifications" + ) + ]) + rows.append([ + types.InlineKeyboardButton( + text=("🔔 Пользовательские уведомления: Включены" if user_notif else "🔕 Пользовательские уведомления: Отключены"), + callback_data="admin_support_toggle_user_notifications" + ) + ]) + + # SLA block + rows.append([ + types.InlineKeyboardButton( + text=("⏰ SLA: Включено" if sla_enabled else "⏹️ SLA: Отключено"), + callback_data="admin_support_toggle_sla" + ) + ]) + rows.append([ + types.InlineKeyboardButton( + text=f"⏳ Время SLA: {sla_minutes} мин", + callback_data="admin_support_set_sla_minutes" + ) + ]) + + # Moderators + moderators = SupportSettingsService.get_moderators() + mod_count = len(moderators) + rows.append([ + types.InlineKeyboardButton( + text=f"🧑‍⚖️ Модераторы: {mod_count}", callback_data="admin_support_list_moderators" + ) + ]) + rows.append([ + types.InlineKeyboardButton( + text="➕ Назначить модератора", callback_data="admin_support_add_moderator" + ), + types.InlineKeyboardButton( + text="➖ Удалить модератора", callback_data="admin_support_remove_moderator" + ) + ]) + + rows.append([ + types.InlineKeyboardButton(text=texts.BACK, callback_data="admin_submenu_support") ]) return types.InlineKeyboardMarkup(inline_keyboard=rows) @@ -78,6 +128,175 @@ async def toggle_support_menu( await show_support_settings(callback, db_user, db) +@admin_required +@error_handler +async def toggle_admin_notifications(callback: types.CallbackQuery, db_user: User, db: AsyncSession): + current = SupportSettingsService.get_admin_ticket_notifications_enabled() + SupportSettingsService.set_admin_ticket_notifications_enabled(not current) + await show_support_settings(callback, db_user, db) + + +@admin_required +@error_handler +async def toggle_user_notifications(callback: types.CallbackQuery, db_user: User, db: AsyncSession): + current = SupportSettingsService.get_user_ticket_notifications_enabled() + SupportSettingsService.set_user_ticket_notifications_enabled(not current) + await show_support_settings(callback, db_user, db) + + +@admin_required +@error_handler +async def toggle_sla(callback: types.CallbackQuery, db_user: User, db: AsyncSession): + current = SupportSettingsService.get_sla_enabled() + SupportSettingsService.set_sla_enabled(not current) + await show_support_settings(callback, db_user, db) + + +from app.states import SupportSettingsStates + +@admin_required +@error_handler +async def start_set_sla_minutes(callback: types.CallbackQuery, db_user: User, db: AsyncSession, state: FSMContext): + await callback.message.edit_text( + "⏳ Настройка SLA\n\nВведите количество минут ожидания ответа (целое число > 0):", + parse_mode="HTML", + reply_markup=types.InlineKeyboardMarkup( + inline_keyboard=[[types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_support_settings")]] + ) + ) + await state.set_state(SupportSettingsStates.waiting_for_desc) # temporary reuse replaced below + # we'll manage separate state below + + +from aiogram.fsm.state import State, StatesGroup + +class SupportAdvancedStates(StatesGroup): + waiting_for_sla_minutes = State() + waiting_for_moderator_id = State() + + +@admin_required +@error_handler +async def start_set_sla_minutes(callback: types.CallbackQuery, db_user: User, db: AsyncSession, state: FSMContext): + await callback.message.edit_text( + "⏳ Настройка SLA\n\nВведите количество минут ожидания ответа (целое число > 0):", + parse_mode="HTML", + reply_markup=types.InlineKeyboardMarkup( + inline_keyboard=[[types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_support_settings")]] + ) + ) + await state.set_state(SupportAdvancedStates.waiting_for_sla_minutes) + await callback.answer() + + +@admin_required +@error_handler +async def handle_sla_minutes(message: types.Message, db_user: User, db: AsyncSession, state: FSMContext): + text = (message.text or "").strip() + try: + minutes = int(text) + if minutes <= 0 or minutes > 1440: + raise ValueError() + except Exception: + await message.answer("❌ Введите корректное число минут (1-1440)") + return + SupportSettingsService.set_sla_minutes(minutes) + await state.clear() + markup = types.InlineKeyboardMarkup( + inline_keyboard=[[types.InlineKeyboardButton(text="🗑 Удалить", callback_data="admin_support_delete_msg")]] + ) + await message.answer("✅ Значение SLA сохранено", reply_markup=markup) + + +@admin_required +@error_handler +async def start_add_moderator(callback: types.CallbackQuery, db_user: User, db: AsyncSession, state: FSMContext): + await callback.message.edit_text( + "🧑‍⚖️ Назначение модератора\n\nОтправьте Telegram ID пользователя (число)", + parse_mode="HTML", + reply_markup=types.InlineKeyboardMarkup( + inline_keyboard=[[types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_support_settings")]] + ) + ) + await state.set_state(SupportAdvancedStates.waiting_for_moderator_id) + await callback.answer() + + +@admin_required +@error_handler +async def handle_add_moderator(message: types.Message, db_user: User, db: AsyncSession, state: FSMContext): + text = (message.text or "").strip() + try: + tid = int(text) + except Exception: + await message.answer("❌ Введите корректный Telegram ID (число)") + return + if SupportSettingsService.add_moderator(tid): + markup = types.InlineKeyboardMarkup( + inline_keyboard=[[types.InlineKeyboardButton(text="🗑 Удалить", callback_data="admin_support_delete_msg")]] + ) + await message.answer(f"✅ Пользователь {tid} назначен модератором", reply_markup=markup) + else: + await message.answer("❌ Не удалось сохранить") + await state.clear() + + +@admin_required +@error_handler +async def start_remove_moderator(callback: types.CallbackQuery, db_user: User, db: AsyncSession, state: FSMContext): + await callback.message.edit_text( + "🧑‍⚖️ Удаление модератора\n\nОтправьте Telegram ID пользователя (число)", + parse_mode="HTML", + reply_markup=types.InlineKeyboardMarkup( + inline_keyboard=[[types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_support_settings")]] + ) + ) + await state.set_state(SupportAdvancedStates.waiting_for_moderator_id) + # We'll reuse the same state; next message will decide action via flag + await state.update_data(action="remove_moderator") + await callback.answer() + + +@admin_required +@error_handler +async def handle_moderator_id(message: types.Message, db_user: User, db: AsyncSession, state: FSMContext): + data = await state.get_data() + action = data.get("action", "add") + text = (message.text or "").strip() + try: + tid = int(text) + except Exception: + await message.answer("❌ Введите корректный Telegram ID (число)") + return + ok = False + if action == "remove_moderator": + ok = SupportSettingsService.remove_moderator(tid) + msg = "✅ Модератор удалён" if ok else "❌ Не удалось удалить" + else: + ok = SupportSettingsService.add_moderator(tid) + msg = "✅ Пользователь назначен модератором" if ok else "❌ Не удалось назначить" + await state.clear() + markup = types.InlineKeyboardMarkup( + inline_keyboard=[[types.InlineKeyboardButton(text="🗑 Удалить", callback_data="admin_support_delete_msg")]] + ) + await message.answer(msg, reply_markup=markup) + + +@admin_required +@error_handler +async def list_moderators(callback: types.CallbackQuery, db_user: User, db: AsyncSession): + moderators = SupportSettingsService.get_moderators() + if not moderators: + await callback.answer("Список пуст", show_alert=True) + return + text = "🧑‍⚖️ Модераторы\n\n" + "\n".join([f"• {tid}" for tid in moderators]) + markup = types.InlineKeyboardMarkup( + inline_keyboard=[[types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_support_settings")]] + ) + await callback.message.edit_text(text, parse_mode="HTML", reply_markup=markup) + await callback.answer() + + @admin_required @error_handler async def set_mode_tickets(callback: types.CallbackQuery, db_user: User, db: AsyncSession): @@ -177,9 +396,17 @@ async def send_desc_copy(callback: types.CallbackQuery, db_user: User, db: Async await callback.answer("Текст отправлен ниже") -@admin_required @error_handler async def delete_sent_message(callback: types.CallbackQuery, db_user: User, db: AsyncSession): + # Allow admins and moderators to delete informational notifications + try: + may_delete = (settings.is_admin(callback.from_user.id) or SupportSettingsService.is_moderator(callback.from_user.id)) + except Exception: + may_delete = False + if not may_delete: + texts = get_texts(db_user.language if db_user else 'ru') + await callback.answer(texts.ACCESS_DENIED, show_alert=True) + return try: await callback.message.delete() finally: @@ -196,6 +423,15 @@ def register_handlers(dp: Dispatcher): dp.callback_query.register(start_edit_desc, F.data == "admin_support_edit_desc") dp.callback_query.register(send_desc_copy, F.data == "admin_support_send_desc") dp.callback_query.register(delete_sent_message, F.data == "admin_support_delete_msg") + dp.callback_query.register(toggle_admin_notifications, F.data == "admin_support_toggle_admin_notifications") + dp.callback_query.register(toggle_user_notifications, F.data == "admin_support_toggle_user_notifications") + dp.callback_query.register(toggle_sla, F.data == "admin_support_toggle_sla") + dp.callback_query.register(start_set_sla_minutes, F.data == "admin_support_set_sla_minutes") + dp.callback_query.register(start_add_moderator, F.data == "admin_support_add_moderator") + dp.callback_query.register(start_remove_moderator, F.data == "admin_support_remove_moderator") + dp.callback_query.register(list_moderators, F.data == "admin_support_list_moderators") dp.message.register(handle_new_desc, SupportSettingsStates.waiting_for_desc) + dp.message.register(handle_sla_minutes, SupportAdvancedStates.waiting_for_sla_minutes) + dp.message.register(handle_moderator_id, SupportAdvancedStates.waiting_for_moderator_id) diff --git a/app/handlers/admin/tickets.py b/app/handlers/admin/tickets.py index a444162d..2503cd48 100644 --- a/app/handlers/admin/tickets.py +++ b/app/handlers/admin/tickets.py @@ -1,6 +1,7 @@ import logging from typing import List, Dict, Any, Optional from aiogram import Dispatcher, types, F, Bot +from aiogram.exceptions import TelegramBadRequest from aiogram.fsm.context import FSMContext from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select, desc, and_ @@ -18,6 +19,7 @@ from app.keyboards.inline import ( from app.localization.texts import get_texts from app.utils.pagination import paginate_list, get_pagination_info from app.services.admin_notification_service import AdminNotificationService +from app.services.support_settings_service import SupportSettingsService from app.config import settings from app.utils.cache import RateLimitCache @@ -33,6 +35,11 @@ async def show_admin_tickets( db: AsyncSession ): """Показать все тикеты для админов""" + # permission gate: admin or active moderator only + if not (settings.is_admin(callback.from_user.id) or SupportSettingsService.is_moderator(callback.from_user.id)): + texts = get_texts(db_user.language) + await callback.answer(texts.ACCESS_DENIED, show_alert=True) + return texts = get_texts(db_user.language) # Определяем текущую страницу и scope @@ -59,6 +66,8 @@ async def show_admin_tickets( # total count for proper pagination total_count = await TicketCRUD.count_tickets_by_statuses(db, statuses) total_pages = max(1, (total_count + page_size - 1) // page_size) if total_count > 0 else 1 + if current_page < 1: + current_page = 1 if current_page > total_pages: current_page = total_pages offset = (current_page - 1) * page_size @@ -81,9 +90,33 @@ async def show_admin_tickets( }) # Итоговые страницы уже посчитаны выше - await callback.message.edit_text( - texts.t("ADMIN_TICKETS_TITLE", "🎫 Все тикеты поддержки:"), - reply_markup=get_admin_tickets_keyboard(ticket_data, current_page=current_page, total_pages=total_pages, language=db_user.language, scope=scope) + header_text = ( + texts.t("ADMIN_TICKETS_TITLE_OPEN", "🎫 Открытые тикеты поддержки:") + if scope == "open" + else texts.t("ADMIN_TICKETS_TITLE_CLOSED", "🎫 Закрытые тикеты поддержки:") + ) + # Determine proper back target for moderators + back_cb = "admin_submenu_support" + try: + if not settings.is_admin(callback.from_user.id) and SupportSettingsService.is_moderator(callback.from_user.id): + back_cb = "moderator_panel" + except Exception: + pass + + keyboard = get_admin_tickets_keyboard( + ticket_data, + current_page=current_page, + total_pages=total_pages, + language=db_user.language, + scope=scope, + back_callback=back_cb, + ) + from app.utils.photo_message import edit_or_answer_photo + await edit_or_answer_photo( + callback=callback, + caption=header_text, + keyboard=keyboard, + parse_mode="HTML", ) await callback.answer() @@ -96,6 +129,11 @@ async def view_admin_ticket( ticket_id: Optional[int] = None ): """Показать детали тикета для админа""" + if not (settings.is_admin(callback.from_user.id) or SupportSettingsService.is_moderator(callback.from_user.id)): + texts = get_texts(db_user.language) + await callback.answer(texts.ACCESS_DENIED, show_alert=True) + return + if ticket_id is None: try: ticket_id = int((callback.data or "").split("_")[-1]) @@ -158,9 +196,10 @@ async def view_admin_ticket( # Добавим кнопку "Вложения", если есть фото has_photos = any(getattr(m, "has_media", False) and getattr(m, "media_type", None) == "photo" for m in ticket.messages or []) keyboard = get_admin_ticket_view_keyboard( - ticket_id, - ticket.is_closed, - db_user.language + ticket_id, + ticket.is_closed, + db_user.language, + is_user_blocked=ticket.is_user_reply_blocked ) if has_photos: try: @@ -168,24 +207,20 @@ async def view_admin_ticket( except Exception: pass - # Сначала пробуем отредактировать; если не вышло — удалим и отправим новое - try: - await callback.message.edit_text( - ticket_text, - reply_markup=keyboard, - ) - except Exception: + # Рендер через фото-утилиту (с логотипом), внутри есть фоллбеки на текст + from app.utils.photo_message import edit_or_answer_photo + await edit_or_answer_photo( + callback=callback, + caption=ticket_text, + keyboard=keyboard, + parse_mode="HTML", + ) + # сохраняем id для дальнейших действий (ответ/статусы) + if state is not None: try: - await callback.message.delete() + await state.update_data(ticket_id=ticket_id) except Exception: pass - await callback.message.answer( - ticket_text, - reply_markup=keyboard, - ) - # сохраняем id для дальнейших действий (ответ/статусы) - if state: - await state.update_data(ticket_id=ticket_id) await callback.answer() @@ -195,6 +230,10 @@ async def reply_to_admin_ticket( db_user: User ): """Начать ответ на тикет от админа""" + if not (settings.is_admin(callback.from_user.id) or SupportSettingsService.is_moderator(callback.from_user.id)): + texts = get_texts(db_user.language) + await callback.answer(texts.ACCESS_DENIED, show_alert=True) + return ticket_id = int(callback.data.replace("admin_reply_ticket_", "")) await state.update_data(ticket_id=ticket_id, reply_mode=True) @@ -214,6 +253,11 @@ async def handle_admin_ticket_reply( db_user: User, db: AsyncSession ): + if not (settings.is_admin(message.from_user.id) or SupportSettingsService.is_moderator(message.from_user.id)): + texts = get_texts(db_user.language) + await message.answer(texts.ACCESS_DENIED) + await state.clear() + return # Проверяем, что пользователь в правильном состоянии current_state = await state.get_state() if current_state != AdminTicketStates.waiting_for_reply: @@ -388,17 +432,42 @@ async def close_admin_ticket( db: AsyncSession ): """Закрыть тикет админом""" + if not (settings.is_admin(callback.from_user.id) or SupportSettingsService.is_moderator(callback.from_user.id)): + texts = get_texts(db_user.language) + await callback.answer(texts.ACCESS_DENIED, show_alert=True) + return ticket_id = int(callback.data.replace("admin_close_ticket_", "")) try: success = await TicketCRUD.close_ticket(db, ticket_id) if success: + # audit + try: + is_mod = (not settings.is_admin(callback.from_user.id) and SupportSettingsService.is_moderator(callback.from_user.id)) + await TicketCRUD.add_support_audit( + db, + actor_user_id=db_user.id if db_user else None, + actor_telegram_id=callback.from_user.id, + is_moderator=is_mod, + action="close_ticket", + ticket_id=ticket_id, + target_user_id=None, + details={} + ) + except Exception: + pass texts = get_texts(db_user.language) - await callback.answer( - texts.t("TICKET_CLOSED", "✅ Тикет закрыт."), - show_alert=True - ) + # Notify with deletable inline message + try: + await callback.message.answer( + texts.t("TICKET_CLOSED", "✅ Тикет закрыт."), + reply_markup=types.InlineKeyboardMarkup( + inline_keyboard=[[types.InlineKeyboardButton(text="🗑 Удалить", callback_data="admin_support_delete_msg")]] + ) + ) + except Exception: + await callback.answer(texts.t("TICKET_CLOSED", "✅ Тикет закрыт."), show_alert=True) # Обновляем inline-клавиатуру в текущем сообщении без кнопок действий await callback.message.edit_reply_markup( @@ -426,6 +495,10 @@ async def cancel_admin_ticket_reply( db_user: User ): """Отменить ответ админа на тикет""" + if not (settings.is_admin(callback.from_user.id) or SupportSettingsService.is_moderator(callback.from_user.id)): + texts = get_texts(db_user.language) + await callback.answer(texts.ACCESS_DENIED, show_alert=True) + return await state.clear() texts = get_texts(db_user.language) @@ -448,13 +521,22 @@ async def block_user_in_ticket( db_user: User, db: AsyncSession ): + if not (settings.is_admin(callback.from_user.id) or SupportSettingsService.is_moderator(callback.from_user.id)): + texts = get_texts(db_user.language) + await callback.answer(texts.ACCESS_DENIED, show_alert=True) + return ticket_id = int(callback.data.replace("admin_block_user_ticket_", "")) texts = get_texts(db_user.language) + # Save original ticket message ids to update it after blocking without reopening + try: + await state.update_data(origin_chat_id=callback.message.chat.id, origin_message_id=callback.message.message_id) + except Exception: + pass await callback.message.edit_text( texts.t("ENTER_BLOCK_MINUTES", "Введите количество минут для блокировки пользователя (например, 15):"), reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[ [types.InlineKeyboardButton( - text=texts.t("CANCEL_REPLY", "❌ Отменить ответ"), + text=texts.t("CANCEL_REPLY", "❌ Отменить ввод"), callback_data="cancel_admin_ticket_reply" )] ]) @@ -470,6 +552,12 @@ async def handle_admin_block_duration_input( db_user: User, db: AsyncSession ): + # permission gate for message flow + if not (settings.is_admin(message.from_user.id) or SupportSettingsService.is_moderator(message.from_user.id)): + texts = get_texts(db_user.language) + await message.answer(texts.ACCESS_DENIED) + await state.clear() + return # Проверяем состояние current_state = await state.get_state() if current_state != AdminTicketStates.waiting_for_block_duration: @@ -482,6 +570,8 @@ async def handle_admin_block_duration_input( data = await state.get_data() ticket_id = data.get("ticket_id") + origin_chat_id = data.get("origin_chat_id") + origin_message_id = data.get("origin_message_id") try: minutes = int(reply_text) minutes = max(1, min(60*24*365, minutes)) # максимум 1 год @@ -505,15 +595,76 @@ async def handle_admin_block_duration_input( until = datetime.utcnow() + timedelta(minutes=minutes) ok = await TicketCRUD.set_user_reply_block(db, ticket_id, permanent=False, until=until) - if ok: - await message.answer(f"✅ Пользователь заблокирован на {minutes} минут") - else: + if not ok: await message.answer("❌ Ошибка блокировки") - await state.clear() - await message.answer( - "✅ Блокировка установлена. Откройте тикет заново для обновления состояния.", - reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[[types.InlineKeyboardButton(text="👁️ Посмотреть тикет", callback_data=f"admin_view_ticket_{ticket_id}")]]) - ) + return + # audit + try: + is_mod = (not settings.is_admin(message.from_user.id) and SupportSettingsService.is_moderator(message.from_user.id)) + await TicketCRUD.add_support_audit( + db, + actor_user_id=db_user.id if db_user else None, + actor_telegram_id=message.from_user.id, + is_moderator=is_mod, + action="block_user_timed", + ticket_id=ticket_id, + target_user_id=ticket.user_id if ticket else None, + details={"minutes": minutes} + ) + except Exception: + pass + # Refresh original ticket card (caption/text and buttons) in place + try: + updated = await TicketCRUD.get_ticket_by_id(db, ticket_id, load_messages=True, load_user=True) + texts = get_texts(db_user.language) + status_text = { + TicketStatus.OPEN.value: texts.t("TICKET_STATUS_OPEN", "Открыт"), + TicketStatus.ANSWERED.value: texts.t("TICKET_STATUS_ANSWERED", "Отвечен"), + TicketStatus.CLOSED.value: texts.t("TICKET_STATUS_CLOSED", "Закрыт"), + TicketStatus.PENDING.value: texts.t("TICKET_STATUS_PENDING", "В ожидании") + }.get(updated.status, updated.status) + user_name = updated.user.full_name if updated.user else "Unknown" + ticket_text = f"🎫 Тикет #{updated.id}\n\n" + ticket_text += f"👤 Пользователь: {user_name}\n" + ticket_text += f"📝 Заголовок: {updated.title}\n" + ticket_text += f"📊 Статус: {updated.status_emoji} {status_text}\n" + ticket_text += f"📅 Создан: {updated.created_at.strftime('%d.%m.%Y %H:%M')}\n" + ticket_text += f"🔄 Обновлен: {updated.updated_at.strftime('%d.%m.%Y %H:%M')}\n\n" + if updated.is_user_reply_blocked: + if updated.user_reply_block_permanent: + ticket_text += "🚫 Пользователь заблокирован навсегда для ответов в этом тикете\n" + elif updated.user_reply_block_until: + ticket_text += f"⏳ Блок до: {updated.user_reply_block_until.strftime('%d.%m.%Y %H:%M')}\n" + if updated.messages: + ticket_text += f"💬 Сообщения ({len(updated.messages)}):\n\n" + for msg in updated.messages: + sender = "👤 Пользователь" if msg.is_user_message else "🛠️ Поддержка" + ticket_text += f"{sender} ({msg.created_at.strftime('%d.%m %H:%M')}):\n" + ticket_text += f"{msg.message_text}\n\n" + if getattr(msg, "has_media", False) and getattr(msg, "media_type", None) == "photo": + ticket_text += "📎 Вложение: фото\n\n" + + kb = get_admin_ticket_view_keyboard(updated.id, updated.is_closed, db_user.language, is_user_blocked=updated.is_user_reply_blocked) + has_photos = any(getattr(m, "has_media", False) and getattr(m, "media_type", None) == "photo" for m in updated.messages or []) + if has_photos: + try: + kb.inline_keyboard.insert(0, [types.InlineKeyboardButton(text=texts.t("TICKET_ATTACHMENTS", "📎 Вложения"), callback_data=f"admin_ticket_attachments_{updated.id}")]) + except Exception: + pass + if origin_chat_id and origin_message_id: + try: + await message.bot.edit_message_caption(chat_id=origin_chat_id, message_id=origin_message_id, caption=ticket_text, reply_markup=kb, parse_mode="HTML") + except Exception: + try: + await message.bot.edit_message_text(chat_id=origin_chat_id, message_id=origin_message_id, text=ticket_text, reply_markup=kb, parse_mode="HTML") + except Exception: + await message.answer(f"✅ Пользователь заблокирован на {minutes} минут") + else: + await message.answer(f"✅ Пользователь заблокирован на {minutes} минут") + except Exception: + await message.answer(f"✅ Пользователь заблокирован на {minutes} минут") + finally: + await state.clear() except Exception as e: logger.error(f"Error setting block duration: {e}") texts = get_texts(db_user.language) @@ -522,19 +673,44 @@ async def handle_admin_block_duration_input( - - - async def unblock_user_in_ticket( callback: types.CallbackQuery, db_user: User, db: AsyncSession, state: FSMContext ): + if not (settings.is_admin(callback.from_user.id) or SupportSettingsService.is_moderator(callback.from_user.id)): + texts = get_texts(db_user.language) + await callback.answer(texts.ACCESS_DENIED, show_alert=True) + return ticket_id = int(callback.data.replace("admin_unblock_user_ticket_", "")) ok = await TicketCRUD.set_user_reply_block(db, ticket_id, permanent=False, until=None) if ok: - await callback.answer("✅ Блок снят") + try: + await callback.message.answer( + "✅ Блок снят", + reply_markup=types.InlineKeyboardMarkup( + inline_keyboard=[[types.InlineKeyboardButton(text="🗑 Удалить", callback_data="admin_support_delete_msg")]] + ) + ) + except Exception: + await callback.answer("✅ Блок снят") + # audit + try: + is_mod = (not settings.is_admin(callback.from_user.id) and SupportSettingsService.is_moderator(callback.from_user.id)) + ticket_id = int(callback.data.replace("admin_unblock_user_ticket_", "")) + await TicketCRUD.add_support_audit( + db, + actor_user_id=db_user.id if db_user else None, + actor_telegram_id=callback.from_user.id, + is_moderator=is_mod, + action="unblock_user", + ticket_id=ticket_id, + target_user_id=None, + details={} + ) + except Exception: + pass await view_admin_ticket(callback, db_user, db, state) else: await callback.answer("❌ Ошибка", show_alert=True) @@ -546,10 +722,37 @@ async def block_user_permanently( db: AsyncSession, state: FSMContext ): + if not (settings.is_admin(callback.from_user.id) or SupportSettingsService.is_moderator(callback.from_user.id)): + texts = get_texts(db_user.language) + await callback.answer(texts.ACCESS_DENIED, show_alert=True) + return ticket_id = int(callback.data.replace("admin_block_user_perm_ticket_", "")) ok = await TicketCRUD.set_user_reply_block(db, ticket_id, permanent=True, until=None) if ok: - await callback.answer("✅ Пользователь заблокирован навсегда") + try: + await callback.message.answer( + "✅ Пользователь заблокирован навсегда", + reply_markup=types.InlineKeyboardMarkup( + inline_keyboard=[[types.InlineKeyboardButton(text="🗑 Удалить", callback_data="admin_support_delete_msg")]] + ) + ) + except Exception: + await callback.answer("✅ Пользователь заблокирован") + # audit + try: + is_mod = (not settings.is_admin(callback.from_user.id) and SupportSettingsService.is_moderator(callback.from_user.id)) + await TicketCRUD.add_support_audit( + db, + actor_user_id=db_user.id if db_user else None, + actor_telegram_id=callback.from_user.id, + is_moderator=is_mod, + action="block_user_perm", + ticket_id=ticket_id, + target_user_id=None, + details={} + ) + except Exception: + pass await view_admin_ticket(callback, db_user, db, state) else: await callback.answer("❌ Ошибка", show_alert=True) @@ -558,6 +761,12 @@ async def block_user_permanently( async def notify_user_about_ticket_reply(bot: Bot, ticket: Ticket, reply_text: str, db: AsyncSession): """Уведомить пользователя о новом ответе в тикете""" try: + # Respect runtime toggle for user ticket notifications + try: + if not SupportSettingsService.get_user_ticket_notifications_enabled(): + return + except Exception: + pass from app.localization.texts import get_texts # Получаем тикет с пользователем @@ -655,6 +864,11 @@ def register_handlers(dp: Dispatcher): db_user: User, db: AsyncSession ): + # permission gate for attachments view + if not (settings.is_admin(callback.from_user.id) or SupportSettingsService.is_moderator(callback.from_user.id)): + texts = get_texts(db_user.language) + await callback.answer(texts.ACCESS_DENIED, show_alert=True) + return texts = get_texts(db_user.language) try: ticket_id = int(callback.data.replace("admin_ticket_attachments_", "")) diff --git a/app/handlers/balance.py b/app/handlers/balance.py index 870b9649..ab9f205f 100644 --- a/app/handlers/balance.py +++ b/app/handlers/balance.py @@ -332,6 +332,84 @@ async def start_yookassa_sbp_payment( await callback.answer() +@error_handler +async def start_mulenpay_payment( + callback: types.CallbackQuery, + db_user: User, + state: FSMContext, +): + texts = get_texts(db_user.language) + + if not settings.is_mulenpay_enabled(): + await callback.answer("❌ Оплата через Mulen Pay временно недоступна", show_alert=True) + return + + message_text = texts.t( + "MULENPAY_TOPUP_PROMPT", + ( + "💳 Оплата через Mulen Pay\n\n" + "Введите сумму для пополнения от 100 до 100 000 ₽.\n" + "Оплата происходит через защищенную платформу Mulen Pay." + ), + ) + + keyboard = get_back_keyboard(db_user.language) + + if settings.YOOKASSA_QUICK_AMOUNT_SELECTION_ENABLED: + quick_amount_buttons = get_quick_amount_buttons(db_user.language) + if quick_amount_buttons: + keyboard.inline_keyboard = quick_amount_buttons + keyboard.inline_keyboard + + await callback.message.edit_text( + message_text, + reply_markup=keyboard, + parse_mode="HTML", + ) + + await state.set_state(BalanceStates.waiting_for_amount) + await state.update_data(payment_method="mulenpay") + await callback.answer() + + +@error_handler +async def start_pal24_payment( + callback: types.CallbackQuery, + db_user: User, + state: FSMContext, +): + texts = get_texts(db_user.language) + + if not settings.is_pal24_enabled(): + await callback.answer("❌ Оплата через PayPalych временно недоступна", show_alert=True) + return + + message_text = texts.t( + "PAL24_TOPUP_PROMPT", + ( + "💳 Оплата через PayPalych\n\n" + "Введите сумму для пополнения от 100 до 1 000 000 ₽.\n" + "Оплата проходит через защищенную платформу PayPalych." + ), + ) + + keyboard = get_back_keyboard(db_user.language) + + if settings.YOOKASSA_QUICK_AMOUNT_SELECTION_ENABLED: + quick_amount_buttons = get_quick_amount_buttons(db_user.language) + if quick_amount_buttons: + keyboard.inline_keyboard = quick_amount_buttons + keyboard.inline_keyboard + + await callback.message.edit_text( + message_text, + reply_markup=keyboard, + parse_mode="HTML", + ) + + await state.set_state(BalanceStates.waiting_for_amount) + await state.update_data(payment_method="pal24") + await callback.answer() + + @error_handler async def start_tribute_payment( callback: types.CallbackQuery, @@ -523,10 +601,18 @@ async def process_topup_amount( from app.database.database import AsyncSessionLocal async with AsyncSessionLocal() as db: await process_yookassa_payment_amount(message, db_user, db, amount_kopeks, state) - elif payment_method == "yookassa_sbp": + elif payment_method == "yookassa_sbp": from app.database.database import AsyncSessionLocal async with AsyncSessionLocal() as db: await process_yookassa_sbp_payment_amount(message, db_user, db, amount_kopeks, state) + elif payment_method == "mulenpay": + from app.database.database import AsyncSessionLocal + async with AsyncSessionLocal() as db: + await process_mulenpay_payment_amount(message, db_user, db, amount_kopeks, state) + elif payment_method == "pal24": + from app.database.database import AsyncSessionLocal + async with AsyncSessionLocal() as db: + await process_pal24_payment_amount(message, db_user, db, amount_kopeks, state) elif payment_method == "cryptobot": from app.database.database import AsyncSessionLocal async with AsyncSessionLocal() as db: @@ -760,6 +846,241 @@ async def process_yookassa_sbp_payment_amount( await state.clear() +@error_handler +async def process_mulenpay_payment_amount( + message: types.Message, + db_user: User, + db: AsyncSession, + amount_kopeks: int, + state: FSMContext, +): + texts = get_texts(db_user.language) + + if not settings.is_mulenpay_enabled(): + await message.answer("❌ Оплата через Mulen Pay временно недоступна") + return + + if amount_kopeks < settings.MULENPAY_MIN_AMOUNT_KOPEKS: + await message.answer( + f"Минимальная сумма пополнения: {settings.format_price(settings.MULENPAY_MIN_AMOUNT_KOPEKS)}" + ) + return + + if amount_kopeks > settings.MULENPAY_MAX_AMOUNT_KOPEKS: + await message.answer( + f"Максимальная сумма пополнения: {settings.format_price(settings.MULENPAY_MAX_AMOUNT_KOPEKS)}" + ) + return + + amount_rubles = amount_kopeks / 100 + + try: + payment_service = PaymentService(message.bot) + payment_result = await payment_service.create_mulenpay_payment( + db=db, + user_id=db_user.id, + amount_kopeks=amount_kopeks, + description=settings.get_balance_payment_description(amount_kopeks), + language=db_user.language, + ) + + if not payment_result or not payment_result.get("payment_url"): + await message.answer( + texts.t( + "MULENPAY_PAYMENT_ERROR", + "❌ Ошибка создания платежа Mulen Pay. Попробуйте позже или обратитесь в поддержку.", + ) + ) + await state.clear() + return + + payment_url = payment_result.get("payment_url") + mulen_payment_id = payment_result.get("mulen_payment_id") + local_payment_id = payment_result.get("local_payment_id") + + keyboard = types.InlineKeyboardMarkup( + inline_keyboard=[ + [ + types.InlineKeyboardButton( + text=texts.t( + "MULENPAY_PAY_BUTTON", + "💳 Оплатить через Mulen Pay", + ), + url=payment_url, + ) + ], + [ + types.InlineKeyboardButton( + text=texts.t("CHECK_STATUS_BUTTON", "📊 Проверить статус"), + callback_data=f"check_mulenpay_{local_payment_id}", + ) + ], + [types.InlineKeyboardButton(text=texts.BACK, callback_data="balance_topup")], + ] + ) + + payment_id_display = mulen_payment_id if mulen_payment_id is not None else local_payment_id + + message_template = texts.t( + "MULENPAY_PAYMENT_INSTRUCTIONS", + ( + "💳 Оплата через Mulen Pay\n\n" + "💰 Сумма: {amount}\n" + "🆔 ID платежа: {payment_id}\n\n" + "📱 Инструкция:\n" + "1. Нажмите кнопку ‘Оплатить через Mulen Pay’\n" + "2. Следуйте подсказкам платежной системы\n" + "3. Подтвердите перевод\n" + "4. Средства зачислятся автоматически\n\n" + "❓ Если возникнут проблемы, обратитесь в {support}" + ), + ) + + message_text = message_template.format( + amount=settings.format_price(amount_kopeks), + payment_id=payment_id_display, + support=settings.get_support_contact_display_html(), + ) + + await message.answer( + message_text, + reply_markup=keyboard, + parse_mode="HTML", + ) + + await state.clear() + + logger.info( + "Создан MulenPay платеж для пользователя %s: %s₽, ID: %s", + db_user.telegram_id, + amount_rubles, + payment_id_display, + ) + + except Exception as e: + logger.error(f"Ошибка создания MulenPay платежа: {e}") + await message.answer( + texts.t( + "MULENPAY_PAYMENT_ERROR", + "❌ Ошибка создания платежа Mulen Pay. Попробуйте позже или обратитесь в поддержку.", + ) + ) + await state.clear() + + +@error_handler +async def process_pal24_payment_amount( + message: types.Message, + db_user: User, + db: AsyncSession, + amount_kopeks: int, + state: FSMContext, +): + texts = get_texts(db_user.language) + + if not settings.is_pal24_enabled(): + await message.answer("❌ Оплата через PayPalych временно недоступна") + return + + if amount_kopeks < settings.PAL24_MIN_AMOUNT_KOPEKS: + min_rubles = settings.PAL24_MIN_AMOUNT_KOPEKS / 100 + await message.answer(f"❌ Минимальная сумма для оплаты через PayPalych: {min_rubles:.0f} ₽") + return + + if amount_kopeks > settings.PAL24_MAX_AMOUNT_KOPEKS: + max_rubles = settings.PAL24_MAX_AMOUNT_KOPEKS / 100 + await message.answer(f"❌ Максимальная сумма для оплаты через PayPalych: {max_rubles:,.0f} ₽".replace(',', ' ')) + return + + try: + payment_service = PaymentService(message.bot) + payment_result = await payment_service.create_pal24_payment( + db=db, + user_id=db_user.id, + amount_kopeks=amount_kopeks, + description=settings.get_balance_payment_description(amount_kopeks), + language=db_user.language, + ) + + if not payment_result or not payment_result.get("link_url"): + await message.answer( + texts.t( + "PAL24_PAYMENT_ERROR", + "❌ Ошибка создания платежа PayPalych. Попробуйте позже или обратитесь в поддержку.", + ) + ) + await state.clear() + return + + link_url = payment_result.get("link_url") + bill_id = payment_result.get("bill_id") + local_payment_id = payment_result.get("local_payment_id") + + keyboard = types.InlineKeyboardMarkup( + inline_keyboard=[ + [ + types.InlineKeyboardButton( + text=texts.t("PAL24_PAY_BUTTON", "💳 Оплатить через PayPalych"), + url=link_url, + ) + ], + [ + types.InlineKeyboardButton( + text=texts.t("CHECK_STATUS_BUTTON", "📊 Проверить статус"), + callback_data=f"check_pal24_{local_payment_id}", + ) + ], + [types.InlineKeyboardButton(text=texts.BACK, callback_data="balance_topup")], + ] + ) + + message_template = texts.t( + "PAL24_PAYMENT_INSTRUCTIONS", + ( + "💳 Оплата через PayPalych\n\n" + "💰 Сумма: {amount}\n" + "🆔 ID счета: {bill_id}\n\n" + "📱 Инструкция:\n" + "1. Нажмите кнопку ‘Оплатить через PayPalych’\n" + "2. Следуйте подсказкам платежной системы\n" + "3. Подтвердите перевод\n" + "4. Средства зачислятся автоматически\n\n" + "❓ Если возникнут проблемы, обратитесь в {support}" + ), + ) + + message_text = message_template.format( + amount=settings.format_price(amount_kopeks), + bill_id=bill_id, + support=settings.get_support_contact_display_html(), + ) + + await message.answer( + message_text, + reply_markup=keyboard, + parse_mode="HTML", + ) + + await state.clear() + + logger.info( + "Создан PayPalych счет для пользователя %s: %s₽, ID: %s", + db_user.telegram_id, + amount_kopeks / 100, + bill_id, + ) + + except Exception as e: + logger.error(f"Ошибка создания PayPalych платежа: {e}") + await message.answer( + texts.t( + "PAL24_PAYMENT_ERROR", + "❌ Ошибка создания платежа PayPalych. Попробуйте позже или обратитесь в поддержку.", + ) + ) + await state.clear() + + @error_handler async def check_yookassa_payment_status( callback: types.CallbackQuery, @@ -815,6 +1136,122 @@ async def check_yookassa_payment_status( logger.error(f"Ошибка проверки статуса платежа: {e}") await callback.answer("❌ Ошибка проверки статуса", show_alert=True) + +@error_handler +async def check_mulenpay_payment_status( + callback: types.CallbackQuery, + db: AsyncSession +): + try: + local_payment_id = int(callback.data.split('_')[-1]) + payment_service = PaymentService(callback.bot) + status_info = await payment_service.get_mulenpay_payment_status(db, local_payment_id) + + if not status_info: + await callback.answer("❌ Платеж не найден", show_alert=True) + return + + payment = status_info["payment"] + + status_labels = { + "created": ("⏳", "Ожидает оплаты"), + "processing": ("⌛", "Обрабатывается"), + "success": ("✅", "Оплачен"), + "canceled": ("❌", "Отменен"), + "error": ("⚠️", "Ошибка"), + "hold": ("🔒", "Холд"), + "unknown": ("❓", "Неизвестно"), + } + + emoji, status_text = status_labels.get(payment.status, ("❓", "Неизвестно")) + + message_lines = [ + "💳 Статус платежа Mulen Pay:\n\n", + f"🆔 ID: {payment.mulen_payment_id or payment.id}\n", + f"💰 Сумма: {settings.format_price(payment.amount_kopeks)}\n", + f"📊 Статус: {emoji} {status_text}\n", + f"📅 Создан: {payment.created_at.strftime('%d.%m.%Y %H:%M')}\n", + ] + + if payment.is_paid: + message_lines.append("\n✅ Платеж успешно завершен! Средства уже на балансе.") + elif payment.status in {"created", "processing"}: + message_lines.append( + "\n⏳ Платеж еще не завершен. Завершите оплату по ссылке и проверьте статус позже." + ) + if payment.payment_url: + message_lines.append(f"\n🔗 Ссылка на оплату: {payment.payment_url}") + elif payment.status in {"canceled", "error"}: + message_lines.append( + f"\n❌ Платеж не был завершен. Попробуйте создать новый платеж или обратитесь в {settings.get_support_contact_display()}" + ) + + message_text = "".join(message_lines) + + if len(message_text) > 190: + await callback.message.answer(message_text) + await callback.answer("ℹ️ Статус платежа отправлен в чат", show_alert=True) + else: + await callback.answer(message_text, show_alert=True) + + except Exception as e: + logger.error(f"Ошибка проверки статуса MulenPay: {e}") + await callback.answer("❌ Ошибка проверки статуса", show_alert=True) + + +@error_handler +async def check_pal24_payment_status( + callback: types.CallbackQuery, + db: AsyncSession, +): + try: + local_payment_id = int(callback.data.split('_')[-1]) + payment_service = PaymentService(callback.bot) + status_info = await payment_service.get_pal24_payment_status(db, local_payment_id) + + if not status_info: + await callback.answer("❌ Платеж не найден", show_alert=True) + return + + payment = status_info["payment"] + + status_labels = { + "NEW": ("⏳", "Ожидает оплаты"), + "PROCESS": ("⌛", "Обрабатывается"), + "SUCCESS": ("✅", "Оплачен"), + "FAIL": ("❌", "Отменен"), + "UNDERPAID": ("⚠️", "Недоплата"), + "OVERPAID": ("⚠️", "Переплата"), + } + + emoji, status_text = status_labels.get(payment.status, ("❓", "Неизвестно")) + + message_lines = [ + "💳 Статус платежа PayPalych:\n\n", + f"🆔 ID счета: {payment.bill_id}\n", + f"💰 Сумма: {settings.format_price(payment.amount_kopeks)}\n", + f"📊 Статус: {emoji} {status_text}\n", + f"📅 Создан: {payment.created_at.strftime('%d.%m.%Y %H:%M')}\n", + ] + + if payment.is_paid: + message_lines.append("\n✅ Платеж успешно завершен! Средства уже на балансе.") + elif payment.status in {"NEW", "PROCESS"}: + message_lines.append("\n⏳ Платеж еще не завершен. Оплатите счет и проверьте статус позже.") + if payment.link_url: + message_lines.append(f"\n🔗 Ссылка на оплату: {payment.link_url}") + elif payment.status in {"FAIL", "UNDERPAID", "OVERPAID"}: + message_lines.append( + f"\n❌ Платеж не завершен корректно. Обратитесь в {settings.get_support_contact_display()}" + ) + + await callback.answer("".join(message_lines), show_alert=True) + + except Exception as e: + logger.error(f"Ошибка проверки статуса PayPalych: {e}") + await callback.answer("❌ Ошибка проверки статуса", show_alert=True) + + @error_handler async def start_cryptobot_payment( callback: types.CallbackQuery, @@ -1085,6 +1522,12 @@ async def handle_quick_amount_selection( await process_yookassa_sbp_payment_amount( callback.message, db_user, db, amount_kopeks, state ) + elif payment_method == "mulenpay": + from app.database.database import AsyncSessionLocal + async with AsyncSessionLocal() as db: + await process_mulenpay_payment_amount( + callback.message, db_user, db, amount_kopeks, state + ) else: await callback.answer("❌ Неизвестный способ оплаты", show_alert=True) return @@ -1096,6 +1539,77 @@ async def handle_quick_amount_selection( await callback.answer("❌ Ошибка обработки запроса", show_alert=True) +@error_handler +async def handle_topup_amount_callback( + callback: types.CallbackQuery, + db_user: User, + state: FSMContext, +): + try: + _, method, amount_str = callback.data.split("|", 2) + amount_kopeks = int(amount_str) + except ValueError: + await callback.answer("❌ Некорректный запрос", show_alert=True) + return + + if amount_kopeks <= 0: + await callback.answer("❌ Некорректная сумма", show_alert=True) + return + + try: + if method == "yookassa": + from app.database.database import AsyncSessionLocal + + async with AsyncSessionLocal() as db: + await process_yookassa_payment_amount( + callback.message, db_user, db, amount_kopeks, state + ) + elif method == "yookassa_sbp": + from app.database.database import AsyncSessionLocal + + async with AsyncSessionLocal() as db: + await process_yookassa_sbp_payment_amount( + callback.message, db_user, db, amount_kopeks, state + ) + elif method == "mulenpay": + from app.database.database import AsyncSessionLocal + + async with AsyncSessionLocal() as db: + await process_mulenpay_payment_amount( + callback.message, db_user, db, amount_kopeks, state + ) + elif method == "pal24": + from app.database.database import AsyncSessionLocal + + async with AsyncSessionLocal() as db: + await process_pal24_payment_amount( + callback.message, db_user, db, amount_kopeks, state + ) + elif method == "cryptobot": + from app.database.database import AsyncSessionLocal + + async with AsyncSessionLocal() as db: + await process_cryptobot_payment_amount( + callback.message, db_user, db, amount_kopeks, state + ) + elif method == "stars": + await process_stars_payment_amount( + callback.message, db_user, amount_kopeks, state + ) + elif method == "tribute": + await start_tribute_payment(callback, db_user) + return + else: + await callback.answer("❌ Неизвестный способ оплаты", show_alert=True) + return + + await callback.answer() + + except Exception as error: + logger.error(f"Ошибка быстрого пополнения: {error}") + await callback.answer("❌ Ошибка обработки запроса", show_alert=True) + + def register_handlers(dp: Dispatcher): dp.callback_query.register( @@ -1132,12 +1646,22 @@ def register_handlers(dp: Dispatcher): start_yookassa_sbp_payment, F.data == "topup_yookassa_sbp" ) - + + dp.callback_query.register( + start_mulenpay_payment, + F.data == "topup_mulenpay" + ) + + dp.callback_query.register( + start_pal24_payment, + F.data == "topup_pal24" + ) + dp.callback_query.register( check_yookassa_payment_status, F.data.startswith("check_yookassa_") ) - + dp.callback_query.register( start_tribute_payment, F.data == "topup_tribute" @@ -1168,6 +1692,16 @@ def register_handlers(dp: Dispatcher): F.data.startswith("check_cryptobot_") ) + dp.callback_query.register( + check_mulenpay_payment_status, + F.data.startswith("check_mulenpay_") + ) + + dp.callback_query.register( + check_pal24_payment_status, + F.data.startswith("check_pal24_") + ) + dp.callback_query.register( handle_payment_methods_unavailable, F.data == "payment_methods_unavailable" @@ -1178,3 +1712,8 @@ def register_handlers(dp: Dispatcher): handle_quick_amount_selection, F.data.startswith("quick_amount_") ) + + dp.callback_query.register( + handle_topup_amount_callback, + F.data.startswith("topup_amount|") + ) diff --git a/app/handlers/common.py b/app/handlers/common.py index 05ecb178..3c8549f2 100644 --- a/app/handlers/common.py +++ b/app/handlers/common.py @@ -1,5 +1,6 @@ import logging from aiogram import Dispatcher, types, F +from aiogram.filters import StateFilter from aiogram.fsm.context import FSMContext from sqlalchemy.ext.asyncio import AsyncSession @@ -120,5 +121,11 @@ def register_handlers(dp: Dispatcher): ) # Самый последний: ловим любые неизвестные текстовые сообщения - dp.message.register(handle_unknown_message) + # Исключаем специальные сервисные события (например, успешные платежи), + # чтобы их обработка не прерывалась общим хендлером неизвестных сообщений + dp.message.register( + handle_unknown_message, + StateFilter(None), + F.successful_payment.is_(None) + ) \ No newline at end of file diff --git a/app/handlers/menu.py b/app/handlers/menu.py index 9822a144..96f9a8db 100644 --- a/app/handlers/menu.py +++ b/app/handlers/menu.py @@ -16,6 +16,7 @@ from app.services.subscription_checkout_service import ( should_offer_checkout_resume, ) from app.utils.photo_message import edit_or_answer_photo +from app.services.support_settings_service import SupportSettingsService logger = logging.getLogger(__name__) @@ -48,6 +49,7 @@ async def show_main_menu( keyboard=get_main_menu_keyboard( language=db_user.language, is_admin=settings.is_admin(db_user.telegram_id), + is_moderator=(not settings.is_admin(db_user.telegram_id) and SupportSettingsService.is_moderator(db_user.telegram_id)), has_had_paid_subscription=db_user.has_had_paid_subscription, has_active_subscription=has_active_subscription, subscription_is_active=subscription_is_active, @@ -120,6 +122,7 @@ async def handle_back_to_menu( keyboard=get_main_menu_keyboard( language=db_user.language, is_admin=settings.is_admin(db_user.telegram_id), + is_moderator=(not settings.is_admin(db_user.telegram_id) and SupportSettingsService.is_moderator(db_user.telegram_id)), has_had_paid_subscription=db_user.has_had_paid_subscription, has_active_subscription=has_active_subscription, subscription_is_active=subscription_is_active, diff --git a/app/handlers/start.py b/app/handlers/start.py index 4a0175b0..a9f63bca 100644 --- a/app/handlers/start.py +++ b/app/handlers/start.py @@ -17,7 +17,7 @@ from app.database.crud.campaign import ( get_campaign_by_start_parameter, get_campaign_by_id, ) -from app.database.models import UserStatus +from app.database.models import UserStatus, SubscriptionStatus from app.keyboards.inline import ( get_rules_keyboard, get_main_menu_keyboard, get_post_registration_keyboard ) @@ -25,6 +25,7 @@ from app.localization.loader import DEFAULT_LANGUAGE from app.localization.texts import get_texts, get_rules from app.services.referral_service import process_referral_registration from app.services.campaign_service import AdvertisingCampaignService +from app.services.subscription_service import SubscriptionService from app.utils.user_utils import generate_unique_referral_code from app.database.crud.user_message import get_random_active_message @@ -591,7 +592,16 @@ async def complete_registration_from_callback( data = await state.get_data() or {} language = data.get('language', DEFAULT_LANGUAGE) texts = get_texts(language) - + + campaign_id = data.get('campaign_id') + is_new_user_registration = ( + existing_user is None + or ( + existing_user + and existing_user.status == UserStatus.DELETED.value + ) + ) + referrer_id = data.get('referrer_id') if not referrer_id and data.get('referral_code'): referrer = await get_user_by_referral_code(db, data['referral_code']) @@ -689,7 +699,16 @@ async def complete_registration_from_callback( from app.database.crud.welcome_text import get_welcome_text_for_user offer_text = await get_welcome_text_for_user(db, callback.from_user) - if offer_text: + skip_welcome_offer = bool(campaign_id) and is_new_user_registration + + if skip_welcome_offer: + logger.info( + "ℹ️ Пропускаем приветственное предложение для нового пользователя %s из рекламной кампании %s", + user.telegram_id, + campaign_id, + ) + + if offer_text and not skip_welcome_offer: try: await callback.message.answer( offer_text, @@ -797,7 +816,16 @@ async def complete_registration( data = await state.get_data() or {} language = data.get('language', DEFAULT_LANGUAGE) texts = get_texts(language) - + + campaign_id = data.get('campaign_id') + is_new_user_registration = ( + existing_user is None + or ( + existing_user + and existing_user.status == UserStatus.DELETED.value + ) + ) + referrer_id = data.get('referrer_id') if not referrer_id and data.get('referral_code'): referrer = await get_user_by_referral_code(db, data['referral_code']) @@ -895,7 +923,16 @@ async def complete_registration( from app.database.crud.welcome_text import get_welcome_text_for_user offer_text = await get_welcome_text_for_user(db, message.from_user) - if offer_text: + skip_welcome_offer = bool(campaign_id) and is_new_user_registration + + if skip_welcome_offer: + logger.info( + "ℹ️ Пропускаем приветственное предложение для нового пользователя %s из рекламной кампании %s", + user.telegram_id, + campaign_id, + ) + + if offer_text and not skip_welcome_offer: try: await message.answer( offer_text, @@ -1105,6 +1142,34 @@ async def required_sub_channel_check( show_alert=True, ) + if user and user.subscription: + subscription = user.subscription + if ( + subscription.is_trial + and subscription.status == SubscriptionStatus.DISABLED.value + ): + subscription.status = SubscriptionStatus.ACTIVE.value + subscription.updated_at = datetime.utcnow() + await db.commit() + await db.refresh(subscription) + logger.info( + "✅ Триальная подписка пользователя %s восстановлена после подтверждения подписки на канал", + user.telegram_id, + ) + + try: + subscription_service = SubscriptionService() + if user.remnawave_uuid: + await subscription_service.update_remnawave_user(db, subscription) + else: + await subscription_service.create_remnawave_user(db, subscription) + except Exception as api_error: + logger.error( + "❌ Ошибка обновления RemnaWave при восстановлении подписки пользователя %s: %s", + user.telegram_id if user else query.from_user.id, + api_error, + ) + await query.answer( texts.t("CHANNEL_SUBSCRIBE_THANKS", "✅ Спасибо за подписку"), show_alert=True, diff --git a/app/handlers/subscription.py b/app/handlers/subscription.py index 3c54f0a7..f3b7b57d 100644 --- a/app/handlers/subscription.py +++ b/app/handlers/subscription.py @@ -17,12 +17,13 @@ from app.database.crud.subscription import ( add_subscription_squad, update_subscription_autopay, add_subscription_servers ) -from app.database.crud.user import subtract_user_balance +from app.database.crud.user import subtract_user_balance, add_user_balance from app.database.crud.transaction import create_transaction, get_user_transactions from app.database.models import ( - User, TransactionType, SubscriptionStatus, - SubscriptionServer, Subscription + User, TransactionType, SubscriptionStatus, + SubscriptionServer, Subscription ) +from app.database.crud.discount_offer import get_offer_by_id, mark_offer_claimed from app.keyboards.inline import ( get_subscription_keyboard, get_trial_keyboard, get_subscription_period_keyboard, get_traffic_packages_keyboard, @@ -38,6 +39,9 @@ from app.keyboards.inline import ( get_extend_subscription_keyboard_with_prices, get_confirm_change_devices_keyboard, get_devices_management_keyboard, get_device_reset_confirm_keyboard, get_device_management_help_keyboard, + get_happ_cryptolink_keyboard, + get_happ_download_platform_keyboard, get_happ_download_link_keyboard, + get_happ_download_button_row, get_payment_methods_keyboard_with_cart, get_subscription_confirm_keyboard_with_cart, get_insufficient_balance_keyboard_with_cart @@ -58,21 +62,72 @@ from app.utils.pricing_utils import ( calculate_prorated_price, validate_pricing_calculation, format_period_description, + apply_percentage_discount, ) from app.utils.pagination import paginate_list +from app.utils.subscription_utils import ( + get_display_subscription_link, + get_happ_cryptolink_redirect_link, +) logger = logging.getLogger(__name__) TRAFFIC_PRICES = get_traffic_prices() +def _get_addon_discount_percent_for_user( + user: Optional[User], + category: str, + period_days_hint: Optional[int] = None, +) -> int: + if user is None: + return 0 + + promo_group = getattr(user, "promo_group", None) + if promo_group is None: + return 0 + + if not getattr(promo_group, "apply_discounts_to_addons", True): + return 0 + + try: + return user.get_promo_discount(category, period_days_hint) + except AttributeError: + return 0 + + +def _apply_addon_discount( + user: Optional[User], + category: str, + amount: int, + period_days_hint: Optional[int] = None, +) -> Dict[str, int]: + percent = _get_addon_discount_percent_for_user(user, category, period_days_hint) + discounted_amount, discount_value = apply_percentage_discount(amount, percent) + + return { + "discounted": discounted_amount, + "discount": discount_value, + "percent": percent, + } + + +def _get_period_hint_from_subscription(subscription: Optional[Subscription]) -> Optional[int]: + if not subscription: + return None + + months_remaining = get_remaining_months(subscription.end_date) + if months_remaining <= 0: + return None + + return months_remaining * 30 + + def _apply_discount_to_monthly_component( amount_per_month: int, percent: int, months: int, ) -> Dict[str, int]: - from app.utils.pricing_utils import apply_percentage_discount - discounted_per_month, discount_per_month = apply_percentage_discount(amount_per_month, percent) return { @@ -98,12 +153,20 @@ async def _prepare_subscription_summary( ) summary_data = dict(data) - countries = await _get_available_countries() + countries = await _get_available_countries(db_user.promo_group_id) 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 = PERIOD_PRICES[summary_data['period_days']] + base_price_original = PERIOD_PRICES[summary_data['period_days']] + period_discount_percent = db_user.get_promo_discount( + "period", + summary_data['period_days'], + ) + base_price, base_discount_total = apply_percentage_discount( + base_price_original, + period_discount_percent, + ) if settings.is_traffic_fixed(): traffic_limit = settings.get_fixed_traffic_limit() @@ -195,6 +258,9 @@ async def _prepare_subscription_summary( summary_data['server_prices_for_period'] = selected_server_prices summary_data['months_in_period'] = months_in_period summary_data['base_price'] = base_price + summary_data['base_price_original'] = base_price_original + summary_data['base_discount_percent'] = period_discount_percent + summary_data['base_discount_total'] = base_discount_total summary_data['final_traffic_gb'] = final_traffic_gb summary_data['traffic_price_per_month'] = traffic_price_per_month summary_data['traffic_discount_percent'] = traffic_component["discount_percent"] @@ -226,7 +292,15 @@ async def _prepare_subscription_summary( else: traffic_display = f"{summary_data.get('traffic_gb', 0)} ГБ" - details_lines = [f"- Базовый период: {texts.format_price(base_price)}"] + base_line = f"- Базовый период: {texts.format_price(base_price_original)}" + if base_discount_total > 0: + base_line += ( + f" → {texts.format_price(base_price)}" + f" (скидка {period_discount_percent}%:" + f" -{texts.format_price(base_discount_total)})" + ) + + details_lines = [base_line] if total_traffic_price > 0: traffic_line = ( @@ -317,26 +391,29 @@ def _build_promo_group_discount_text( period_lines: List[str] = [] - if ( - promo_group.is_default - and periods - and settings.is_base_promo_group_period_discount_enabled() - ): - discounts = settings.get_base_promo_group_period_discounts() + period_candidates: set[int] = set(periods or []) - for period_days in periods: - percent = discounts.get(period_days, 0) - - if percent <= 0: + raw_period_discounts = getattr(promo_group, "period_discounts", None) + if isinstance(raw_period_discounts, dict): + for key in raw_period_discounts.keys(): + try: + period_candidates.add(int(key)) + except (TypeError, ValueError): continue - period_display = format_period_description(period_days, db_user.language) - period_lines.append( - texts.PROMO_GROUP_PERIOD_DISCOUNT_ITEM.format( - period=period_display, - percent=percent, - ) + for period_days in sorted(period_candidates): + percent = promo_group.get_discount_percent("period", period_days) + + if percent <= 0: + continue + + period_display = format_period_description(period_days, db_user.language) + period_lines.append( + texts.PROMO_GROUP_PERIOD_DISCOUNT_ITEM.format( + period=period_display, + percent=percent, ) + ) if not service_lines and not period_lines: return "" @@ -538,12 +615,17 @@ async def show_subscription_info( message += f"• {device_info}\n" message += texts.t("SUBSCRIPTION_CONNECTED_DEVICES_FOOTER", "") - if hasattr(subscription, 'subscription_url') and subscription.subscription_url: - if actual_status in ['trial_active', 'paid_active'] and not settings.HIDE_SUBSCRIPTION_LINK: + subscription_link = get_display_subscription_link(subscription) + if subscription_link: + if ( + actual_status in ['trial_active', 'paid_active'] + and not settings.HIDE_SUBSCRIPTION_LINK + and not settings.is_happ_cryptolink_mode() + ): message += "\n\n" + texts.t( "SUBSCRIPTION_CONNECT_LINK_SECTION", "🔗 Ссылка для подключения:\n{subscription_url}", - ).format(subscription_url=subscription.subscription_url) + ).format(subscription_url=subscription_link) message += "\n\n" + texts.t( "SUBSCRIPTION_CONNECT_LINK_PROMPT", "📱 Скопируйте ссылку и добавьте в ваше VPN приложение", @@ -666,15 +748,39 @@ async def get_subscription_cost(subscription, db: AsyncSession) -> int: subscription_service = SubscriptionService() - base_cost = PERIOD_PRICES.get(30, 0) - + base_cost_original = PERIOD_PRICES.get(30, 0) + try: + owner = subscription.user + except AttributeError: + owner = None + + promo_group_id = getattr(owner, "promo_group_id", None) if owner else None + + period_discount_percent = 0 + if owner: + try: + period_discount_percent = owner.get_promo_discount("period", 30) + except AttributeError: + period_discount_percent = 0 + + from app.utils.pricing_utils import apply_percentage_discount + + base_cost, _ = apply_percentage_discount( + base_cost_original, + period_discount_percent, + ) + try: servers_cost, _ = await subscription_service.get_countries_price_by_uuids( - subscription.connected_squads, db + subscription.connected_squads, + db, + promo_group_id=promo_group_id, ) except AttributeError: servers_cost, _ = await get_countries_price_by_uuids_fallback( - subscription.connected_squads, db + subscription.connected_squads, + db, + promo_group_id=promo_group_id, ) traffic_cost = settings.get_traffic_price(subscription.traffic_limit_gb) @@ -683,7 +789,14 @@ async def get_subscription_cost(subscription, db: AsyncSession) -> int: total_cost = base_cost + servers_cost + traffic_cost + devices_cost logger.info(f"📊 Месячная стоимость конфигурации подписки {subscription.id}:") - logger.info(f" 📅 Базовый тариф (30 дней): {base_cost/100}₽") + base_log = f" 📅 Базовый тариф (30 дней): {base_cost_original/100}₽" + if period_discount_percent > 0: + discount_value = base_cost_original * period_discount_percent // 100 + base_log += ( + f" → {base_cost/100}₽" + f" (скидка {period_discount_percent}%: -{discount_value/100}₽)" + ) + logger.info(base_log) if servers_cost > 0: logger.info(f" 🌍 Серверы: {servers_cost/100}₽") if traffic_cost > 0: @@ -779,17 +892,32 @@ async def activate_trial( except Exception as e: logger.error(f"Ошибка отправки уведомления о триале: {e}") - if remnawave_user and hasattr(subscription, 'subscription_url') and subscription.subscription_url: - subscription_import_link = texts.t( - "SUBSCRIPTION_IMPORT_LINK_SECTION", - "🔗 Ваша ссылка для импорта в VPN приложение:\\n{subscription_url}", - ).format(subscription_url=subscription.subscription_url) + subscription_link = get_display_subscription_link(subscription) + if remnawave_user and subscription_link: + if settings.is_happ_cryptolink_mode(): + trial_success_text = ( + f"{texts.TRIAL_ACTIVATED}\n\n" + + texts.t( + "SUBSCRIPTION_HAPP_LINK_PROMPT", + "🔒 Ссылка на подписку создана. Нажмите кнопку \"Подключиться\" ниже, чтобы открыть её в Happ.", + ) + + "\n\n" + + texts.t( + 'SUBSCRIPTION_IMPORT_INSTRUCTION_PROMPT', + '📱 Нажмите кнопку ниже, чтобы получить инструкцию по настройке VPN на вашем устройстве', + ) + ) + else: + subscription_import_link = texts.t( + "SUBSCRIPTION_IMPORT_LINK_SECTION", + "🔗 Ваша ссылка для импорта в VPN приложение:\n{subscription_url}", + ).format(subscription_url=subscription_link) - trial_success_text = ( - f"{texts.TRIAL_ACTIVATED}\n\n" - f"{subscription_import_link}\n\n" - f"{texts.t('SUBSCRIPTION_IMPORT_INSTRUCTION_PROMPT', '📱 Нажмите кнопку ниже, чтобы получить инструкцию по настройке VPN на вашем устройстве')}" - ) + trial_success_text = ( + f"{texts.TRIAL_ACTIVATED}\n\n" + f"{subscription_import_link}\n\n" + f"{texts.t('SUBSCRIPTION_IMPORT_INSTRUCTION_PROMPT', '📱 Нажмите кнопку ниже, чтобы получить инструкцию по настройке VPN на вашем устройстве')}" + ) connect_mode = settings.CONNECT_BUTTON_MODE @@ -798,10 +926,9 @@ async def activate_trial( [ InlineKeyboardButton( text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"), - web_app=types.WebAppInfo(url=subscription.subscription_url), + web_app=types.WebAppInfo(url=subscription_link), ) ], - [InlineKeyboardButton(text=texts.t("MY_SUBSCRIPTION_BUTTON", "📱 Моя подписка"), callback_data="menu_subscription")], [InlineKeyboardButton(text=texts.t("BACK_TO_MAIN_MENU_BUTTON", "⬅️ В главное меню"), callback_data="back_to_menu")], ]) elif connect_mode == "miniapp_custom": @@ -822,19 +949,44 @@ async def activate_trial( web_app=types.WebAppInfo(url=settings.MINIAPP_CUSTOM_URL), ) ], - [InlineKeyboardButton(text=texts.t("MY_SUBSCRIPTION_BUTTON", "📱 Моя подписка"), callback_data="menu_subscription")], [InlineKeyboardButton(text=texts.t("BACK_TO_MAIN_MENU_BUTTON", "⬅️ В главное меню"), callback_data="back_to_menu")], ]) elif connect_mode == "link": - connect_keyboard = InlineKeyboardMarkup(inline_keyboard=[ - [InlineKeyboardButton(text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"), url=subscription.subscription_url)], - [InlineKeyboardButton(text=texts.t("MY_SUBSCRIPTION_BUTTON", "📱 Моя подписка"), callback_data="menu_subscription")], - [InlineKeyboardButton(text=texts.t("BACK_TO_MAIN_MENU_BUTTON", "⬅️ В главное меню"), callback_data="back_to_menu")], + rows = [ + [InlineKeyboardButton(text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"), url=subscription_link)] + ] + happ_row = get_happ_download_button_row(texts) + if happ_row: + rows.append(happ_row) + rows.append([ + InlineKeyboardButton( + text=texts.t("BACK_TO_MAIN_MENU_BUTTON", "⬅️ В главное меню"), + callback_data="back_to_menu" + ) ]) + connect_keyboard = InlineKeyboardMarkup(inline_keyboard=rows) + elif connect_mode == "happ_cryptolink": + rows = [ + [ + InlineKeyboardButton( + text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"), + callback_data="open_subscription_link", + ) + ] + ] + happ_row = get_happ_download_button_row(texts) + if happ_row: + rows.append(happ_row) + rows.append([ + InlineKeyboardButton( + text=texts.t("BACK_TO_MAIN_MENU_BUTTON", "⬅️ В главное меню"), + callback_data="back_to_menu" + ) + ]) + connect_keyboard = InlineKeyboardMarkup(inline_keyboard=rows) else: connect_keyboard = InlineKeyboardMarkup(inline_keyboard=[ [InlineKeyboardButton(text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"), callback_data="subscription_connect")], - [InlineKeyboardButton(text=texts.t("MY_SUBSCRIPTION_BUTTON", "📱 Моя подписка"), callback_data="menu_subscription")], [InlineKeyboardButton(text=texts.t("BACK_TO_MAIN_MENU_BUTTON", "⬅️ В главное меню"), callback_data="back_to_menu")], ]) @@ -921,7 +1073,10 @@ async def save_cart_and_redirect_to_topup( f"🛒 Ваша корзина сохранена!\n" f"После пополнения баланса вы сможете вернуться к оформлению подписки.\n\n" f"Выберите способ пополнения:", - reply_markup=get_payment_methods_keyboard_with_cart(db_user.language), + reply_markup=get_payment_methods_keyboard_with_cart( + db_user.language, + missing_amount, + ), parse_mode="HTML" ) @@ -947,13 +1102,16 @@ async def return_to_saved_cart( f"Требуется: {texts.format_price(total_price)}\n" f"У вас: {texts.format_price(db_user.balance_kopeks)}\n" f"Не хватает: {texts.format_price(missing_amount)}", - reply_markup=get_insufficient_balance_keyboard_with_cart(db_user.language) + reply_markup=get_insufficient_balance_keyboard_with_cart( + db_user.language, + missing_amount, + ) ) return from app.utils.pricing_utils import calculate_months_from_days, format_period_description - countries = await _get_available_countries() + countries = await _get_available_countries(db_user.promo_group_id) selected_countries_names = [] months_in_period = calculate_months_from_days(data['period_days']) @@ -993,19 +1151,26 @@ async def handle_add_countries( db: AsyncSession, state: FSMContext ): - if not await _should_show_countries_management(): + if not await _should_show_countries_management(db_user): await callback.answer("ℹ️ Управление серверами недоступно - доступен только один сервер", show_alert=True) return texts = get_texts(db_user.language) subscription = db_user.subscription - + if not subscription or subscription.is_trial: await callback.answer("⚠ Эта функция доступна только для платных подписок", show_alert=True) return - - countries = await _get_available_countries() + + countries = await _get_available_countries(db_user.promo_group_id) current_countries = subscription.connected_squads + + period_hint_days = _get_period_hint_from_subscription(subscription) + servers_discount_percent = _get_addon_discount_percent_for_user( + db_user, + "servers", + period_hint_days, + ) current_countries_names = [] for country in countries: @@ -1031,18 +1196,23 @@ async def handle_add_countries( await callback.message.edit_text( text, reply_markup=get_manage_countries_keyboard( - countries, - current_countries.copy(), - current_countries, + countries, + current_countries.copy(), + current_countries, db_user.language, - subscription.end_date + subscription.end_date, + servers_discount_percent, ), parse_mode="HTML" ) await callback.answer() -async def get_countries_price_by_uuids_fallback(country_uuids: List[str], db: AsyncSession) -> Tuple[int, List[int]]: +async def get_countries_price_by_uuids_fallback( + country_uuids: List[str], + db: AsyncSession, + promo_group_id: Optional[int] = None, +) -> Tuple[int, List[int]]: try: from app.database.crud.server_squad import get_server_squad_by_uuid @@ -1052,7 +1222,12 @@ async def get_countries_price_by_uuids_fallback(country_uuids: List[str], db: As for country_uuid in country_uuids: try: server = await get_server_squad_by_uuid(db, country_uuid) - if server and server.is_available and not server.is_full: + is_allowed = True + if promo_group_id is not None and server: + allowed_ids = {pg.id for pg in server.allowed_promo_groups} + is_allowed = promo_group_id in allowed_ids + + if server and server.is_available and not server.is_full and is_allowed: price = server.price_kopeks total_price += price prices_list.append(price) @@ -1086,31 +1261,44 @@ async def handle_manage_country( if not subscription or subscription.is_trial: await callback.answer("⚠ Только для платных подписок", show_alert=True) return - + data = await state.get_data() current_selected = data.get('countries', subscription.connected_squads.copy()) - + + countries = await _get_available_countries(db_user.promo_group_id) + allowed_country_ids = {country['uuid'] for country in countries} + + if country_uuid not in allowed_country_ids and country_uuid not in current_selected: + await callback.answer("❌ Сервер недоступен для вашей промогруппы", show_alert=True) + return + if country_uuid in current_selected: current_selected.remove(country_uuid) action = "removed" else: current_selected.append(country_uuid) action = "added" - + logger.info(f"🔍 Страна {country_uuid} {action}") - + await state.update_data(countries=current_selected) - - countries = await _get_available_countries() - + + period_hint_days = _get_period_hint_from_subscription(subscription) + servers_discount_percent = _get_addon_discount_percent_for_user( + db_user, + "servers", + period_hint_days, + ) + try: await callback.message.edit_reply_markup( reply_markup=get_manage_countries_keyboard( - countries, - current_selected, - subscription.connected_squads, + countries, + current_selected, + subscription.connected_squads, db_user.language, - subscription.end_date + subscription.end_date, + servers_discount_percent, ) ) logger.info(f"✅ Клавиатура обновлена") @@ -1143,49 +1331,111 @@ async def apply_countries_changes( selected_countries = data.get('countries', []) current_countries = subscription.connected_squads - + + countries = await _get_available_countries(db_user.promo_group_id) + allowed_country_ids = {country['uuid'] for country in countries} + + selected_countries = [ + country_uuid + for country_uuid in selected_countries + if country_uuid in allowed_country_ids or country_uuid in current_countries + ] + added = [c for c in selected_countries if c not in current_countries] removed = [c for c in current_countries if c not in selected_countries] - + if not added and not removed: await callback.answer("⚠️ Изменения не обнаружены", show_alert=True) return - + logger.info(f"🔧 Добавлено: {added}, Удалено: {removed}") - - countries = await _get_available_countries() - + months_to_pay = get_remaining_months(subscription.end_date) - + + period_hint_days = months_to_pay * 30 if months_to_pay > 0 else None + servers_discount_percent = _get_addon_discount_percent_for_user( + db_user, + "servers", + period_hint_days, + ) + cost_per_month = 0 added_names = [] removed_names = [] - - added_server_prices = [] - + + added_server_components: List[Dict[str, int]] = [] + for country in countries: + if not country.get('is_available', True): + continue + if country['uuid'] in added: server_price_per_month = country['price_kopeks'] - cost_per_month += server_price_per_month + discounted_per_month, discount_per_month = apply_percentage_discount( + server_price_per_month, + servers_discount_percent, + ) + cost_per_month += discounted_per_month added_names.append(country['name']) + added_server_components.append( + { + "discounted_per_month": discounted_per_month, + "discount_per_month": discount_per_month, + "original_per_month": server_price_per_month, + } + ) if country['uuid'] in removed: removed_names.append(country['name']) - + total_cost, charged_months = calculate_prorated_price(cost_per_month, subscription.end_date) - - for country in countries: - if country['uuid'] in added: - server_price_per_month = country['price_kopeks'] - server_total_price = server_price_per_month * charged_months - added_server_prices.append(server_total_price) - - logger.info(f"Стоимость новых серверов: {cost_per_month/100}₽/мес × {charged_months} мес = {total_cost/100}₽") + + added_server_prices = [ + component["discounted_per_month"] * charged_months + for component in added_server_components + ] + + total_discount = sum( + component["discount_per_month"] * charged_months + for component in added_server_components + ) + + if added_names: + logger.info( + "Стоимость новых серверов: %.2f₽/мес × %s мес = %.2f₽ (скидка %.2f₽)", + cost_per_month / 100, + charged_months, + total_cost / 100, + total_discount / 100, + ) if total_cost > 0 and db_user.balance_kopeks < total_cost: - await callback.answer( - f"⚠️ Недостаточно средств!\nТребуется: {texts.format_price(total_cost)} (за {charged_months} мес)\nУ вас: {texts.format_price(db_user.balance_kopeks)}", - show_alert=True + missing_kopeks = total_cost - db_user.balance_kopeks + required_text = f"{texts.format_price(total_cost)} (за {charged_months} мес)" + message_text = texts.t( + "ADDON_INSUFFICIENT_FUNDS_MESSAGE", + ( + "⚠️ Недостаточно средств\n\n" + "Стоимость услуги: {required}\n" + "На балансе: {balance}\n" + "Не хватает: {missing}\n\n" + "Выберите способ пополнения. Сумма подставится автоматически." + ), + ).format( + required=required_text, + balance=texts.format_price(db_user.balance_kopeks), + missing=texts.format_price(missing_kopeks), ) + + await callback.message.answer( + message_text, + reply_markup=get_insufficient_balance_keyboard( + db_user.language, + resume_callback=resume_callback, + amount_kopeks=missing_kopeks, + ), + parse_mode="HTML", + ) + await callback.answer() return try: @@ -1243,6 +1493,11 @@ async def apply_countries_changes( success_text += "\n".join(f"• {name}" for name in added_names) if total_cost > 0: success_text += f"\n💰 Списано: {texts.format_price(total_cost)} (за {charged_months} мес)" + if total_discount > 0: + success_text += ( + f" (скидка {servers_discount_percent}%:" + f" -{texts.format_price(total_discount)})" + ) success_text += "\n" if removed_names: @@ -1294,12 +1549,22 @@ async def handle_add_traffic( 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( + db_user, + "traffic", + period_hint_days, + ) + await callback.message.edit_text( f"📈 Добавить трафик к подписке\n\n" f"Текущий лимит: {texts.format_traffic(current_traffic)}\n" f"Выберите дополнительный трафик:", - reply_markup=get_add_traffic_keyboard(db_user.language, subscription.end_date), + reply_markup=get_add_traffic_keyboard( + db_user.language, + subscription.end_date, + traffic_discount_percent, + ), parse_mode="HTML" ) @@ -1319,7 +1584,14 @@ async def handle_change_devices( return current_devices = subscription.device_limit - + + period_hint_days = _get_period_hint_from_subscription(subscription) + devices_discount_percent = _get_addon_discount_percent_for_user( + db_user, + "devices", + period_hint_days, + ) + await callback.message.edit_text( f"📱 Изменение количества устройств\n\n" f"Текущий лимит: {current_devices} устройств\n" @@ -1327,7 +1599,12 @@ async def handle_change_devices( f"💡 Важно:\n" f"• При увеличении - доплата пропорционально оставшемуся времени\n" f"• При уменьшении - возврат средств не производится", - reply_markup=get_change_devices_keyboard(current_devices, db_user.language, subscription.end_date), + reply_markup=get_change_devices_keyboard( + current_devices, + db_user.language, + subscription.end_date, + devices_discount_percent, + ), parse_mode="HTML" ) @@ -1369,17 +1646,62 @@ async def confirm_change_devices( chargeable_devices = additional_devices devices_price_per_month = chargeable_devices * settings.PRICE_PER_DEVICE - price, charged_months = calculate_prorated_price(devices_price_per_month, subscription.end_date) + months_hint = get_remaining_months(subscription.end_date) + period_hint_days = months_hint * 30 if months_hint > 0 else None + devices_discount_percent = _get_addon_discount_percent_for_user( + db_user, + "devices", + period_hint_days, + ) + discounted_per_month, discount_per_month = apply_percentage_discount( + devices_price_per_month, + devices_discount_percent, + ) + price, charged_months = calculate_prorated_price( + discounted_per_month, + subscription.end_date, + ) + total_discount = discount_per_month * charged_months if price > 0 and db_user.balance_kopeks < price: - await callback.answer( - f"⚠️ Недостаточно средств!\nТребуется: {texts.format_price(price)} (за {charged_months} мес)\nУ вас: {texts.format_price(db_user.balance_kopeks)}", - show_alert=True + missing_kopeks = price - db_user.balance_kopeks + required_text = f"{texts.format_price(price)} (за {charged_months} мес)" + message_text = texts.t( + "ADDON_INSUFFICIENT_FUNDS_MESSAGE", + ( + "⚠️ Недостаточно средств\n\n" + "Стоимость услуги: {required}\n" + "На балансе: {balance}\n" + "Не хватает: {missing}\n\n" + "Выберите способ пополнения. Сумма подставится автоматически." + ), + ).format( + required=required_text, + balance=texts.format_price(db_user.balance_kopeks), + missing=texts.format_price(missing_kopeks), ) + + await callback.message.answer( + message_text, + reply_markup=get_insufficient_balance_keyboard( + db_user.language, + amount_kopeks=missing_kopeks, + ), + parse_mode="HTML", + ) + await callback.answer() return action_text = f"увеличить до {new_devices_count}" - cost_text = f"Доплата: {texts.format_price(price)} (за {charged_months} мес)" if price > 0 else "Бесплатно" + if price > 0: + cost_text = f"Доплата: {texts.format_price(price)} (за {charged_months} мес)" + if total_discount > 0: + cost_text += ( + f" (скидка {devices_discount_percent}%:" + f" -{texts.format_price(total_discount)})" + ) + else: + cost_text = "Бесплатно" else: price = 0 @@ -1805,10 +2127,19 @@ async def handle_extend_subscription( months_in_period = calculate_months_from_days(days) from app.config import PERIOD_PRICES - base_price = PERIOD_PRICES.get(days, 0) + from app.utils.pricing_utils import apply_percentage_discount + + base_price_original = PERIOD_PRICES.get(days, 0) + period_discount_percent = db_user.get_promo_discount("period", days) + base_price, _ = apply_percentage_discount( + base_price_original, + period_discount_percent, + ) servers_price_per_month, _ = await subscription_service.get_countries_price_by_uuids( - subscription.connected_squads, db + subscription.connected_squads, + db, + promo_group_id=db_user.promo_group_id, ) servers_discount_percent = db_user.get_promo_discount( "servers", @@ -1939,6 +2270,8 @@ async def confirm_add_devices( devices_count = int(callback.data.split('_')[2]) texts = get_texts(db_user.language) subscription = db_user.subscription + + resume_callback = None new_total_devices = subscription.device_limit + devices_count @@ -1951,18 +2284,58 @@ async def confirm_add_devices( return devices_price_per_month = devices_count * settings.PRICE_PER_DEVICE - price, charged_months = calculate_prorated_price(devices_price_per_month, subscription.end_date) - - logger.info(f"Добавление {devices_count} устройств: {devices_price_per_month/100}₽/мес × {charged_months} мес = {price/100}₽") + months_hint = get_remaining_months(subscription.end_date) + period_hint_days = months_hint * 30 if months_hint > 0 else None + devices_discount_percent = _get_addon_discount_percent_for_user( + db_user, + "devices", + period_hint_days, + ) + discounted_per_month, discount_per_month = apply_percentage_discount( + devices_price_per_month, + devices_discount_percent, + ) + price, charged_months = calculate_prorated_price( + discounted_per_month, + subscription.end_date, + ) + total_discount = discount_per_month * charged_months + + logger.info( + "Добавление %s устройств: %.2f₽/мес × %s мес = %.2f₽ (скидка %.2f₽)", + devices_count, + discounted_per_month / 100, + charged_months, + price / 100, + total_discount / 100, + ) if db_user.balance_kopeks < price: missing_kopeks = price - db_user.balance_kopeks + required_text = f"{texts.format_price(price)} (за {charged_months} мес)" + message_text = texts.t( + "ADDON_INSUFFICIENT_FUNDS_MESSAGE", + ( + "⚠️ Недостаточно средств\n\n" + "Стоимость услуги: {required}\n" + "На балансе: {balance}\n" + "Не хватает: {missing}\n\n" + "Выберите способ пополнения. Сумма подставится автоматически." + ), + ).format( + required=required_text, + balance=texts.format_price(db_user.balance_kopeks), + missing=texts.format_price(missing_kopeks), + ) + await callback.message.edit_text( - texts.INSUFFICIENT_BALANCE.format(amount=texts.format_price(missing_kopeks)), + message_text, reply_markup=get_insufficient_balance_keyboard( db_user.language, resume_callback=resume_callback, + amount_kopeks=missing_kopeks, ), + parse_mode="HTML", ) await callback.answer() return @@ -1994,11 +2367,20 @@ async def confirm_add_devices( await db.refresh(db_user) await db.refresh(subscription) - await callback.message.edit_text( - f"✅ Устройства успешно добавлены!\n\n" + success_text = ( + "✅ Устройства успешно добавлены!\n\n" f"📱 Добавлено: {devices_count} устройств\n" f"Новый лимит: {subscription.device_limit} устройств\n" - f"💰 Списано: {texts.format_price(price)} (за {charged_months} мес)", + ) + success_text += f"💰 Списано: {texts.format_price(price)} (за {charged_months} мес)" + if total_discount > 0: + success_text += ( + f" (скидка {devices_discount_percent}%:" + f" -{texts.format_price(total_discount)})" + ) + + await callback.message.edit_text( + success_text, reply_markup=get_back_keyboard(db_user.language) ) @@ -2019,7 +2401,11 @@ async def confirm_extend_subscription( db_user: User, db: AsyncSession ): - from app.utils.pricing_utils import calculate_months_from_days, validate_pricing_calculation + from app.utils.pricing_utils import ( + calculate_months_from_days, + validate_pricing_calculation, + apply_percentage_discount, + ) from app.services.admin_notification_service import AdminNotificationService days = int(callback.data.split('_')[2]) @@ -2036,12 +2422,20 @@ async def confirm_extend_subscription( try: from app.config import PERIOD_PRICES + from app.utils.pricing_utils import apply_percentage_discount - base_price = PERIOD_PRICES.get(days, 0) + base_price_original = PERIOD_PRICES.get(days, 0) + period_discount_percent = db_user.get_promo_discount("period", days) + base_price, base_discount_total = apply_percentage_discount( + base_price_original, + period_discount_percent, + ) subscription_service = SubscriptionService() servers_price_per_month, per_server_monthly_prices = await subscription_service.get_countries_price_by_uuids( - subscription.connected_squads, db + subscription.connected_squads, + db, + promo_group_id=db_user.promo_group_id, ) servers_discount_percent = db_user.get_promo_discount( "servers", @@ -2095,7 +2489,13 @@ async def confirm_extend_subscription( return logger.info(f"💰 Расчет продления подписки {subscription.id} на {days} дней ({months_in_period} мес):") - logger.info(f" 📅 Период {days} дней: {base_price/100}₽") + base_log = f" 📅 Период {days} дней: {base_price_original/100}₽" + if base_discount_total > 0: + base_log += ( + f" → {base_price/100}₽" + f" (скидка {period_discount_percent}%: -{base_discount_total/100}₽)" + ) + logger.info(base_log) if total_servers_price > 0: logger.info( f" 🌐 Серверы: {servers_price_per_month/100}₽/мес × {months_in_period}" @@ -2138,9 +2538,29 @@ async def confirm_extend_subscription( if db_user.balance_kopeks < price: missing_kopeks = price - db_user.balance_kopeks + required_text = texts.format_price(price) + message_text = texts.t( + "ADDON_INSUFFICIENT_FUNDS_MESSAGE", + ( + "⚠️ Недостаточно средств\n\n" + "Стоимость услуги: {required}\n" + "На балансе: {balance}\n" + "Не хватает: {missing}\n\n" + "Выберите способ пополнения. Сумма подставится автоматически." + ), + ).format( + required=required_text, + balance=texts.format_price(db_user.balance_kopeks), + missing=texts.format_price(missing_kopeks), + ) + await callback.message.edit_text( - texts.INSUFFICIENT_BALANCE.format(amount=texts.format_price(missing_kopeks)), - reply_markup=get_insufficient_balance_keyboard(db_user.language), + message_text, + reply_markup=get_insufficient_balance_keyboard( + db_user.language, + amount_kopeks=missing_kopeks, + ), + parse_mode="HTML", ) await callback.answer() return @@ -2254,13 +2674,32 @@ async def confirm_reset_traffic( texts = get_texts(db_user.language) subscription = db_user.subscription - reset_price = PERIOD_PRICES[30] - + reset_price = PERIOD_PRICES[30] + if db_user.balance_kopeks < reset_price: missing_kopeks = reset_price - db_user.balance_kopeks + message_text = texts.t( + "ADDON_INSUFFICIENT_FUNDS_MESSAGE", + ( + "⚠️ Недостаточно средств\n\n" + "Стоимость услуги: {required}\n" + "На балансе: {balance}\n" + "Не хватает: {missing}\n\n" + "Выберите способ пополнения. Сумма подставится автоматически." + ), + ).format( + required=texts.format_price(reset_price), + balance=texts.format_price(db_user.balance_kopeks), + missing=texts.format_price(missing_kopeks), + ) + await callback.message.edit_text( - texts.INSUFFICIENT_BALANCE.format(amount=texts.format_price(missing_kopeks)), - reply_markup=get_insufficient_balance_keyboard(db_user.language), + message_text, + reply_markup=get_insufficient_balance_keyboard( + db_user.language, + amount_kopeks=missing_kopeks, + ), + parse_mode="HTML", ) await callback.answer() return @@ -2350,15 +2789,15 @@ async def select_period( ) await state.set_state(SubscriptionStates.selecting_traffic) else: - if await _should_show_countries_management(): - countries = await _get_available_countries() + if await _should_show_countries_management(db_user): + countries = await _get_available_countries(db_user.promo_group_id) await callback.message.edit_text( texts.SELECT_COUNTRIES, reply_markup=get_countries_keyboard(countries, [], db_user.language) ) await state.set_state(SubscriptionStates.selecting_countries) else: - countries = await _get_available_countries() + countries = await _get_available_countries(db_user.promo_group_id) available_countries = [c for c in countries if c.get('is_available', True)] data['countries'] = [available_countries[0]['uuid']] if available_countries else [] await state.set_data(data) @@ -2506,15 +2945,15 @@ async def select_traffic( await state.set_data(data) - if await _should_show_countries_management(): - countries = await _get_available_countries() + if await _should_show_countries_management(db_user): + countries = await _get_available_countries(db_user.promo_group_id) await callback.message.edit_text( texts.SELECT_COUNTRIES, reply_markup=get_countries_keyboard(countries, [], db_user.language) ) await state.set_state(SubscriptionStates.selecting_countries) else: - countries = await _get_available_countries() + countries = await _get_available_countries(db_user.promo_group_id) available_countries = [c for c in countries if c.get('is_available', True)] data['countries'] = [available_countries[0]['uuid']] if available_countries else [] await state.set_data(data) @@ -2545,16 +2984,37 @@ async def select_country( else: selected_countries.append(country_uuid) - countries = await _get_available_countries() + countries = await _get_available_countries(db_user.promo_group_id) + allowed_country_ids = {country['uuid'] for country in countries} + + if country_uuid not in allowed_country_ids and country_uuid not in selected_countries: + await callback.answer("❌ Сервер недоступен для вашей промогруппы", show_alert=True) + return - base_price = PERIOD_PRICES[data['period_days']] + settings.get_traffic_price(data['traffic_gb']) + period_base_price = PERIOD_PRICES[data['period_days']] + from app.utils.pricing_utils import apply_percentage_discount + + discounted_base_price, _ = apply_percentage_discount( + period_base_price, + db_user.get_promo_discount("period", data['period_days']), + ) + + base_price = discounted_base_price + settings.get_traffic_price(data['traffic_gb']) try: subscription_service = SubscriptionService() - countries_price, _ = await subscription_service.get_countries_price_by_uuids(selected_countries, db) + countries_price, _ = await subscription_service.get_countries_price_by_uuids( + selected_countries, + db, + promo_group_id=db_user.promo_group_id, + ) except AttributeError: logger.warning("Используем fallback функцию для расчета цен стран") - countries_price, _ = await get_countries_price_by_uuids_fallback(selected_countries, db) + countries_price, _ = await get_countries_price_by_uuids_fallback( + selected_countries, + db, + promo_group_id=db_user.promo_group_id, + ) data['countries'] = selected_countries data['total_price'] = base_price + countries_price @@ -2612,7 +3072,7 @@ async def select_devices( settings.get_traffic_price(data['traffic_gb']) ) - countries = await _get_available_countries() + countries = await _get_available_countries(db_user.promo_group_id) countries_price = sum( c['price_kopeks'] for c in countries if c['uuid'] in data['countries'] @@ -2681,13 +3141,40 @@ async def confirm_purchase( else None ) - countries = await _get_available_countries() + countries = await _get_available_countries(db_user.promo_group_id) months_in_period = data.get( 'months_in_period', calculate_months_from_days(data['period_days']) ) - base_price = data.get('base_price', PERIOD_PRICES[data['period_days']]) + base_price = data.get('base_price') + base_price_original = data.get('base_price_original') + base_discount_percent = data.get('base_discount_percent') + base_discount_total = data.get('base_discount_total') + + if base_price is None: + base_price_original = PERIOD_PRICES[data['period_days']] + base_discount_percent = db_user.get_promo_discount( + "period", + data['period_days'], + ) + base_price, base_discount_total = apply_percentage_discount( + base_price_original, + base_discount_percent, + ) + else: + if base_price_original is None: + base_price_original = PERIOD_PRICES[data['period_days']] + if base_discount_percent is None: + base_discount_percent = db_user.get_promo_discount( + "period", + data['period_days'], + ) + if base_discount_total is None: + _, base_discount_total = apply_percentage_discount( + base_price_original, + base_discount_percent, + ) server_prices = data.get('server_prices_for_period', []) if not server_prices: @@ -2816,7 +3303,13 @@ async def confirm_purchase( return logger.info(f"Расчет покупки подписки на {data['period_days']} дней ({months_in_period} мес):") - logger.info(f" Период: {base_price/100}₽") + base_log = f" Период: {base_price_original/100}₽" + if base_discount_total and base_discount_total > 0: + base_log += ( + f" → {base_price/100}₽" + f" (скидка {base_discount_percent}%: -{base_discount_total/100}₽)" + ) + logger.info(base_log) if total_traffic_price > 0: message = ( f" Трафик: {traffic_price_per_month/100}₽/мес × {months_in_period}" @@ -2854,12 +3347,29 @@ async def confirm_purchase( if db_user.balance_kopeks < final_price: missing_kopeks = final_price - db_user.balance_kopeks + message_text = texts.t( + "ADDON_INSUFFICIENT_FUNDS_MESSAGE", + ( + "⚠️ Недостаточно средств\n\n" + "Стоимость услуги: {required}\n" + "На балансе: {balance}\n" + "Не хватает: {missing}\n\n" + "Выберите способ пополнения. Сумма подставится автоматически." + ), + ).format( + required=texts.format_price(final_price), + balance=texts.format_price(db_user.balance_kopeks), + missing=texts.format_price(missing_kopeks), + ) + await callback.message.edit_text( - texts.INSUFFICIENT_BALANCE.format(amount=texts.format_price(missing_kopeks)), + message_text, reply_markup=get_insufficient_balance_keyboard( db_user.language, resume_callback=resume_callback, + amount_kopeks=missing_kopeks, ), + parse_mode="HTML", ) await callback.answer() return @@ -2874,12 +3384,29 @@ async def confirm_purchase( if not success: missing_kopeks = final_price - db_user.balance_kopeks + message_text = texts.t( + "ADDON_INSUFFICIENT_FUNDS_MESSAGE", + ( + "⚠️ Недостаточно средств\n\n" + "Стоимость услуги: {required}\n" + "На балансе: {balance}\n" + "Не хватает: {missing}\n\n" + "Выберите способ пополнения. Сумма подставится автоматически." + ), + ).format( + required=texts.format_price(final_price), + balance=texts.format_price(db_user.balance_kopeks), + missing=texts.format_price(missing_kopeks), + ) + await callback.message.edit_text( - texts.INSUFFICIENT_BALANCE.format(amount=texts.format_price(missing_kopeks)), + message_text, reply_markup=get_insufficient_balance_keyboard( db_user.language, resume_callback=resume_callback, + amount_kopeks=missing_kopeks, ), + parse_mode="HTML", ) await callback.answer() return @@ -2983,17 +3510,32 @@ async def confirm_purchase( await db.refresh(db_user) await db.refresh(subscription) - if remnawave_user and hasattr(subscription, 'subscription_url') and subscription.subscription_url: - import_link_section = texts.t( - "SUBSCRIPTION_IMPORT_LINK_SECTION", - "🔗 Ваша ссылка для импорта в VPN приложение:\\n{subscription_url}", - ).format(subscription_url=subscription.subscription_url) + subscription_link = get_display_subscription_link(subscription) + if remnawave_user and subscription_link: + if settings.is_happ_cryptolink_mode(): + success_text = ( + f"{texts.SUBSCRIPTION_PURCHASED}\n\n" + + texts.t( + "SUBSCRIPTION_HAPP_LINK_PROMPT", + "🔒 Ссылка на подписку создана. Нажмите кнопку \"Подключиться\" ниже, чтобы открыть её в Happ.", + ) + + "\n\n" + + texts.t( + 'SUBSCRIPTION_IMPORT_INSTRUCTION_PROMPT', + '📱 Нажмите кнопку ниже, чтобы получить инструкцию по настройке VPN на вашем устройстве', + ) + ) + else: + import_link_section = texts.t( + "SUBSCRIPTION_IMPORT_LINK_SECTION", + "🔗 Ваша ссылка для импорта в VPN приложение:\\n{subscription_url}", + ).format(subscription_url=subscription_link) - success_text = ( - f"{texts.SUBSCRIPTION_PURCHASED}\n\n" - f"{import_link_section}\n\n" - f"{texts.t('SUBSCRIPTION_IMPORT_INSTRUCTION_PROMPT', '📱 Нажмите кнопку ниже, чтобы получить инструкцию по настройке VPN на вашем устройстве')}" - ) + success_text = ( + f"{texts.SUBSCRIPTION_PURCHASED}\n\n" + f"{import_link_section}\n\n" + f"{texts.t('SUBSCRIPTION_IMPORT_INSTRUCTION_PROMPT', '📱 Нажмите кнопку ниже, чтобы получить инструкцию по настройке VPN на вашем устройстве')}" + ) connect_mode = settings.CONNECT_BUTTON_MODE @@ -3002,10 +3544,9 @@ async def confirm_purchase( [ InlineKeyboardButton( text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"), - web_app=types.WebAppInfo(url=subscription.subscription_url), + web_app=types.WebAppInfo(url=subscription_link), ) ], - [InlineKeyboardButton(text=texts.t("MY_SUBSCRIPTION_BUTTON", "📱 Моя подписка"), callback_data="menu_subscription")], [InlineKeyboardButton(text=texts.t("BACK_TO_MAIN_MENU_BUTTON", "⬅️ В главное меню"), callback_data="back_to_menu")], ]) elif connect_mode == "miniapp_custom": @@ -3026,19 +3567,34 @@ async def confirm_purchase( web_app=types.WebAppInfo(url=settings.MINIAPP_CUSTOM_URL), ) ], - [InlineKeyboardButton(text=texts.t("MY_SUBSCRIPTION_BUTTON", "📱 Моя подписка"), callback_data="menu_subscription")], [InlineKeyboardButton(text=texts.t("BACK_TO_MAIN_MENU_BUTTON", "⬅️ В главное меню"), callback_data="back_to_menu")], ]) elif connect_mode == "link": - connect_keyboard = InlineKeyboardMarkup(inline_keyboard=[ - [InlineKeyboardButton(text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"), url=subscription.subscription_url)], - [InlineKeyboardButton(text=texts.t("MY_SUBSCRIPTION_BUTTON", "📱 Моя подписка"), callback_data="menu_subscription")], - [InlineKeyboardButton(text=texts.t("BACK_TO_MAIN_MENU_BUTTON", "⬅️ В главное меню"), callback_data="back_to_menu")], - ]) + rows = [ + [InlineKeyboardButton(text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"), url=subscription_link)] + ] + happ_row = get_happ_download_button_row(texts) + if happ_row: + rows.append(happ_row) + rows.append([InlineKeyboardButton(text=texts.t("BACK_TO_MAIN_MENU_BUTTON", "⬅️ В главное меню"), callback_data="back_to_menu")]) + connect_keyboard = InlineKeyboardMarkup(inline_keyboard=rows) + elif connect_mode == "happ_cryptolink": + rows = [ + [ + InlineKeyboardButton( + text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"), + callback_data="open_subscription_link", + ) + ] + ] + happ_row = get_happ_download_button_row(texts) + if happ_row: + rows.append(happ_row) + rows.append([InlineKeyboardButton(text=texts.t("BACK_TO_MAIN_MENU_BUTTON", "⬅️ В главное меню"), callback_data="back_to_menu")]) + connect_keyboard = InlineKeyboardMarkup(inline_keyboard=rows) else: connect_keyboard = InlineKeyboardMarkup(inline_keyboard=[ [InlineKeyboardButton(text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"), callback_data="subscription_connect")], - [InlineKeyboardButton(text=texts.t("MY_SUBSCRIPTION_BUTTON", "📱 Моя подписка"), callback_data="menu_subscription")], [InlineKeyboardButton(text=texts.t("BACK_TO_MAIN_MENU_BUTTON", "⬅️ В главное меню"), callback_data="back_to_menu")], ]) @@ -3121,25 +3677,68 @@ async def add_traffic( texts = get_texts(db_user.language) subscription = db_user.subscription - price = settings.get_traffic_price(traffic_gb) - - if price == 0 and traffic_gb != 0: + base_price = settings.get_traffic_price(traffic_gb) + + if base_price == 0 and traffic_gb != 0: await callback.answer("⚠️ Цена для этого пакета не настроена", show_alert=True) return - + + period_hint_days = _get_period_hint_from_subscription(subscription) + discount_result = _apply_addon_discount( + db_user, + "traffic", + base_price, + period_hint_days, + ) + + discounted_per_month = discount_result["discounted"] + discount_per_month = discount_result["discount"] + charged_months = 1 + + if subscription: + price, charged_months = calculate_prorated_price( + discounted_per_month, + subscription.end_date, + ) + else: + price = discounted_per_month + + total_discount_value = discount_per_month * charged_months + if db_user.balance_kopeks < price: missing_kopeks = price - db_user.balance_kopeks + message_text = texts.t( + "ADDON_INSUFFICIENT_FUNDS_MESSAGE", + ( + "⚠️ Недостаточно средств\n\n" + "Стоимость услуги: {required}\n" + "На балансе: {balance}\n" + "Не хватает: {missing}\n\n" + "Выберите способ пополнения. Сумма подставится автоматически." + ), + ).format( + required=texts.format_price(price), + balance=texts.format_price(db_user.balance_kopeks), + missing=texts.format_price(missing_kopeks), + ) + await callback.message.edit_text( - texts.INSUFFICIENT_BALANCE.format(amount=texts.format_price(missing_kopeks)), - reply_markup=get_insufficient_balance_keyboard(db_user.language), + message_text, + reply_markup=get_insufficient_balance_keyboard( + db_user.language, + amount_kopeks=missing_kopeks, + ), + parse_mode="HTML", ) await callback.answer() return try: success = await subtract_user_balance( - db, db_user, price, - f"Добавление {traffic_gb} ГБ трафика" + db, + db_user, + price, + f"Добавление {traffic_gb} ГБ трафика", ) if not success: @@ -3159,7 +3758,7 @@ async def add_traffic( user_id=db_user.id, type=TransactionType.SUBSCRIPTION_PAYMENT, amount_kopeks=price, - description=f"Добавление {traffic_gb} ГБ трафика" + description=f"Добавление {traffic_gb} ГБ трафика", ) @@ -3172,7 +3771,15 @@ async def add_traffic( else: success_text += f"📈 Добавлено: {traffic_gb} ГБ\n" success_text += f"Новый лимит: {texts.format_traffic(subscription.traffic_limit_gb)}" - + + if price > 0: + success_text += f"\n💰 Списано: {texts.format_price(price)}" + if total_discount_value > 0: + success_text += ( + f" (скидка {discount_result['percent']}%:" + f" -{texts.format_price(total_discount_value)})" + ) + await callback.message.edit_text( success_text, reply_markup=get_back_keyboard(db_user.language) @@ -3256,7 +3863,7 @@ async def handle_subscription_settings( Выберите что хотите изменить: """ - show_countries = await _should_show_countries_management() + show_countries = await _should_show_countries_management(db_user) await callback.message.edit_text( settings_text, @@ -3369,8 +3976,8 @@ async def handle_subscription_config_back( await state.set_state(SubscriptionStates.selecting_period) elif current_state == SubscriptionStates.selecting_devices.state: - if await _should_show_countries_management(): - countries = await _get_available_countries() + if await _should_show_countries_management(db_user): + countries = await _get_available_countries(db_user.promo_group_id) data = await state.get_data() selected_countries = data.get('countries', []) @@ -3416,19 +4023,30 @@ async def handle_subscription_cancel( await callback.answer("❌ Покупка отменена") -async def _get_available_countries(): - from app.utils.cache import cache +async def _get_available_countries(promo_group_id: Optional[int] = None): + from app.utils.cache import cache, cache_key from app.database.database import AsyncSessionLocal from app.database.crud.server_squad import get_available_server_squads - - cached_countries = await cache.get("available_countries") + + cache_key_value = cache_key("available_countries", promo_group_id or "all") + cached_countries = await cache.get(cache_key_value) if cached_countries: return cached_countries - + try: async with AsyncSessionLocal() as db: - available_servers = await get_available_server_squads(db) - + available_servers = await get_available_server_squads( + db, promo_group_id=promo_group_id + ) + + if promo_group_id is not None and not available_servers: + logger.info( + "Промогруппа %s не имеет доступных серверов, возврат пустого списка", + promo_group_id, + ) + await cache.set(cache_key_value, [], 60) + return [] + countries = [] for server in available_servers: countries.append({ @@ -3467,16 +4085,16 @@ async def _get_available_countries(): "is_available": True }) - await cache.set("available_countries", countries, 300) + await cache.set(cache_key_value, countries, 300) return countries - + except Exception as e: logger.error(f"Ошибка получения списка стран: {e}") fallback_countries = [ {"uuid": "default-free", "name": "🆓 Бесплатный сервер", "price_kopeks": 0, "is_available": True}, ] - - await cache.set("available_countries", fallback_countries, 60) + + await cache.set(cache_key_value, fallback_countries, 60) return fallback_countries async def _get_countries_info(squad_uuids): @@ -3509,7 +4127,12 @@ async def handle_add_country_to_subscription( logger.info(f"🔍 Данные состояния: {data}") selected_countries = data.get('countries', []) - countries = await _get_available_countries() + countries = await _get_available_countries(db_user.promo_group_id) + allowed_country_ids = {country['uuid'] for country in countries} + + if country_uuid not in allowed_country_ids and country_uuid not in selected_countries: + await callback.answer("❌ Сервер недоступен для вашей промогруппы", show_alert=True) + return if country_uuid in selected_countries: selected_countries.remove(country_uuid) @@ -3519,21 +4142,50 @@ async def handle_add_country_to_subscription( logger.info(f"🔍 Добавлена страна: {country_uuid}") total_price = 0 + subscription = db_user.subscription + period_hint_days = _get_period_hint_from_subscription(subscription) + servers_discount_percent = _get_addon_discount_percent_for_user( + db_user, + "servers", + period_hint_days, + ) + for country in countries: - if country['uuid'] in selected_countries and country['uuid'] not in db_user.subscription.connected_squads: - total_price += country['price_kopeks'] - + if not country.get('is_available', True): + continue + + if ( + country['uuid'] in selected_countries + and country['uuid'] not in subscription.connected_squads + ): + server_price = country['price_kopeks'] + if servers_discount_percent > 0 and server_price > 0: + discounted_price, _ = apply_percentage_discount( + server_price, + servers_discount_percent, + ) + else: + discounted_price = server_price + total_price += discounted_price + data['countries'] = selected_countries data['total_price'] = total_price await state.set_data(data) - + logger.info(f"🔍 Новые выбранные страны: {selected_countries}") logger.info(f"🔍 Общая стоимость: {total_price}") try: from app.keyboards.inline import get_manage_countries_keyboard await callback.message.edit_reply_markup( - reply_markup=get_manage_countries_keyboard(countries, selected_countries, db_user.subscription.connected_squads, db_user.language) + reply_markup=get_manage_countries_keyboard( + countries, + selected_countries, + subscription.connected_squads, + db_user.language, + subscription.end_date, + servers_discount_percent, + ) ) logger.info(f"✅ Клавиатура обновлена") except Exception as e: @@ -3541,9 +4193,36 @@ async def handle_add_country_to_subscription( await callback.answer() -async def _should_show_countries_management() -> bool: +async def _should_show_countries_management(user: Optional[User] = None) -> bool: try: - countries = await _get_available_countries() + promo_group_id = user.promo_group_id if user else None + + promo_group = getattr(user, "promo_group", None) if user else None + if promo_group and getattr(promo_group, "server_squads", None): + allowed_servers = [ + server + for server in promo_group.server_squads + if server.is_available and not server.is_full + ] + + if len(allowed_servers) > 1: + logger.debug( + "Промогруппа %s имеет %s доступных серверов, показываем управление странами", + promo_group.id, + len(allowed_servers), + ) + return True + + if len(promo_group.server_squads) > 1: + logger.debug( + "Промогруппа %s имеет %s серверов, но доступен только %s — показываем управление странами", + promo_group.id, + len(promo_group.server_squads), + len(allowed_servers), + ) + return True + + countries = await _get_available_countries(promo_group_id) available_countries = [c for c in countries if c.get('is_available', True)] return len(available_countries) > 1 except Exception as e: @@ -3564,7 +4243,16 @@ async def confirm_add_countries_to_subscription( selected_countries = data.get('countries', []) current_countries = subscription.connected_squads - + + countries = await _get_available_countries(db_user.promo_group_id) + allowed_country_ids = {country['uuid'] for country in countries} + + selected_countries = [ + country_uuid + for country_uuid in selected_countries + if country_uuid in allowed_country_ids or country_uuid in current_countries + ] + new_countries = [c for c in selected_countries if c not in current_countries] removed_countries = [c for c in current_countries if c not in selected_countries] @@ -3572,23 +4260,68 @@ async def confirm_add_countries_to_subscription( await callback.answer("⚠️ Изменения не обнаружены", show_alert=True) return - countries = await _get_available_countries() total_price = 0 new_countries_names = [] removed_countries_names = [] - + + period_hint_days = _get_period_hint_from_subscription(subscription) + servers_discount_percent = _get_addon_discount_percent_for_user( + db_user, + "servers", + period_hint_days, + ) + total_discount_value = 0 + for country in countries: + if not country.get('is_available', True): + continue + if country['uuid'] in new_countries: - total_price += country['price_kopeks'] + server_price = country['price_kopeks'] + if servers_discount_percent > 0 and server_price > 0: + discounted_per_month, discount_per_month = apply_percentage_discount( + server_price, + servers_discount_percent, + ) + else: + discounted_per_month = server_price + discount_per_month = 0 + + charged_price, charged_months = calculate_prorated_price( + discounted_per_month, + subscription.end_date, + ) + + total_price += charged_price + total_discount_value += discount_per_month * charged_months new_countries_names.append(country['name']) if country['uuid'] in removed_countries: removed_countries_names.append(country['name']) if new_countries and db_user.balance_kopeks < total_price: missing_kopeks = total_price - db_user.balance_kopeks + message_text = texts.t( + "ADDON_INSUFFICIENT_FUNDS_MESSAGE", + ( + "⚠️ Недостаточно средств\n\n" + "Стоимость услуги: {required}\n" + "На балансе: {balance}\n" + "Не хватает: {missing}\n\n" + "Выберите способ пополнения. Сумма подставится автоматически." + ), + ).format( + required=texts.format_price(total_price), + balance=texts.format_price(db_user.balance_kopeks), + missing=texts.format_price(missing_kopeks), + ) + await callback.message.edit_text( - texts.INSUFFICIENT_BALANCE.format(amount=texts.format_price(missing_kopeks)), - reply_markup=get_insufficient_balance_keyboard(db_user.language), + message_text, + reply_markup=get_insufficient_balance_keyboard( + db_user.language, + amount_kopeks=missing_kopeks, + ), + parse_mode="HTML", ) await state.clear() await callback.answer() @@ -3616,7 +4349,7 @@ async def confirm_add_countries_to_subscription( subscription.connected_squads = selected_countries subscription.updated_at = datetime.utcnow() await db.commit() - + subscription_service = SubscriptionService() await subscription_service.update_remnawave_user(db, subscription) @@ -3628,7 +4361,13 @@ async def confirm_add_countries_to_subscription( if new_countries_names: success_text += f"➕ Добавлены страны:\n{chr(10).join(f'• {name}' for name in new_countries_names)}\n" if total_price > 0: - success_text += f"💰 Списано: {texts.format_price(total_price)}\n" + success_text += f"💰 Списано: {texts.format_price(total_price)}" + if total_discount_value > 0: + success_text += ( + f" (скидка {servers_discount_percent}%:" + f" -{texts.format_price(total_discount_value)})" + ) + success_text += "\n" if removed_countries_names: success_text += f"\n➖ Отключены страны:\n{chr(10).join(f'• {name}' for name in removed_countries_names)}\n" @@ -3658,9 +4397,91 @@ async def confirm_reset_devices( db_user: User, db: AsyncSession ): - + await handle_device_management(callback, db_user, db) +async def handle_happ_download_request( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession +): + texts = get_texts(db_user.language) + prompt_text = texts.t( + "HAPP_DOWNLOAD_PROMPT", + "📥 Скачать Happ\nВыберите ваше устройство:", + ) + + keyboard = get_happ_download_platform_keyboard(db_user.language) + + await callback.message.answer(prompt_text, reply_markup=keyboard, parse_mode="HTML") + await callback.answer() + + +async def handle_happ_download_platform_choice( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession +): + platform = callback.data.split('_')[-1] + if platform == "pc": + platform = "windows" + texts = get_texts(db_user.language) + link = settings.get_happ_download_link(platform) + + if not link: + await callback.answer( + texts.t("HAPP_DOWNLOAD_LINK_NOT_SET", "❌ Ссылка для этого устройства не настроена"), + show_alert=True, + ) + return + + platform_names = { + "ios": texts.t("HAPP_PLATFORM_IOS", "🍎 iOS"), + "android": texts.t("HAPP_PLATFORM_ANDROID", "🤖 Android"), + "macos": texts.t("HAPP_PLATFORM_MACOS", "🖥️ Mac OS"), + "windows": texts.t("HAPP_PLATFORM_WINDOWS", "💻 Windows"), + } + + link_text = texts.t( + "HAPP_DOWNLOAD_LINK_MESSAGE", + "⬇️ Скачайте Happ для {platform}:", + ).format(platform=platform_names.get(platform, platform.upper())) + + keyboard = get_happ_download_link_keyboard(db_user.language, link) + + await callback.message.edit_text(link_text, reply_markup=keyboard, parse_mode="HTML") + await callback.answer() + + +async def handle_happ_download_close( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession +): + try: + await callback.message.delete() + except Exception: + pass + + await callback.answer() + + +async def handle_happ_download_back( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession +): + texts = get_texts(db_user.language) + prompt_text = texts.t( + "HAPP_DOWNLOAD_PROMPT", + "📥 Скачать Happ\nВыберите ваше устройство:", + ) + + keyboard = get_happ_download_platform_keyboard(db_user.language) + + await callback.message.edit_text(prompt_text, reply_markup=keyboard, parse_mode="HTML") + await callback.answer() + async def handle_connect_subscription( callback: types.CallbackQuery, db_user: User, @@ -3668,8 +4489,9 @@ async def handle_connect_subscription( ): texts = get_texts(db_user.language) subscription = db_user.subscription - - if not subscription or not subscription.subscription_url: + subscription_link = get_display_subscription_link(subscription) + + if not subscription_link: await callback.answer( texts.t( "SUBSCRIPTION_NO_ACTIVE_LINK", @@ -3686,7 +4508,7 @@ async def handle_connect_subscription( [ InlineKeyboardButton( text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"), - web_app=types.WebAppInfo(url=subscription.subscription_url) + web_app=types.WebAppInfo(url=subscription_link) ) ], [ @@ -3740,29 +4562,61 @@ async def handle_connect_subscription( ) elif connect_mode == "link": - keyboard = InlineKeyboardMarkup(inline_keyboard=[ + rows = [ [ InlineKeyboardButton( text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"), - url=subscription.subscription_url + url=subscription_link ) - ], - [ - InlineKeyboardButton(text=texts.BACK, callback_data="menu_subscription") ] + ] + happ_row = get_happ_download_button_row(texts) + if happ_row: + rows.append(happ_row) + rows.append([ + InlineKeyboardButton(text=texts.BACK, callback_data="menu_subscription") ]) + keyboard = InlineKeyboardMarkup(inline_keyboard=rows) + await callback.message.edit_text( texts.t( "SUBSCRIPTION_CONNECT_LINK_MESSAGE", - """🚀 Подключить подписку + """🚀 Подключить подписку", 🔗 Нажмите кнопку ниже, чтобы открыть ссылку подписки:""", ), reply_markup=keyboard, parse_mode="HTML" ) + elif connect_mode == "happ_cryptolink": + rows = [ + [ + InlineKeyboardButton( + text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"), + callback_data="open_subscription_link", + ) + ] + ] + happ_row = get_happ_download_button_row(texts) + if happ_row: + rows.append(happ_row) + rows.append([ + InlineKeyboardButton(text=texts.BACK, callback_data="menu_subscription") + ]) + keyboard = InlineKeyboardMarkup(inline_keyboard=rows) + + await callback.message.edit_text( + texts.t( + "SUBSCRIPTION_CONNECT_LINK_MESSAGE", + """🚀 Подключить подписку", + +🔗 Нажмите кнопку ниже, чтобы открыть ссылку подписки:""", + ), + reply_markup=keyboard, + parse_mode="HTML" + ) else: device_text = texts.t( "SUBSCRIPTION_CONNECT_DEVICE_MESSAGE", @@ -3772,7 +4626,7 @@ async def handle_connect_subscription( {subscription_url} 💡 Выберите ваше устройство для получения подробной инструкции по настройке:""", - ).format(subscription_url=subscription.subscription_url) + ).format(subscription_url=subscription_link) await callback.message.edit_text( device_text, @@ -3783,6 +4637,76 @@ async def handle_connect_subscription( await callback.answer() +async def claim_discount_offer( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession, +): + texts = get_texts(db_user.language) + + try: + offer_id = int(callback.data.split("_")[-1]) + except (ValueError, AttributeError): + await callback.answer( + texts.get("DISCOUNT_CLAIM_NOT_FOUND", "❌ Предложение не найдено"), + show_alert=True, + ) + return + + offer = await get_offer_by_id(db, offer_id) + if not offer or offer.user_id != db_user.id: + await callback.answer( + texts.get("DISCOUNT_CLAIM_NOT_FOUND", "❌ Предложение не найдено"), + show_alert=True, + ) + return + + now = datetime.utcnow() + if offer.claimed_at is not None: + await callback.answer( + texts.get("DISCOUNT_CLAIM_ALREADY", "ℹ️ Скидка уже была активирована"), + show_alert=True, + ) + return + + if not offer.is_active or offer.expires_at <= now: + offer.is_active = False + await db.commit() + await callback.answer( + texts.get("DISCOUNT_CLAIM_EXPIRED", "⚠️ Время действия предложения истекло"), + show_alert=True, + ) + return + + bonus_amount = offer.bonus_amount_kopeks or 0 + if bonus_amount > 0: + success = await add_user_balance( + db, + db_user, + bonus_amount, + texts.get("DISCOUNT_BONUS_DESCRIPTION", "Скидка за продление подписки"), + ) + if not success: + await callback.answer( + texts.get("DISCOUNT_CLAIM_ERROR", "❌ Не удалось начислить скидку. Попробуйте позже."), + show_alert=True, + ) + return + + await mark_offer_claimed(db, offer) + + success_message = texts.get( + "DISCOUNT_CLAIM_SUCCESS", + "🎉 Скидка {percent}% активирована! На баланс начислено {amount}.", + ).format( + percent=offer.discount_percent, + amount=settings.format_price(bonus_amount), + ) + + await callback.answer("✅ Скидка активирована!", show_alert=True) + await callback.message.answer(success_message) + + async def handle_device_guide( callback: types.CallbackQuery, db_user: User, @@ -3791,8 +4715,9 @@ async def handle_device_guide( device_type = callback.data.split('_')[2] texts = get_texts(db_user.language) subscription = db_user.subscription - - if not subscription or not subscription.subscription_url: + subscription_link = get_display_subscription_link(subscription) + + if not subscription_link: await callback.answer( texts.t("SUBSCRIPTION_LINK_UNAVAILABLE", "❌ Ссылка подписки недоступна"), show_alert=True, @@ -3800,6 +4725,13 @@ async def handle_device_guide( return apps = get_apps_for_device(device_type, db_user.language) + subscription_link = get_display_subscription_link(subscription) + if not subscription_link: + await callback.answer( + texts.t("SUBSCRIPTION_LINK_UNAVAILABLE", "❌ Ссылка подписки недоступна"), + show_alert=True, + ) + return if not apps: await callback.answer( @@ -3817,7 +4749,7 @@ async def handle_device_guide( ).format(device_name=get_device_name(device_type, db_user.language)) + "\n\n" + texts.t("SUBSCRIPTION_DEVICE_LINK_TITLE", "🔗 Ссылка подписки:") - + f"\n{subscription.subscription_url}\n\n" + + f"\n{subscription_link}\n\n" + texts.t( "SUBSCRIPTION_DEVICE_FEATURED_APP", "📋 Рекомендуемое приложение: {app_name}", @@ -3856,7 +4788,7 @@ async def handle_device_guide( await callback.message.edit_text( guide_text, reply_markup=get_connection_guide_keyboard( - subscription.subscription_url, + subscription_link, featured_app, db_user.language ), @@ -3926,7 +4858,7 @@ async def handle_specific_app_guide( ).format(app_name=app['name'], device_name=get_device_name(device_type, db_user.language)) + "\n\n" + texts.t("SUBSCRIPTION_DEVICE_LINK_TITLE", "🔗 Ссылка подписки:") - + f"\n{subscription.subscription_url}\n\n" + + f"\n{subscription_link}\n\n" + texts.t("SUBSCRIPTION_DEVICE_STEP_INSTALL_TITLE", "Шаг 1 - Установка:") + f"\n{app['installationStep']['description'][db_user.language]}\n\n" + texts.t("SUBSCRIPTION_DEVICE_STEP_ADD_TITLE", "Шаг 2 - Добавление подписки:") @@ -3949,7 +4881,7 @@ async def handle_specific_app_guide( await callback.message.edit_text( guide_text, reply_markup=get_specific_app_keyboard( - subscription.subscription_url, + subscription_link, app, device_type, db_user.language @@ -3974,19 +4906,61 @@ async def handle_open_subscription_link( db_user: User, db: AsyncSession ): + texts = get_texts(db_user.language) subscription = db_user.subscription - - if not subscription or not subscription.subscription_url: + subscription_link = get_display_subscription_link(subscription) + + if not subscription_link: await callback.answer( texts.t("SUBSCRIPTION_LINK_UNAVAILABLE", "❌ Ссылка подписки недоступна"), show_alert=True, ) return + if settings.is_happ_cryptolink_mode(): + redirect_link = get_happ_cryptolink_redirect_link(subscription_link) + happ_message = ( + texts.t( + "SUBSCRIPTION_HAPP_OPEN_TITLE", + "🔗 Подключение через Happ", + ) + + "\n\n" + + texts.t( + "SUBSCRIPTION_HAPP_OPEN_LINK", + "🔓 Открыть ссылку в Happ", + ).format(subscription_link=subscription_link) + + "\n\n" + + texts.t( + "SUBSCRIPTION_HAPP_OPEN_HINT", + "💡 Если ссылка не открывается автоматически, скопируйте её вручную: {subscription_link}", + ).format(subscription_link=subscription_link) + ) + + if redirect_link: + happ_message += "\n\n" + texts.t( + "SUBSCRIPTION_HAPP_OPEN_BUTTON_HINT", + "▶️ Нажмите кнопку \"Подключиться\" ниже, чтобы открыть Happ и добавить подписку автоматически.", + ) + + keyboard = get_happ_cryptolink_keyboard( + subscription_link, + db_user.language, + redirect_link=redirect_link, + ) + + await callback.message.answer( + happ_message, + parse_mode="HTML", + disable_web_page_preview=True, + reply_markup=keyboard, + ) + await callback.answer() + return + link_text = ( texts.t("SUBSCRIPTION_DEVICE_LINK_TITLE", "🔗 Ссылка подписки:") + "\n\n" - + f"{subscription.subscription_url}\n\n" + + f"{subscription_link}\n\n" + texts.t("SUBSCRIPTION_LINK_USAGE_TITLE", "📱 Как использовать:") + "\n" + "\n".join( @@ -4113,11 +5087,12 @@ async def show_device_connection_help( ): subscription = db_user.subscription - - if not subscription or not subscription.subscription_url: + subscription_link = get_display_subscription_link(subscription) + + if not subscription_link: await callback.answer("❌ Ссылка подписки недоступна", show_alert=True) return - + help_text = f""" 📱 Как подключить устройство заново @@ -4136,7 +5111,7 @@ async def show_device_connection_help( • Нажмите "Подключить" 🔗 Ваша ссылка подписки: -{subscription.subscription_url} +{subscription_link} 💡 Совет: Сохраните эту ссылку - она понадобится для подключения новых устройств """ @@ -4209,7 +5184,13 @@ async def handle_switch_traffic( 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( + db_user, + "traffic", + period_hint_days, + ) + await callback.message.edit_text( f"🔄 Переключение лимита трафика\n\n" f"Текущий лимит: {texts.format_traffic(current_traffic)}\n" @@ -4217,7 +5198,12 @@ async def handle_switch_traffic( f"💡 Важно:\n" f"• При увеличении - доплата за разницу\n" f"• При уменьшении - возврат средств не производится", - reply_markup=get_traffic_switch_keyboard(current_traffic, db_user.language, subscription.end_date), + reply_markup=get_traffic_switch_keyboard( + current_traffic, + db_user.language, + subscription.end_date, + traffic_discount_percent, + ), parse_mode="HTML" ) @@ -4243,24 +5229,67 @@ async def confirm_switch_traffic( old_price_per_month = settings.get_traffic_price(current_traffic) new_price_per_month = settings.get_traffic_price(new_traffic_gb) - + months_remaining = get_remaining_months(subscription.end_date) - price_difference_per_month = new_price_per_month - old_price_per_month + period_hint_days = months_remaining * 30 if months_remaining > 0 else None + traffic_discount_percent = _get_addon_discount_percent_for_user( + db_user, + "traffic", + period_hint_days, + ) + + discounted_old_per_month, _ = apply_percentage_discount( + old_price_per_month, + traffic_discount_percent, + ) + discounted_new_per_month, _ = apply_percentage_discount( + new_price_per_month, + traffic_discount_percent, + ) + price_difference_per_month = discounted_new_per_month - discounted_old_per_month + discount_savings_per_month = ( + (new_price_per_month - old_price_per_month) - price_difference_per_month + ) if price_difference_per_month > 0: total_price_difference = price_difference_per_month * months_remaining - + if db_user.balance_kopeks < total_price_difference: missing_kopeks = total_price_difference - db_user.balance_kopeks + message_text = texts.t( + "ADDON_INSUFFICIENT_FUNDS_MESSAGE", + ( + "⚠️ Недостаточно средств\n\n" + "Стоимость услуги: {required}\n" + "На балансе: {balance}\n" + "Не хватает: {missing}\n\n" + "Выберите способ пополнения. Сумма подставится автоматически." + ), + ).format( + required=f"{texts.format_price(total_price_difference)} (за {months_remaining} мес)", + balance=texts.format_price(db_user.balance_kopeks), + missing=texts.format_price(missing_kopeks), + ) + await callback.message.edit_text( - texts.INSUFFICIENT_BALANCE.format(amount=texts.format_price(missing_kopeks)), - reply_markup=get_insufficient_balance_keyboard(db_user.language), + message_text, + reply_markup=get_insufficient_balance_keyboard( + db_user.language, + amount_kopeks=missing_kopeks, + ), + parse_mode="HTML", ) await callback.answer() return action_text = f"увеличить до {texts.format_traffic(new_traffic_gb)}" cost_text = f"Доплата: {texts.format_price(total_price_difference)} (за {months_remaining} мес)" + if discount_savings_per_month > 0: + total_discount_savings = discount_savings_per_month * months_remaining + cost_text += ( + f" (скидка {traffic_discount_percent}%:" + f" -{texts.format_price(total_discount_savings)})" + ) else: total_price_difference = 0 action_text = f"уменьшить до {texts.format_traffic(new_traffic_gb)}" @@ -4380,9 +5409,10 @@ async def execute_switch_traffic( def get_traffic_switch_keyboard( - current_traffic_gb: int, - language: str = "ru", - subscription_end_date: datetime = None + current_traffic_gb: int, + language: str = "ru", + subscription_end_date: datetime = None, + discount_percent: int = 0, ) -> InlineKeyboardMarkup: from app.utils.pricing_utils import get_remaining_months from app.config import settings @@ -4398,16 +5428,24 @@ def get_traffic_switch_keyboard( enabled_packages = [pkg for pkg in packages if pkg['enabled']] current_price_per_month = settings.get_traffic_price(current_traffic_gb) + discounted_current_per_month, _ = apply_percentage_discount( + current_price_per_month, + discount_percent, + ) buttons = [] for package in enabled_packages: gb = package['gb'] price_per_month = package['price'] - - price_diff_per_month = price_per_month - current_price_per_month + discounted_price_per_month, _ = apply_percentage_discount( + price_per_month, + discount_percent, + ) + + price_diff_per_month = discounted_price_per_month - discounted_current_per_month total_price_diff = price_diff_per_month * months_multiplier - + if gb == current_traffic_gb: emoji = "✅" action_text = " (текущий)" @@ -4416,6 +5454,13 @@ def get_traffic_switch_keyboard( emoji = "⬆️" action_text = "" price_text = f" (+{total_price_diff//100}₽{period_text})" + if discount_percent > 0: + discount_total = ( + (price_per_month - current_price_per_month) * months_multiplier + - total_price_diff + ) + if discount_total > 0: + price_text += f" (скидка {discount_percent}%: -{discount_total//100}₽)" elif total_price_diff < 0: emoji = "⬇️" action_text = "" @@ -4659,6 +5704,37 @@ def register_handlers(dp: Dispatcher): F.data == "countries_apply" ) + dp.callback_query.register( + claim_discount_offer, + F.data.startswith("claim_discount_") + ) + + dp.callback_query.register( + handle_happ_download_request, + F.data == "subscription_happ_download" + ) + + dp.callback_query.register( + handle_happ_download_platform_choice, + F.data.in_([ + "happ_download_ios", + "happ_download_android", + "happ_download_pc", + "happ_download_macos", + "happ_download_windows", + ]) + ) + + dp.callback_query.register( + handle_happ_download_close, + F.data == "happ_download_close" + ) + + dp.callback_query.register( + handle_happ_download_back, + F.data == "happ_download_back" + ) + dp.callback_query.register( handle_connect_subscription, F.data == "subscription_connect" diff --git a/app/handlers/tickets.py b/app/handlers/tickets.py index 2afc72e0..b421f426 100644 --- a/app/handlers/tickets.py +++ b/app/handlers/tickets.py @@ -374,12 +374,17 @@ async def show_my_tickets( except ValueError: current_page = 1 - # Получаем тикеты пользователя (открытые/закрытые отдельно) - all_tickets = await TicketCRUD.get_user_tickets(db, db_user.id, limit=100) - open_tickets = [t for t in all_tickets if t.status != TicketStatus.CLOSED.value] - closed_tickets = [t for t in all_tickets if t.status == TicketStatus.CLOSED.value] - - if not open_tickets and not closed_tickets: + # Пагинация открытых тикетов из БД + per_page = 10 + total_open = await TicketCRUD.count_user_tickets_by_statuses(db, db_user.id, [TicketStatus.OPEN.value, TicketStatus.ANSWERED.value, TicketStatus.PENDING.value]) + total_pages = max(1, (total_open + per_page - 1) // per_page) + current_page = max(1, min(current_page, total_pages)) + offset = (current_page - 1) * per_page + open_tickets = await TicketCRUD.get_user_tickets_by_statuses(db, db_user.id, [TicketStatus.OPEN.value, TicketStatus.ANSWERED.value, TicketStatus.PENDING.value], limit=per_page, offset=offset) + + # Проверка на отсутствие тикетов совсем (ни открытых, ни закрытых) + has_closed_any = await TicketCRUD.count_user_tickets_by_statuses(db, db_user.id, [TicketStatus.CLOSED.value]) > 0 + if not open_tickets and not has_closed_any: await callback.message.edit_text( texts.t("NO_TICKETS", "У вас пока нет тикетов."), reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[ @@ -400,32 +405,19 @@ async def show_my_tickets( await callback.answer() return - # Открытые с пагинацией - open_data = [] - for t in open_tickets: - if t.status != TicketStatus.CLOSED.value: - open_data.append({'id': t.id, 'title': t.title, 'status_emoji': t.status_emoji}) - per_page = 10 - pag = get_pagination_info(total_count=len(open_data), page=current_page, per_page=per_page) - # Корректируем текущую страницу в допустимые границы - current_page = max(1, min(current_page, pag["total_pages"])) - start_index = (current_page - 1) * per_page - end_index = start_index + per_page - page_items = open_data[start_index:end_index] - keyboard = get_my_tickets_keyboard(page_items, current_page=current_page, total_pages=pag["total_pages"], language=db_user.language) + # Открытые с пагинацией (DB) + open_data = [{'id': t.id, 'title': t.title, 'status_emoji': t.status_emoji} for t in open_tickets] + keyboard = get_my_tickets_keyboard(open_data, current_page=current_page, total_pages=total_pages, language=db_user.language, page_prefix="my_tickets_page_") # Добавим кнопку перехода к закрытым keyboard.inline_keyboard.insert(0, [types.InlineKeyboardButton(text=texts.t("VIEW_CLOSED_TICKETS", "🟢 Закрытые тикеты"), callback_data="my_tickets_closed")]) - # Покажем список тикетов c логотипом, если режим включен - if settings.ENABLE_LOGO_MODE and callback.message.photo: - from app.utils.photo_message import edit_or_answer_photo - await edit_or_answer_photo( - callback=callback, - caption=texts.t("MY_TICKETS_TITLE", "📋 Ваши тикеты:"), - keyboard=keyboard, - parse_mode="HTML", - ) - else: - await callback.message.edit_text(texts.t("MY_TICKETS_TITLE", "📋 Ваши тикеты:"), reply_markup=keyboard) + # Всегда используем фото-рендер с логотипом (утилита сама сделает фоллбек при необходимости) + from app.utils.photo_message import edit_or_answer_photo + await edit_or_answer_photo( + callback=callback, + caption=texts.t("MY_TICKETS_TITLE", "📋 Ваши тикеты:"), + keyboard=keyboard, + parse_mode="HTML", + ) await callback.answer() @@ -435,9 +427,18 @@ async def show_my_tickets_closed( db: AsyncSession ): texts = get_texts(db_user.language) - # Пагинация (при необходимости можно добавить аналогично open) - tickets = await TicketCRUD.get_user_tickets(db, db_user.id, status=TicketStatus.CLOSED.value, limit=10) - if not tickets: + # Пагинация закрытых + current_page = 1 + data_str = callback.data + if data_str.startswith("my_tickets_closed_page_"): + try: + current_page = int(data_str.replace("my_tickets_closed_page_", "")) + except ValueError: + current_page = 1 + + per_page = 10 + total_closed = await TicketCRUD.count_user_tickets_by_statuses(db, db_user.id, [TicketStatus.CLOSED.value]) + if total_closed == 0: await callback.message.edit_text( texts.t("NO_CLOSED_TICKETS", "Закрытых тикетов пока нет."), reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[ @@ -447,19 +448,20 @@ async def show_my_tickets_closed( ) await callback.answer() return + total_pages = max(1, (total_closed + per_page - 1) // per_page) + current_page = max(1, min(current_page, total_pages)) + offset = (current_page - 1) * per_page + tickets = await TicketCRUD.get_user_tickets_by_statuses(db, db_user.id, [TicketStatus.CLOSED.value], limit=per_page, offset=offset) data = [{'id': t.id, 'title': t.title, 'status_emoji': t.status_emoji} for t in tickets] - kb = get_my_tickets_keyboard(data, current_page=1, language=db_user.language) + kb = get_my_tickets_keyboard(data, current_page=current_page, total_pages=total_pages, language=db_user.language, page_prefix="my_tickets_closed_page_") kb.inline_keyboard.insert(0, [types.InlineKeyboardButton(text=texts.t("BACK_TO_OPEN_TICKETS", "🔴 Открытые тикеты"), callback_data="my_tickets")]) - if settings.ENABLE_LOGO_MODE and callback.message.photo: - from app.utils.photo_message import edit_or_answer_photo - await edit_or_answer_photo( - callback=callback, - caption=texts.t("CLOSED_TICKETS_TITLE", "🟢 Закрытые тикеты:"), - keyboard=kb, - parse_mode="HTML", - ) - else: - await callback.message.edit_text(texts.t("CLOSED_TICKETS_TITLE", "🟢 Закрытые тикеты:"), reply_markup=kb) + from app.utils.photo_message import edit_or_answer_photo + await edit_or_answer_photo( + callback=callback, + caption=texts.t("CLOSED_TICKETS_TITLE", "🟢 Закрытые тикеты:"), + keyboard=kb, + parse_mode="HTML", + ) await callback.answer() @@ -758,7 +760,10 @@ async def handle_ticket_reply( if ticket.status == TicketStatus.CLOSED.value: texts = get_texts(db_user.language) await message.answer( - texts.t("TICKET_CLOSED", "✅ Тикет закрыт.") + texts.t("TICKET_CLOSED", "✅ Тикет закрыт."), + reply_markup=types.InlineKeyboardMarkup( + inline_keyboard=[[types.InlineKeyboardButton(text=texts.t("CLOSE_NOTIFICATION", "❌ Закрыть уведомление"), callback_data=f"close_ticket_notification_{ticket.id}")]] + ) ) await state.clear() return @@ -767,7 +772,10 @@ async def handle_ticket_reply( if ticket.status == TicketStatus.CLOSED.value or ticket.is_user_reply_blocked: texts = get_texts(db_user.language) await message.answer( - texts.t("TICKET_CLOSED_NO_REPLY", "❌ Тикет закрыт, ответить невозможно.") + texts.t("TICKET_CLOSED_NO_REPLY", "❌ Тикет закрыт, ответить невозможно."), + reply_markup=types.InlineKeyboardMarkup( + inline_keyboard=[[types.InlineKeyboardButton(text=texts.t("CLOSE_NOTIFICATION", "❌ Закрыть уведомление"), callback_data=f"close_ticket_notification_{ticket.id}")]] + ) ) await state.clear() return @@ -981,6 +989,10 @@ def register_handlers(dp: Dispatcher): show_my_tickets_closed, F.data == "my_tickets_closed" ) + dp.callback_query.register( + show_my_tickets_closed, + F.data.startswith("my_tickets_closed_page_") + ) dp.callback_query.register( view_ticket, diff --git a/app/keyboards/admin.py b/app/keyboards/admin.py index df1db350..13c37204 100644 --- a/app/keyboards/admin.py +++ b/app/keyboards/admin.py @@ -10,6 +10,7 @@ def get_admin_main_keyboard(language: str = "ru") -> InlineKeyboardMarkup: return InlineKeyboardMarkup(inline_keyboard=[ [InlineKeyboardButton(text="👥 Юзеры/Подписки", callback_data="admin_submenu_users")], [InlineKeyboardButton(text="💰 Промокоды/Статистика", callback_data="admin_submenu_promo")], + [InlineKeyboardButton(text="🛟 Поддержка", callback_data="admin_submenu_support")], [InlineKeyboardButton(text="📨 Сообщения", callback_data="admin_submenu_communications")], [InlineKeyboardButton(text="⚙️ Настройки", callback_data="admin_submenu_settings")], [InlineKeyboardButton(text="🛠️ Система", callback_data="admin_submenu_system")], @@ -61,15 +62,28 @@ def get_admin_communications_submenu_keyboard(language: str = "ru") -> InlineKey [ InlineKeyboardButton(text=texts.ADMIN_MESSAGES, callback_data="admin_messages") ], + [ + InlineKeyboardButton(text="👋 Приветственный текст", callback_data="welcome_text_panel"), + InlineKeyboardButton(text="📢 Сообщения в меню", callback_data="user_messages_panel") + ], + [ + InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_panel") + ] + ]) + + +def get_admin_support_submenu_keyboard(language: str = "ru") -> InlineKeyboardMarkup: + texts = get_texts(language) + + return InlineKeyboardMarkup(inline_keyboard=[ [ InlineKeyboardButton(text="🎫 Тикеты поддержки", callback_data="admin_tickets") ], [ - InlineKeyboardButton(text="🛟 Настройки поддержки", callback_data="admin_support_settings") + InlineKeyboardButton(text="🧾 Аудит модераторов", callback_data="admin_support_audit") ], [ - InlineKeyboardButton(text="👋 Приветственный текст", callback_data="welcome_text_panel"), - InlineKeyboardButton(text="📢 Сообщения в меню", callback_data="user_messages_panel") + InlineKeyboardButton(text="🛟 Настройки поддержки", callback_data="admin_support_settings") ], [ InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_panel") @@ -85,6 +99,12 @@ def get_admin_settings_submenu_keyboard(language: str = "ru") -> InlineKeyboardM InlineKeyboardButton(text=texts.ADMIN_REMNAWAVE, callback_data="admin_remnawave"), InlineKeyboardButton(text=texts.ADMIN_MONITORING, callback_data="admin_monitoring") ], + [ + InlineKeyboardButton( + text=texts.t("ADMIN_MONITORING_SETTINGS", "⚙️ Настройки мониторинга"), + callback_data="admin_mon_settings" + ) + ], [ InlineKeyboardButton(text=texts.ADMIN_RULES, callback_data="admin_rules"), InlineKeyboardButton(text="🔧 Техработы", callback_data="maintenance_panel") @@ -97,18 +117,36 @@ def get_admin_settings_submenu_keyboard(language: str = "ru") -> InlineKeyboardM def get_admin_system_submenu_keyboard(language: str = "ru") -> InlineKeyboardMarkup: texts = get_texts(language) - + return InlineKeyboardMarkup(inline_keyboard=[ [ InlineKeyboardButton(text="📄 Обновления", callback_data="admin_updates"), InlineKeyboardButton(text="🗄️ Бекапы", callback_data="backup_panel") ], + [InlineKeyboardButton(text=texts.t("ADMIN_REPORTS", "📊 Отчеты"), callback_data="admin_reports")], [ InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_panel") ] ]) +def get_admin_reports_keyboard(language: str = "ru") -> InlineKeyboardMarkup: + return InlineKeyboardMarkup(inline_keyboard=[ + [InlineKeyboardButton(text="📆 За вчера", callback_data="admin_reports_daily")], + [InlineKeyboardButton(text="🗓️ За неделю", callback_data="admin_reports_weekly")], + [InlineKeyboardButton(text="📅 За месяц", callback_data="admin_reports_monthly")], + [InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_panel")] + ]) + + +def get_admin_report_result_keyboard(language: str = "ru") -> InlineKeyboardMarkup: + texts = get_texts(language) + + return InlineKeyboardMarkup(inline_keyboard=[ + [InlineKeyboardButton(text=texts.t("REPORT_CLOSE", "❌ Закрыть"), callback_data="admin_close_report")] + ]) + + def get_admin_users_keyboard(language: str = "ru") -> InlineKeyboardMarkup: return InlineKeyboardMarkup(inline_keyboard=[ [ diff --git a/app/keyboards/inline.py b/app/keyboards/inline.py index 55590709..a57c6cf6 100644 --- a/app/keyboards/inline.py +++ b/app/keyboards/inline.py @@ -8,7 +8,11 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.config import settings, PERIOD_PRICES, TRAFFIC_PRICES from app.localization.loader import DEFAULT_LANGUAGE from app.localization.texts import get_texts -from app.utils.pricing_utils import format_period_description +from app.utils.pricing_utils import format_period_description, apply_percentage_discount +from app.utils.subscription_utils import ( + get_display_subscription_link, + get_happ_cryptolink_redirect_link, +) import logging logger = logging.getLogger(__name__) @@ -67,6 +71,8 @@ def get_main_menu_keyboard( balance_kopeks: int = 0, subscription=None, show_resume_checkout: bool = False, + *, + is_moderator: bool = False, ) -> InlineKeyboardMarkup: texts = get_texts(language) @@ -85,13 +91,24 @@ def get_main_menu_keyboard( if has_active_subscription and subscription_is_active: connect_mode = settings.CONNECT_BUTTON_MODE + subscription_link = get_display_subscription_link(subscription) + + def _fallback_connect_button() -> InlineKeyboardButton: + return InlineKeyboardButton( + text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"), + callback_data="subscription_connect", + ) + if connect_mode == "miniapp_subscription": - keyboard.append([ - InlineKeyboardButton( - text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"), - web_app=types.WebAppInfo(url=subscription.subscription_url) - ) - ]) + if subscription_link: + keyboard.append([ + InlineKeyboardButton( + text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"), + web_app=types.WebAppInfo(url=subscription_link) + ) + ]) + else: + keyboard.append([_fallback_connect_button()]) elif connect_mode == "miniapp_custom": keyboard.append([ InlineKeyboardButton( @@ -100,19 +117,31 @@ def get_main_menu_keyboard( ) ]) elif connect_mode == "link": - keyboard.append([ - InlineKeyboardButton( - text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"), - url=subscription.subscription_url - ) - ]) + if subscription_link: + keyboard.append([ + InlineKeyboardButton( + text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"), + url=subscription_link + ) + ]) + else: + keyboard.append([_fallback_connect_button()]) + elif connect_mode == "happ_cryptolink": + if subscription_link: + keyboard.append([ + InlineKeyboardButton( + text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"), + callback_data="open_subscription_link", + ) + ]) + else: + keyboard.append([_fallback_connect_button()]) else: - keyboard.append([ - InlineKeyboardButton( - text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"), - callback_data="subscription_connect" - ) - ]) + keyboard.append([_fallback_connect_button()]) + + happ_row = get_happ_download_button_row(texts) + if happ_row: + keyboard.append(happ_row) keyboard.append([ InlineKeyboardButton(text=balance_button_text, callback_data="menu_balance"), @@ -170,6 +199,15 @@ def get_main_menu_keyboard( keyboard.append([ InlineKeyboardButton(text=server_status_text, url=status_url) ]) + elif server_status_mode == "external_link_miniapp": + status_url = settings.get_server_status_external_url() + if status_url: + keyboard.append([ + InlineKeyboardButton( + text=server_status_text, + web_app=types.WebAppInfo(url=status_url), + ) + ]) elif server_status_mode == "xray": keyboard.append([ InlineKeyboardButton(text=server_status_text, callback_data="menu_server_status") @@ -198,10 +236,104 @@ def get_main_menu_keyboard( else: if settings.DEBUG: print("DEBUG KEYBOARD: Админ кнопка НЕ добавлена") + # Moderator access (limited support panel) + if (not is_admin) and is_moderator: + keyboard.append([ + InlineKeyboardButton(text="🧑‍⚖️ Модерация", callback_data="moderator_panel") + ]) return InlineKeyboardMarkup(inline_keyboard=keyboard) +def get_happ_download_button_row(texts) -> Optional[List[InlineKeyboardButton]]: + if not settings.is_happ_download_button_enabled(): + return None + + return [ + InlineKeyboardButton( + text=texts.t("HAPP_DOWNLOAD_BUTTON", "⬇️ Скачать Happ"), + callback_data="subscription_happ_download" + ) + ] + + +def get_happ_cryptolink_keyboard( + subscription_link: str, + language: str = DEFAULT_LANGUAGE, + redirect_link: Optional[str] = None, +) -> InlineKeyboardMarkup: + texts = get_texts(language) + final_redirect_link = redirect_link or get_happ_cryptolink_redirect_link(subscription_link) + + buttons: List[List[InlineKeyboardButton]] = [] + + if final_redirect_link: + buttons.append([ + InlineKeyboardButton( + text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"), + url=final_redirect_link, + ) + ]) + + buttons.extend([ + [ + InlineKeyboardButton( + text=texts.t("HAPP_PLATFORM_IOS", "🍎 iOS"), + callback_data="happ_download_ios", + ) + ], + [ + InlineKeyboardButton( + text=texts.t("HAPP_PLATFORM_ANDROID", "🤖 Android"), + callback_data="happ_download_android", + ) + ], + [ + InlineKeyboardButton( + text=texts.t("HAPP_PLATFORM_MACOS", "🖥️ Mac OS"), + callback_data="happ_download_macos", + ) + ], + [ + InlineKeyboardButton( + text=texts.t("HAPP_PLATFORM_WINDOWS", "💻 Windows"), + callback_data="happ_download_windows", + ) + ], + [ + InlineKeyboardButton( + text=texts.t("BACK_TO_MAIN_MENU_BUTTON", "⬅️ В главное меню"), + callback_data="back_to_menu", + ) + ], + ]) + + return InlineKeyboardMarkup(inline_keyboard=buttons) + + +def get_happ_download_platform_keyboard(language: str = DEFAULT_LANGUAGE) -> InlineKeyboardMarkup: + texts = get_texts(language) + buttons = [ + [InlineKeyboardButton(text=texts.t("HAPP_PLATFORM_IOS", "🍎 iOS"), callback_data="happ_download_ios")], + [InlineKeyboardButton(text=texts.t("HAPP_PLATFORM_ANDROID", "🤖 Android"), callback_data="happ_download_android")], + [InlineKeyboardButton(text=texts.t("HAPP_PLATFORM_MACOS", "🖥️ Mac OS"), callback_data="happ_download_macos")], + [InlineKeyboardButton(text=texts.t("HAPP_PLATFORM_WINDOWS", "💻 Windows"), callback_data="happ_download_windows")], + [InlineKeyboardButton(text=texts.BACK, callback_data="happ_download_close")], + ] + + return InlineKeyboardMarkup(inline_keyboard=buttons) + + +def get_happ_download_link_keyboard(language: str, link: str) -> InlineKeyboardMarkup: + texts = get_texts(language) + buttons = [ + [InlineKeyboardButton(text=texts.t("HAPP_DOWNLOAD_OPEN_LINK", "🔗 Открыть ссылку"), url=link)], + [InlineKeyboardButton(text=texts.BACK, callback_data="happ_download_back")], + ] + + return InlineKeyboardMarkup(inline_keyboard=buttons) + + def get_back_keyboard(language: str = DEFAULT_LANGUAGE) -> InlineKeyboardMarkup: texts = get_texts(language) return InlineKeyboardMarkup(inline_keyboard=[ @@ -254,29 +386,36 @@ def get_server_status_keyboard( def get_insufficient_balance_keyboard( language: str = DEFAULT_LANGUAGE, resume_callback: str | None = None, - ) -> InlineKeyboardMarkup: + amount_kopeks: int | None = None, +) -> InlineKeyboardMarkup: texts = get_texts(language) - keyboard: list[list[InlineKeyboardButton]] = [ - [ - InlineKeyboardButton( - text=texts.GO_TO_BALANCE_TOP_UP, - callback_data="balance_topup", - ) - ] - ] + keyboard = get_payment_methods_keyboard(amount_kopeks or 0, language) if resume_callback: - keyboard.append([ - InlineKeyboardButton( - text=texts.RETURN_TO_SUBSCRIPTION_CHECKOUT, - callback_data=resume_callback, + keyboard.inline_keyboard.insert( + 0, + [ + InlineKeyboardButton( + text=texts.RETURN_TO_SUBSCRIPTION_CHECKOUT, + callback_data=resume_callback, + ) + ], + ) + + if keyboard.inline_keyboard: + last_row = keyboard.inline_keyboard[-1] + if ( + len(last_row) == 1 + and isinstance(last_row[0], InlineKeyboardButton) + and last_row[0].callback_data == "menu_balance" + ): + keyboard.inline_keyboard[-1][0] = InlineKeyboardButton( + text=last_row[0].text, + callback_data="back_to_menu", ) - ]) - keyboard.append([InlineKeyboardButton(text=texts.BACK, callback_data="back_to_menu")]) - - return InlineKeyboardMarkup(inline_keyboard=keyboard) + return keyboard def get_subscription_keyboard( @@ -291,14 +430,15 @@ def get_subscription_keyboard( keyboard = [] if has_subscription: - if subscription and subscription.subscription_url: + subscription_link = get_display_subscription_link(subscription) if subscription else None + if subscription_link: connect_mode = settings.CONNECT_BUTTON_MODE - + if connect_mode == "miniapp_subscription": keyboard.append([ InlineKeyboardButton( text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"), - web_app=types.WebAppInfo(url=subscription.subscription_url) + web_app=types.WebAppInfo(url=subscription_link) ) ]) elif connect_mode == "miniapp_custom": @@ -315,12 +455,34 @@ def get_subscription_keyboard( ]) elif connect_mode == "link": keyboard.append([ - InlineKeyboardButton(text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"), url=subscription.subscription_url) + InlineKeyboardButton(text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"), url=subscription_link) + ]) + elif connect_mode == "happ_cryptolink": + keyboard.append([ + InlineKeyboardButton( + text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"), + callback_data="open_subscription_link", + ) ]) else: keyboard.append([ InlineKeyboardButton(text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"), callback_data="subscription_connect") ]) + elif settings.CONNECT_BUTTON_MODE == "miniapp_custom": + keyboard.append([ + InlineKeyboardButton( + text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"), + web_app=types.WebAppInfo(url=settings.MINIAPP_CUSTOM_URL) + ) + ]) + else: + keyboard.append([ + InlineKeyboardButton(text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"), callback_data="subscription_connect") + ]) + + happ_row = get_happ_download_button_row(texts) + if happ_row: + keyboard.append(happ_row) if not is_trial: keyboard.append([ @@ -351,8 +513,11 @@ def get_subscription_keyboard( return InlineKeyboardMarkup(inline_keyboard=keyboard) -def get_payment_methods_keyboard_with_cart(language: str = "ru") -> InlineKeyboardMarkup: - keyboard = get_payment_methods_keyboard(0, language) +def get_payment_methods_keyboard_with_cart( + language: str = "ru", + amount_kopeks: int = 0, +) -> InlineKeyboardMarkup: + keyboard = get_payment_methods_keyboard(amount_kopeks, language) # Добавляем кнопку "Очистить корзину" keyboard.inline_keyboard.append([ @@ -380,21 +545,26 @@ def get_subscription_confirm_keyboard_with_cart(language: str = "ru") -> InlineK )] ]) -def get_insufficient_balance_keyboard_with_cart(language: str = "ru") -> InlineKeyboardMarkup: - return InlineKeyboardMarkup(inline_keyboard=[ - [InlineKeyboardButton( - text="💰 Пополнить баланс", - callback_data="balance_topup" - )], - [InlineKeyboardButton( - text="🗑️ Очистить корзину", - callback_data="clear_saved_cart" - )], - [InlineKeyboardButton( - text="🔙 Назад", - callback_data="back_to_menu" - )] - ]) +def get_insufficient_balance_keyboard_with_cart( + language: str = "ru", + amount_kopeks: int = 0, +) -> InlineKeyboardMarkup: + keyboard = get_insufficient_balance_keyboard( + language, + amount_kopeks=amount_kopeks, + ) + + keyboard.inline_keyboard.insert( + 0, + [ + InlineKeyboardButton( + text="🗑️ Очистить корзину и вернуться", + callback_data="clear_saved_cart", + ) + ], + ) + + return keyboard def get_trial_keyboard(language: str = "ru") -> InlineKeyboardMarkup: texts = get_texts(language) @@ -608,67 +778,89 @@ def get_balance_keyboard(language: str = DEFAULT_LANGUAGE) -> InlineKeyboardMark def get_payment_methods_keyboard(amount_kopeks: int, language: str = DEFAULT_LANGUAGE) -> InlineKeyboardMarkup: texts = get_texts(language) keyboard = [] - - + + amount_kopeks = max(0, int(amount_kopeks or 0)) + + def _build_callback(method: str) -> str: + if amount_kopeks > 0: + return f"topup_amount|{method}|{amount_kopeks}" + return f"topup_{method}" + if settings.TELEGRAM_STARS_ENABLED: keyboard.append([ InlineKeyboardButton( - text=texts.t("PAYMENT_TELEGRAM_STARS", "⭐ Telegram Stars"), - callback_data="topup_stars" + text=texts.t("PAYMENT_TELEGRAM_STARS", "⭐ Telegram Stars"), + callback_data=_build_callback("stars") ) ]) if settings.is_yookassa_enabled(): keyboard.append([ InlineKeyboardButton( - text=texts.t("PAYMENT_CARD_YOOKASSA", "💳 Банковская карта (YooKassa)"), - callback_data="topup_yookassa" + text=texts.t("PAYMENT_CARD_YOOKASSA", "💳 Банковская карта (YooKassa)"), + callback_data=_build_callback("yookassa") ) ]) - + if settings.YOOKASSA_SBP_ENABLED: keyboard.append([ InlineKeyboardButton( - text=texts.t("PAYMENT_SBP_YOOKASSA", "🏦 Оплатить по СБП (YooKassa)"), - callback_data="topup_yookassa_sbp" + text=texts.t("PAYMENT_SBP_YOOKASSA", "🏦 Оплатить по СБП (YooKassa)"), + callback_data=_build_callback("yookassa_sbp") ) ]) if settings.TRIBUTE_ENABLED: keyboard.append([ InlineKeyboardButton( - text=texts.t("PAYMENT_CARD_TRIBUTE", "💳 Банковская карта (Tribute)"), - callback_data="topup_tribute" + text=texts.t("PAYMENT_CARD_TRIBUTE", "💳 Банковская карта (Tribute)"), + callback_data=_build_callback("tribute") + ) + ]) + + if settings.is_mulenpay_enabled(): + keyboard.append([ + InlineKeyboardButton( + text=texts.t("PAYMENT_CARD_MULENPAY", "💳 Банковская карта (Mulen Pay)"), + callback_data=_build_callback("mulenpay") + ) + ]) + + if settings.is_pal24_enabled(): + keyboard.append([ + InlineKeyboardButton( + text=texts.t("PAYMENT_CARD_PAL24", "💳 Банковская карта (PayPalych)"), + callback_data=_build_callback("pal24") ) ]) if settings.is_cryptobot_enabled(): keyboard.append([ InlineKeyboardButton( - text=texts.t("PAYMENT_CRYPTOBOT", "🪙 Криптовалюта (CryptoBot)"), - callback_data="topup_cryptobot" + text=texts.t("PAYMENT_CRYPTOBOT", "🪙 Криптовалюта (CryptoBot)"), + callback_data=_build_callback("cryptobot") ) ]) keyboard.append([ InlineKeyboardButton( - text=texts.t("PAYMENT_VIA_SUPPORT", "🛠️ Через поддержку"), + text=texts.t("PAYMENT_VIA_SUPPORT", "🛠️ Через поддержку"), callback_data="topup_support" ) ]) - - if len(keyboard) == 1: + + if len(keyboard) == 1: keyboard.insert(0, [ InlineKeyboardButton( text=texts.t("PAYMENTS_TEMPORARILY_UNAVAILABLE", "⚠️ Способы оплаты временно недоступны"), callback_data="payment_methods_unavailable" ) ]) - + keyboard.append([ InlineKeyboardButton(text=texts.BACK, callback_data="menu_balance") ]) - + return InlineKeyboardMarkup(inline_keyboard=keyboard) def get_yookassa_payment_keyboard( @@ -931,7 +1123,11 @@ def get_extend_subscription_keyboard(language: str = DEFAULT_LANGUAGE) -> Inline return InlineKeyboardMarkup(inline_keyboard=keyboard) -def get_add_traffic_keyboard(language: str = DEFAULT_LANGUAGE, subscription_end_date: datetime = None) -> InlineKeyboardMarkup: +def get_add_traffic_keyboard( + language: str = DEFAULT_LANGUAGE, + subscription_end_date: datetime = None, + discount_percent: int = 0, +) -> InlineKeyboardMarkup: from app.utils.pricing_utils import get_remaining_months from app.config import settings texts = get_texts(language) @@ -963,8 +1159,13 @@ def get_add_traffic_keyboard(language: str = DEFAULT_LANGUAGE, subscription_end_ for package in enabled_packages: gb = package['gb'] price_per_month = package['price'] - total_price = price_per_month * months_multiplier - + 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 gb == 0: if language == "ru": text = f"♾️ Безлимитный трафик - {total_price//100} ₽{period_text}" @@ -975,7 +1176,10 @@ def get_add_traffic_keyboard(language: str = DEFAULT_LANGUAGE, subscription_end_ 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}") ]) @@ -989,7 +1193,12 @@ def get_add_traffic_keyboard(language: str = DEFAULT_LANGUAGE, subscription_end_ return InlineKeyboardMarkup(inline_keyboard=buttons) -def get_change_devices_keyboard(current_devices: int, language: str = DEFAULT_LANGUAGE, subscription_end_date: datetime = None) -> InlineKeyboardMarkup: +def get_change_devices_keyboard( + current_devices: int, + language: str = DEFAULT_LANGUAGE, + subscription_end_date: datetime = None, + discount_percent: int = 0, +) -> InlineKeyboardMarkup: from app.utils.pricing_utils import get_remaining_months from app.config import settings texts = get_texts(language) @@ -1026,8 +1235,17 @@ def get_change_devices_keyboard(current_devices: int, language: str = DEFAULT_LA if chargeable_devices > 0: price_per_month = chargeable_devices * device_price_per_month - total_price = price_per_month * months_multiplier + discounted_per_month, discount_per_month = apply_percentage_discount( + price_per_month, + discount_percent, + ) + total_price = discounted_per_month * months_multiplier price_text = f" (+{total_price//100}₽{period_text})" + if discount_percent > 0 and discount_per_month * months_multiplier > 0: + price_text += ( + f" (скидка {discount_percent}%:" + f" -{(discount_per_month * months_multiplier)//100}₽)" + ) action_text = "" else: price_text = " (бесплатно)" @@ -1104,7 +1322,8 @@ def get_manage_countries_keyboard( selected: List[str], current_subscription_countries: List[str], language: str = DEFAULT_LANGUAGE, - subscription_end_date: datetime = None + subscription_end_date: datetime = None, + discount_percent: int = 0, ) -> InlineKeyboardMarkup: from app.utils.pricing_utils import get_remaining_months @@ -1119,10 +1338,18 @@ def get_manage_countries_keyboard( total_cost = 0 for country in countries: + if not country.get('is_available', True): + continue + uuid = country['uuid'] name = country['name'] price_per_month = country['price_kopeks'] - + + discounted_per_month, discount_per_month = apply_percentage_discount( + price_per_month, + discount_percent, + ) + if uuid in current_subscription_countries: if uuid in selected: icon = "✅" @@ -1131,17 +1358,31 @@ def get_manage_countries_keyboard( else: if uuid in selected: icon = "➕" - total_cost += price_per_month * months_multiplier + total_cost += discounted_per_month * months_multiplier else: icon = "⚪" - + if uuid not in current_subscription_countries and uuid in selected: - total_price = price_per_month * months_multiplier + total_price = discounted_per_month * months_multiplier if months_multiplier > 1: - price_text = f" ({price_per_month//100}₽/мес × {months_multiplier} = {total_price//100}₽)" - logger.info(f"🔍 Сервер {name}: {price_per_month/100}₽/мес × {months_multiplier} мес = {total_price/100}₽") + price_text = ( + f" ({discounted_per_month//100}₽/мес × {months_multiplier} = {total_price//100}₽)" + ) + logger.info( + "🔍 Сервер %s: %.2f₽/мес × %s мес = %.2f₽ (скидка %.2f₽)", + name, + discounted_per_month / 100, + months_multiplier, + total_price / 100, + (discount_per_month * months_multiplier) / 100, + ) else: price_text = f" ({total_price//100}₽)" + if discount_percent > 0 and discount_per_month * months_multiplier > 0: + price_text += ( + f" (скидка {discount_percent}%:" + f" -{(discount_per_month * months_multiplier)//100}₽)" + ) display_name = f"{icon} {name}{price_text}" else: display_name = f"{icon} {name}" @@ -1226,9 +1467,18 @@ def get_connection_guide_keyboard( if app_buttons: keyboard.append(app_buttons) - keyboard.append([ - InlineKeyboardButton(text=texts.t("COPY_SUBSCRIPTION_LINK", "📋 Скопировать ссылку подписки"), url=subscription_url) - ]) + if settings.is_happ_cryptolink_mode(): + copy_button = InlineKeyboardButton( + text=texts.t("COPY_SUBSCRIPTION_LINK", "📋 Скопировать ссылку подписки"), + callback_data="open_subscription_link", + ) + else: + copy_button = InlineKeyboardButton( + text=texts.t("COPY_SUBSCRIPTION_LINK", "📋 Скопировать ссылку подписки"), + url=subscription_url, + ) + + keyboard.append([copy_button]) keyboard.extend([ [ @@ -1299,9 +1549,18 @@ def get_specific_app_keyboard( if app_buttons: keyboard.append(app_buttons) - keyboard.append([ - InlineKeyboardButton(text=texts.t("COPY_SUBSCRIPTION_LINK", "📋 Скопировать ссылку подписки"), url=subscription_url) - ]) + if settings.is_happ_cryptolink_mode(): + copy_button = InlineKeyboardButton( + text=texts.t("COPY_SUBSCRIPTION_LINK", "📋 Скопировать ссылку подписки"), + callback_data="open_subscription_link", + ) + else: + copy_button = InlineKeyboardButton( + text=texts.t("COPY_SUBSCRIPTION_LINK", "📋 Скопировать ссылку подписки"), + url=subscription_url, + ) + + keyboard.append([copy_button]) if 'additionalAfterAddSubscriptionStep' in app and 'buttons' in app['additionalAfterAddSubscriptionStep']: for button in app['additionalAfterAddSubscriptionStep']['buttons']: @@ -1542,7 +1801,8 @@ def get_my_tickets_keyboard( tickets: List[dict], current_page: int = 1, total_pages: int = 1, - language: str = DEFAULT_LANGUAGE + language: str = DEFAULT_LANGUAGE, + page_prefix: str = "my_tickets_page_" ) -> InlineKeyboardMarkup: texts = get_texts(language) keyboard = [] @@ -1570,7 +1830,7 @@ def get_my_tickets_keyboard( nav_row.append( InlineKeyboardButton( text=texts.t("PAGINATION_PREV", "⬅️"), - callback_data=f"my_tickets_page_{current_page - 1}" + callback_data=f"{page_prefix}{current_page - 1}" ) ) @@ -1585,7 +1845,7 @@ def get_my_tickets_keyboard( nav_row.append( InlineKeyboardButton( text=texts.t("PAGINATION_NEXT", "➡️"), - callback_data=f"my_tickets_page_{current_page + 1}" + callback_data=f"{page_prefix}{current_page + 1}" ) ) @@ -1648,7 +1908,9 @@ def get_admin_tickets_keyboard( current_page: int = 1, total_pages: int = 1, language: str = DEFAULT_LANGUAGE, - scope: str = "all" + scope: str = "all", + *, + back_callback: str = "admin_submenu_support" ) -> InlineKeyboardMarkup: texts = get_texts(language) keyboard = [] @@ -1713,7 +1975,7 @@ def get_admin_tickets_keyboard( keyboard.append(nav_row) keyboard.append([ - InlineKeyboardButton(text=texts.BACK, callback_data="admin_submenu_communications") + InlineKeyboardButton(text=texts.BACK, callback_data=back_callback) ]) return InlineKeyboardMarkup(inline_keyboard=keyboard) @@ -1722,7 +1984,9 @@ def get_admin_tickets_keyboard( def get_admin_ticket_view_keyboard( ticket_id: int, is_closed: bool = False, - language: str = DEFAULT_LANGUAGE + language: str = DEFAULT_LANGUAGE, + *, + is_user_blocked: bool = False ) -> InlineKeyboardMarkup: texts = get_texts(language) keyboard = [] @@ -1743,14 +2007,16 @@ def get_admin_ticket_view_keyboard( ) ]) - # Block controls: first row Unblock + Block forever, second row Block by time - keyboard.append([ - InlineKeyboardButton(text=texts.t("UNBLOCK", "✅ Разблокировать"), callback_data=f"admin_unblock_user_ticket_{ticket_id}"), - InlineKeyboardButton(text=texts.t("BLOCK_FOREVER", "🚫 Блок навсегда"), callback_data=f"admin_block_user_perm_ticket_{ticket_id}") - ]) - keyboard.append([ - InlineKeyboardButton(text=texts.t("BLOCK_BY_TIME", "⏳ Блокировка по времени"), callback_data=f"admin_block_user_ticket_{ticket_id}") - ]) + # Блок-контролы: когда не заблокирован — показать два варианта, когда заблокирован — только "Разблокировать" + if is_user_blocked: + keyboard.append([ + InlineKeyboardButton(text=texts.t("UNBLOCK", "✅ Разблокировать"), callback_data=f"admin_unblock_user_ticket_{ticket_id}") + ]) + else: + keyboard.append([ + InlineKeyboardButton(text=texts.t("BLOCK_FOREVER", "🚫 Заблокировать"), callback_data=f"admin_block_user_perm_ticket_{ticket_id}"), + InlineKeyboardButton(text=texts.t("BLOCK_BY_TIME", "⏳ Блок по времени"), callback_data=f"admin_block_user_ticket_{ticket_id}") + ]) keyboard.append([ InlineKeyboardButton(text=texts.BACK, callback_data="admin_tickets") diff --git a/app/localization/locales/en.json b/app/localization/locales/en.json index 49417771..cdd32cf7 100644 --- a/app/localization/locales/en.json +++ b/app/localization/locales/en.json @@ -19,6 +19,16 @@ "CONFIRM": "✅ Confirm", "CONFIRM_CHANGE_BUTTON": "✅ Confirm change", "CONNECT_BUTTON": "🔗 Connect", + "HAPP_DOWNLOAD_BUTTON": "⬇️ Download Happ", + "HAPP_DOWNLOAD_PROMPT": "📥 Download Happ\nChoose your device:", + "HAPP_PLATFORM_IOS": "🍎 iOS", + "HAPP_PLATFORM_ANDROID": "🤖 Android", + "HAPP_PLATFORM_MACOS": "🖥️ Mac OS", + "HAPP_PLATFORM_WINDOWS": "💻 Windows", + "HAPP_PLATFORM_PC": "💻 PC", + "HAPP_DOWNLOAD_LINK_MESSAGE": "⬇️ Download Happ for {platform}:", + "HAPP_DOWNLOAD_LINK_NOT_SET": "❌ Download link for this device is not configured", + "HAPP_DOWNLOAD_OPEN_LINK": "🔗 Open link", "CONTINUE": "➡️ Continue", "CONTINUE_BUTTON": "➡️ Continue", "COPY_SUBSCRIPTION_LINK": "📋 Copy subscription link", @@ -37,6 +47,7 @@ "GO_TO_BALANCE_TOP_UP": "💳 Go to balance top up", "RETURN_TO_SUBSCRIPTION_CHECKOUT": "⬅️ Return to subscription checkout", "INSUFFICIENT_BALANCE": "❌ Insufficient balance.\n\nTop up {amount} and try again.", + "ADDON_INSUFFICIENT_FUNDS_MESSAGE": "⚠️ Insufficient funds\n\nService price: {required}\nBalance: {balance}\nMissing: {missing}\n\nChoose a top-up method. The amount will be filled in automatically.", "LANGUAGE_SELECTED": "🌐 Interface language set: English", "LOADING": "⏳ Loading...", "MAIN_MENU": "👤 {user_name}\n\n📱 Subscription: {subscription_status}\n\nChoose an option:\n", @@ -126,7 +137,16 @@ "ADMIN_PROMO_GROUPS": "💳 Promo groups", "ADMIN_PROMO_GROUPS_TITLE": "💳 Promo groups", "ADMIN_PROMO_GROUPS_SUMMARY": "Groups total: {count}\nMembers total: {members}", - "ADMIN_PROMO_GROUPS_DISCOUNTS": "Discounts — servers: {servers}%, traffic: {traffic}%, devices: {devices}%", + "ADMIN_PROMO_GROUP_DISCOUNTS_HEADER": "💸 Promo group discounts:", + "ADMIN_PROMO_GROUP_DISCOUNT_LINE_SERVERS": "• Servers: {percent}%", + "ADMIN_PROMO_GROUP_DISCOUNT_LINE_TRAFFIC": "• Traffic: {percent}%", + "ADMIN_PROMO_GROUP_DISCOUNT_LINE_DEVICES": "• Devices: {percent}%", + "ADMIN_PROMO_GROUP_ADDON_DISCOUNT_ENABLED": "🧩 Add-on discounts: enabled", + "ADMIN_PROMO_GROUP_ADDON_DISCOUNT_DISABLED": "🧩 Add-on discounts: disabled", + "ADMIN_PROMO_GROUP_TOGGLE_ADDON_DISCOUNT_ENABLE": "🧩 Enable add-on discounts", + "ADMIN_PROMO_GROUP_TOGGLE_ADDON_DISCOUNT_DISABLE": "🧩 Disable add-on discounts", + "ADMIN_PROMO_GROUP_ADDON_DISCOUNT_UPDATED_ENABLED": "🧩 Add-on purchase discounts are enabled.", + "ADMIN_PROMO_GROUP_ADDON_DISCOUNT_UPDATED_DISABLED": "🧩 Add-on purchase discounts are disabled.", "ADMIN_PROMO_GROUPS_DEFAULT_LABEL": " (default)", "ADMIN_PROMO_GROUPS_MEMBERS_COUNT": "Members: {count}", "ADMIN_PROMO_GROUPS_EMPTY": "No promo groups found.", @@ -403,4 +423,5 @@ "PAYMENT_METHOD_SUPPORT_NAME": "🛠️ Support team", "PAYMENT_METHOD_SUPPORT_DESCRIPTION": "other options", "PAYMENT_METHODS_UNAVAILABLE_ALERT": "⚠️ Automated payment methods are temporarily unavailable. Contact support to top up your balance." + } diff --git a/app/localization/locales/ru.json b/app/localization/locales/ru.json index 1dc7de10..290685ad 100644 --- a/app/localization/locales/ru.json +++ b/app/localization/locales/ru.json @@ -14,7 +14,16 @@ "ADMIN_PROMO_GROUPS": "💳 Промогруппы", "ADMIN_PROMO_GROUPS_TITLE": "💳 Промогруппы", "ADMIN_PROMO_GROUPS_SUMMARY": "Всего групп: {count}\nВсего участников: {members}", - "ADMIN_PROMO_GROUPS_DISCOUNTS": "Скидки — серверы: {servers}%, трафик: {traffic}%, устройства: {devices}%", + "ADMIN_PROMO_GROUP_DISCOUNTS_HEADER": "💸 Скидки промогруппы:", + "ADMIN_PROMO_GROUP_DISCOUNT_LINE_SERVERS": "• Серверы: {percent}%", + "ADMIN_PROMO_GROUP_DISCOUNT_LINE_TRAFFIC": "• Трафик: {percent}%", + "ADMIN_PROMO_GROUP_DISCOUNT_LINE_DEVICES": "• Устройства: {percent}%", + "ADMIN_PROMO_GROUP_ADDON_DISCOUNT_ENABLED": "🧩 Скидки на доп. услуги: включены", + "ADMIN_PROMO_GROUP_ADDON_DISCOUNT_DISABLED": "🧩 Скидки на доп. услуги: отключены", + "ADMIN_PROMO_GROUP_TOGGLE_ADDON_DISCOUNT_ENABLE": "🧩 Включить скидки на доп. услуги", + "ADMIN_PROMO_GROUP_TOGGLE_ADDON_DISCOUNT_DISABLE": "🧩 Отключить скидки на доп. услуги", + "ADMIN_PROMO_GROUP_ADDON_DISCOUNT_UPDATED_ENABLED": "🧩 Скидки на докупку доп. услуг включены.", + "ADMIN_PROMO_GROUP_ADDON_DISCOUNT_UPDATED_DISABLED": "🧩 Скидки на докупку доп. услуг отключены.", "ADMIN_PROMO_GROUPS_DEFAULT_LABEL": " (базовая)", "ADMIN_PROMO_GROUPS_MEMBERS_COUNT": "Участников: {count}", "ADMIN_PROMO_GROUPS_EMPTY": "Промогруппы не найдены.", @@ -55,6 +64,8 @@ "ADMIN_PROMO_GROUP_DELETED": "Промогруппа «{name}» удалена.", "ADMIN_SUBSCRIPTIONS": "📱 Подписки", "ADMIN_USERS": "👥 Пользователи", + "ADMIN_TICKETS_TITLE_OPEN": "🎫 Открытые тикеты поддержки:", + "ADMIN_TICKETS_TITLE_CLOSED": "🎫 Закрытые тикеты поддержки:", "AUTOPAY_BUTTON": "💳 Автоплатёж", "AUTOPAY_DISABLED_TEXT": "Отключен - не забудьте продлить вручную!", "AUTOPAY_ENABLED_TEXT": "Включен - подписка продлится автоматически", @@ -97,6 +108,16 @@ "CONFIRM": "✅ Подтвердить", "CONFIRM_CHANGE_BUTTON": "✅ Подтвердить изменение", "CONNECT_BUTTON": "🔗 Подключиться", + "HAPP_DOWNLOAD_BUTTON": "⬇️ Скачать Happ", + "HAPP_DOWNLOAD_PROMPT": "📥 Скачать Happ\nВыберите ваше устройство:", + "HAPP_PLATFORM_IOS": "🍎 iOS", + "HAPP_PLATFORM_ANDROID": "🤖 Android", + "HAPP_PLATFORM_MACOS": "🖥️ Mac OS", + "HAPP_PLATFORM_WINDOWS": "💻 Windows", + "HAPP_PLATFORM_PC": "💻 ПК", + "HAPP_DOWNLOAD_LINK_MESSAGE": "⬇️ Скачайте Happ для {platform}:", + "HAPP_DOWNLOAD_LINK_NOT_SET": "❌ Ссылка для этого устройства не настроена", + "HAPP_DOWNLOAD_OPEN_LINK": "🔗 Открыть ссылку", "CONTACT_SUPPORT": "💬 Написать в поддержку", "CONTINUE": "➡️ Продолжить", "CONTINUE_BUTTON": "✅ Продолжить", @@ -121,6 +142,7 @@ "GO_TO_BALANCE_TOP_UP": "💳 Перейти к пополнению баланса", "RETURN_TO_SUBSCRIPTION_CHECKOUT": "⬅️ Вернуться к оформлению подписки", "INSUFFICIENT_BALANCE": "❌ Недостаточно средств на балансе. \n \n Пополните баланс на {amount} и попробуйте снова.\n ", + "ADDON_INSUFFICIENT_FUNDS_MESSAGE": "⚠️ Недостаточно средств\n\nСтоимость услуги: {required}\nНа балансе: {balance}\nНе хватает: {missing}\n\nВыберите способ пополнения. Сумма подставится автоматически.", "INVALID_AMOUNT": "❌ Неверная сумма", "LANGUAGE_SELECTED": "🌐 Язык интерфейса установлен: Русский", "LOADING": "⏳ Загрузка...", @@ -403,4 +425,5 @@ "PAYMENT_METHOD_SUPPORT_NAME": "🛠️ Через поддержку", "PAYMENT_METHOD_SUPPORT_DESCRIPTION": "другие способы", "PAYMENT_METHODS_UNAVAILABLE_ALERT": "⚠️ В данный момент автоматические способы оплаты временно недоступны. Для пополнения баланса обратитесь в техподдержку." + } diff --git a/app/middlewares/channel_checker.py b/app/middlewares/channel_checker.py index bf6669b5..65026540 100644 --- a/app/middlewares/channel_checker.py +++ b/app/middlewares/channel_checker.py @@ -7,10 +7,15 @@ from aiogram.types import TelegramObject, Update, Message, CallbackQuery from aiogram.enums import ChatMemberStatus from app.config import settings +from app.database.database import get_db +from app.database.crud.subscription import deactivate_subscription +from app.database.crud.user import get_user_by_telegram_id +from app.database.models import SubscriptionStatus from app.keyboards.inline import get_channel_sub_keyboard from app.localization.loader import DEFAULT_LANGUAGE from app.localization.texts import get_texts from app.utils.check_reg_process import is_registration_process +from app.services.subscription_service import SubscriptionService logger = logging.getLogger(__name__) @@ -49,6 +54,16 @@ class ChannelCheckerMiddleware(BaseMiddleware): return await handler(event, data) + # Админам разрешаем пропускать проверку подписки, чтобы не блокировать + # работу панели управления даже при отсутствии подписки. Важно делать + # это до обращения к состоянию, чтобы не выполнять лишние операции. + if settings.is_admin(telegram_id): + logger.debug( + "✅ Пользователь %s является администратором — пропускаем проверку подписки", + telegram_id, + ) + return await handler(event, data) + state: FSMContext = data.get('state') current_state = None @@ -85,11 +100,14 @@ class ChannelCheckerMiddleware(BaseMiddleware): return await handler(event, data) elif member.status in self.BAD_MEMBER_STATUS: logger.info(f"❌ Пользователь {telegram_id} не подписан на канал (статус: {member.status})") - + + if telegram_id: + await self._deactivate_trial_subscription(telegram_id) + if isinstance(event, CallbackQuery) and event.data == "sub_channel_check": await event.answer("❌ Вы еще не подписались на канал! Подпишитесь и попробуйте снова.", show_alert=True) - return - + return + return await self._deny_message(event, bot, channel_link) else: logger.warning(f"⚠️ Неожиданный статус пользователя {telegram_id}: {member.status}") @@ -110,6 +128,53 @@ class ChannelCheckerMiddleware(BaseMiddleware): logger.error(f"❌ Неожиданная ошибка при проверке подписки: {e}") return await handler(event, data) + async def _deactivate_trial_subscription(self, telegram_id: int) -> None: + 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 (not subscription.is_trial or + subscription.status != SubscriptionStatus.ACTIVE.value): + logger.debug( + "ℹ️ Подписка пользователя %s не требует деактивации (trial=%s, status=%s)", + telegram_id, + subscription.is_trial, + subscription.status, + ) + break + + await deactivate_subscription(db, subscription) + logger.info( + "🚫 Триальная подписка пользователя %s отключена после отписки от канала", + telegram_id, + ) + + if user.remnawave_uuid: + service = SubscriptionService() + try: + await service.disable_remnawave_user(user.remnawave_uuid) + except Exception as api_error: + logger.error( + "❌ Не удалось отключить пользователя RemnaWave %s: %s", + user.remnawave_uuid, + api_error, + ) + except Exception as db_error: + logger.error( + "❌ Ошибка деактивации подписки пользователя %s после отписки: %s", + telegram_id, + db_error, + ) + finally: + break + @staticmethod async def _deny_message(event: TelegramObject, bot: Bot, channel_link: str): logger.debug("🚫 Отправляем сообщение о необходимости подписки") diff --git a/app/services/admin_notification_service.py b/app/services/admin_notification_service.py index 24deb059..1882e35b 100644 --- a/app/services/admin_notification_service.py +++ b/app/services/admin_notification_service.py @@ -3,10 +3,11 @@ from typing import Optional, Dict, Any, List from datetime import datetime from aiogram import Bot, types from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError +from sqlalchemy import select, func from sqlalchemy.ext.asyncio import AsyncSession from app.config import settings -from app.database.models import User, Subscription, Transaction +from app.database.models import User, Subscription, Transaction, TransactionType from app.database.crud.user import get_user_by_id logger = logging.getLogger(__name__) @@ -215,7 +216,17 @@ class AdminNotificationService: return False try: - topup_status = "🆕 Первое пополнение" if not user.has_made_first_topup else "🔄 Пополнение" + deposit_count_result = await db.execute( + select(func.count()) + .select_from(Transaction) + .where( + Transaction.user_id == user.id, + Transaction.type == TransactionType.DEPOSIT.value, + Transaction.is_completed.is_(True) + ) + ) + deposit_count = deposit_count_result.scalar_one() or 0 + topup_status = "🆕 Первое пополнение" if deposit_count <= 1 else "🔄 Пополнение" payment_method = self._get_payment_method_display(transaction.payment_method) balance_change = user.balance_kopeks - old_balance referrer_info = await self._get_referrer_info(db, user.referred_by_id) @@ -341,6 +352,8 @@ class AdminNotificationService: 'telegram_stars': '⭐ Telegram Stars', 'yookassa': '💳 YooKassa (карта)', 'tribute': '💎 Tribute (карта)', + 'mulenpay': '💳 Mulen Pay (карта)', + 'pal24': '💳 PayPalych (карта)', 'manual': '🛠️ Вручную (админ)', 'balance': '💰 С баланса' } @@ -818,7 +831,13 @@ class AdminNotificationService: """Публичный метод для отправки уведомлений по тикетам в админ-топик. Учитывает настройки включенности в settings. """ - if not self._is_enabled(): + # Respect runtime toggle for admin ticket notifications + try: + from app.services.support_settings_service import SupportSettingsService + runtime_enabled = SupportSettingsService.get_admin_ticket_notifications_enabled() + except Exception: + runtime_enabled = True + if not (self._is_enabled() and runtime_enabled): return False return await self._send_message(text, reply_markup=keyboard, ticket_event=True) diff --git a/app/services/backup_service.py b/app/services/backup_service.py index 8c5f6b8d..a1fbee35 100644 --- a/app/services/backup_service.py +++ b/app/services/backup_service.py @@ -21,7 +21,9 @@ from app.database.models import ( ReferralEarning, Squad, ServiceRule, SystemSetting, MonitoringLog, SubscriptionConversion, SentNotification, BroadcastHistory, ServerSquad, SubscriptionServer, UserMessage, YooKassaPayment, - CryptoBotPayment, WelcomeText, Base + CryptoBotPayment, WelcomeText, Base, PromoGroup, AdvertisingCampaign, + AdvertisingCampaignRegistration, SupportAuditLog, Ticket, TicketMessage, + MulenPayPayment, Pal24Payment ) logger = logging.getLogger(__name__) @@ -61,29 +63,34 @@ class BackupService: self._settings = self._load_settings() self.backup_models_ordered = [ - ServiceRule, SystemSetting, + ServiceRule, Squad, - PromoCode, ServerSquad, - - User, - - WelcomeText, + PromoGroup, + User, + PromoCode, + WelcomeText, + UserMessage, Subscription, + SubscriptionServer, + SubscriptionConversion, Transaction, YooKassaPayment, CryptoBotPayment, + MulenPayPayment, + Pal24Payment, PromoCodeUse, ReferralEarning, - SubscriptionConversion, + SentNotification, BroadcastHistory, - UserMessage, - - SentNotification, - SubscriptionServer, + AdvertisingCampaign, + AdvertisingCampaignRegistration, + Ticket, + TicketMessage, + SupportAuditLog, ] - + if self._settings.include_logs: self.backup_models_ordered.append(MonitoringLog) @@ -329,60 +336,47 @@ class BackupService: await self._clear_database_tables(db) models_by_table = {model.__tablename__: model for model in self.backup_models_ordered} - - await self._restore_users_without_referrals(db, backup_data, models_by_table) - - for model in self.backup_models_ordered: - table_name = model.__tablename__ - - if table_name == "users": + + pre_restore_tables = {"promo_groups"} + for table_name in pre_restore_tables: + model = models_by_table.get(table_name) + if not model: continue - + records = backup_data.get(table_name, []) if not records: continue - + logger.info(f"🔥 Восстанавливаем таблицу {table_name} ({len(records)} записей)") - - for record_data in records: - try: - processed_data = self._process_record_data(record_data, model, table_name) - - primary_key_col = self._get_primary_key_column(model) - - if primary_key_col and primary_key_col in processed_data: - existing_record = await db.execute( - select(model).where( - getattr(model, primary_key_col) == processed_data[primary_key_col] - ) - ) - existing = existing_record.scalar_one_or_none() - - if existing and not clear_existing: - for key, value in processed_data.items(): - if key != primary_key_col: - setattr(existing, key, value) - logger.debug(f"Обновлена существующая запись {primary_key_col}={processed_data[primary_key_col]} в {table_name}") - else: - instance = model(**processed_data) - db.add(instance) - else: - instance = model(**processed_data) - db.add(instance) - - restored_records += 1 - - except Exception as e: - logger.error(f"Ошибка восстановления записи в {table_name}: {e}") - logger.error(f"Проблемные данные: {record_data}") - await db.rollback() - raise e - - restored_tables += 1 - logger.info(f"✅ Таблица {table_name} восстановлена") - + restored = await self._restore_table_records(db, model, table_name, records, clear_existing) + restored_records += restored + + if restored: + restored_tables += 1 + logger.info(f"✅ Таблица {table_name} восстановлена") + + await self._restore_users_without_referrals(db, backup_data, models_by_table) + + for model in self.backup_models_ordered: + table_name = model.__tablename__ + + if table_name == "users" or table_name in pre_restore_tables: + continue + + records = backup_data.get(table_name, []) + if not records: + continue + + logger.info(f"🔥 Восстанавливаем таблицу {table_name} ({len(records)} записей)") + restored = await self._restore_table_records(db, model, table_name, records, clear_existing) + restored_records += restored + + if restored: + restored_tables += 1 + logger.info(f"✅ Таблица {table_name} восстановлена") + await self._update_user_referrals(db, backup_data) - + await db.commit() break @@ -549,14 +543,64 @@ class BackupService: return col.name return None + async def _restore_table_records( + self, + db: AsyncSession, + model, + table_name: str, + records: List[Dict[str, Any]], + clear_existing: bool + ) -> int: + restored_count = 0 + + for record_data in records: + try: + processed_data = self._process_record_data(record_data, model, table_name) + + primary_key_col = self._get_primary_key_column(model) + + if primary_key_col and primary_key_col in processed_data: + existing_record = await db.execute( + select(model).where( + getattr(model, primary_key_col) == processed_data[primary_key_col] + ) + ) + existing = existing_record.scalar_one_or_none() + + if existing and not clear_existing: + for key, value in processed_data.items(): + if key != primary_key_col: + setattr(existing, key, value) + else: + instance = model(**processed_data) + db.add(instance) + else: + instance = model(**processed_data) + db.add(instance) + + restored_count += 1 + + except Exception as e: + logger.error(f"Ошибка восстановления записи в {table_name}: {e}") + logger.error(f"Проблемные данные: {record_data}") + await db.rollback() + raise e + + return restored_count + async def _clear_database_tables(self, db: AsyncSession): tables_order = [ - "subscription_servers", "sent_notifications", - "user_messages", "broadcast_history", "subscription_conversions", - "referral_earnings", "promocode_uses", "transactions", - "yookassa_payments", "cryptobot_payments", "welcome_texts", - "subscriptions", "users", "promocodes", "server_squads", - "squads", "service_rules", "system_settings", "monitoring_logs" + "ticket_messages", "tickets", "support_audit_logs", + "advertising_campaign_registrations", "advertising_campaigns", + "subscription_servers", "sent_notifications", + "user_messages", "broadcast_history", "subscription_conversions", + "referral_earnings", "promocode_uses", + "yookassa_payments", "cryptobot_payments", + "mulenpay_payments", "pal24_payments", + "transactions", "welcome_texts", "subscriptions", + "promocodes", "users", "promo_groups", + "server_squads", "squads", "service_rules", + "system_settings", "monitoring_logs" ] for table_name in tables_order: diff --git a/app/services/monitoring_service.py b/app/services/monitoring_service.py index 2d0b96c7..042f67b8 100644 --- a/app/services/monitoring_service.py +++ b/app/services/monitoring_service.py @@ -1,38 +1,58 @@ import asyncio import logging from datetime import datetime, timedelta +from pathlib import Path from typing import Dict, List, Any, Optional, Set + +from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError +from aiogram.enums import ChatMemberStatus +from aiogram.types import FSInputFile +from sqlalchemy import select, and_, or_ from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy import select, and_ from sqlalchemy.orm import selectinload from app.config import settings from app.database.database import get_db -from app.database.crud.subscription import ( - get_expired_subscriptions, get_expiring_subscriptions, - get_subscriptions_for_autopay, deactivate_subscription, - extend_subscription -) -from app.database.crud.user import ( - get_user_by_id, get_inactive_users, delete_user, - subtract_user_balance +from app.database.crud.discount_offer import ( + deactivate_expired_offers, + upsert_discount_offer, ) from app.database.crud.notification import ( notification_sent, record_notification, ) -from app.database.models import MonitoringLog, SubscriptionStatus, Subscription, User -from app.services.subscription_service import SubscriptionService -from app.services.payment_service import PaymentService +from app.database.crud.subscription import ( + deactivate_subscription, + extend_subscription, + get_expired_subscriptions, + get_expiring_subscriptions, + get_subscriptions_for_autopay, +) +from app.database.crud.user import ( + delete_user, + get_inactive_users, + get_user_by_id, + subtract_user_balance, +) +from app.database.models import MonitoringLog, SubscriptionStatus, Subscription, User, Ticket, TicketStatus from app.localization.texts import get_texts +from app.services.notification_settings_service import NotificationSettingsService +from app.services.payment_service import PaymentService +from app.services.subscription_service import SubscriptionService from app.external.remnawave_api import ( - RemnaWaveUser, UserStatus, TrafficLimitStrategy, RemnaWaveAPIError + RemnaWaveAPIError, + RemnaWaveUser, + TrafficLimitStrategy, + UserStatus, ) logger = logging.getLogger(__name__) +LOGO_PATH = Path(settings.LOGO_FILE) + + class MonitoringService: def __init__(self, bot=None): @@ -40,8 +60,82 @@ class MonitoringService: self.subscription_service = SubscriptionService() self.payment_service = PaymentService() self.bot = bot - self._notified_users: Set[str] = set() + self._notified_users: Set[str] = set() self._last_cleanup = datetime.utcnow() + self._sla_task = None + + async def _send_message_with_logo( + self, + chat_id: int, + text: str, + reply_markup=None, + parse_mode: Optional[str] = "HTML", + ): + """Отправляет сообщение, добавляя логотип при необходимости.""" + if not self.bot: + raise RuntimeError("Bot instance is not available") + + if ( + settings.ENABLE_LOGO_MODE + and LOGO_PATH.exists() + and (text is None or len(text) <= 1000) + ): + try: + return await self.bot.send_photo( + chat_id=chat_id, + photo=FSInputFile(LOGO_PATH), + caption=text, + reply_markup=reply_markup, + parse_mode=parse_mode, + ) + except TelegramBadRequest as exc: + logger.warning( + "Не удалось отправить сообщение с логотипом пользователю %s: %s. " + "Отправляем текстовое сообщение.", + chat_id, + exc, + ) + + return await self.bot.send_message( + chat_id=chat_id, + text=text, + reply_markup=reply_markup, + parse_mode=parse_mode, + ) + + @staticmethod + def _is_unreachable_error(error: TelegramBadRequest) -> bool: + message = str(error).lower() + unreachable_markers = ( + "chat not found", + "user is deactivated", + "bot was blocked by the user", + "bot can't initiate conversation", + "can't initiate conversation", + "user not found", + "peer id invalid", + ) + return any(marker in message for marker in unreachable_markers) + + def _handle_unreachable_user(self, user: User, error: Exception, context: str) -> bool: + if isinstance(error, TelegramForbiddenError): + logger.warning( + "⚠️ Пользователь %s недоступен (%s): бот заблокирован", + user.telegram_id, + context, + ) + return True + + if isinstance(error, TelegramBadRequest) and self._is_unreachable_error(error): + logger.warning( + "⚠️ Пользователь %s недоступен (%s): %s", + user.telegram_id, + context, + error, + ) + return True + + return False async def start_monitoring(self): if self.is_running: @@ -50,6 +144,12 @@ class MonitoringService: self.is_running = True logger.info("🔄 Запуск службы мониторинга") + # Start dedicated SLA loop with its own interval for timely 5-min checks + try: + if not self._sla_task or self._sla_task.done(): + self._sla_task = asyncio.create_task(self._sla_loop()) + except Exception as e: + logger.error(f"Не удалось запустить SLA-мониторинг: {e}") while self.is_running: try: @@ -63,15 +163,27 @@ class MonitoringService: def stop_monitoring(self): self.is_running = False logger.info("ℹ️ Мониторинг остановлен") + try: + if self._sla_task and not self._sla_task.done(): + self._sla_task.cancel() + except Exception: + pass async def _monitoring_cycle(self): async for db in get_db(): try: await self._cleanup_notification_cache() - + + expired_offers = await deactivate_expired_offers(db) + if expired_offers: + logger.info(f"🧹 Деактивировано {expired_offers} просроченных скидочных предложений") + await self._check_expired_subscriptions(db) await self._check_expiring_subscriptions(db) - await self._check_trial_expiring_soon(db) + await self._check_trial_expiring_soon(db) + await self._check_trial_inactivity_notifications(db) + await self._check_trial_channel_subscriptions(db) + await self._check_expired_subscription_followups(db) await self._process_autopayments(db) await self._cleanup_inactive_users(db) await self._sync_with_remnawave(db) @@ -169,6 +281,7 @@ class MonitoringService: ) subscription.subscription_url = updated_user.subscription_url + subscription.subscription_crypto_link = updated_user.happ_crypto_link await db.commit() status_text = "активным" if is_active else "истёкшим" @@ -238,7 +351,7 @@ class MonitoringService: async def _check_trial_expiring_soon(self, db: AsyncSession): try: threshold_time = datetime.utcnow() + timedelta(hours=2) - + result = await db.execute( select(Subscription) .options(selectinload(Subscription.user)) @@ -276,7 +389,341 @@ class MonitoringService: except Exception as e: logger.error(f"Ошибка проверки истекающих тестовых подписок: {e}") - + + async def _check_trial_inactivity_notifications(self, db: AsyncSession): + if not NotificationSettingsService.are_notifications_globally_enabled(): + return + if not self.bot: + return + + try: + now = datetime.utcnow() + one_hour_ago = now - timedelta(hours=1) + + result = await db.execute( + select(Subscription) + .options(selectinload(Subscription.user)) + .where( + and_( + Subscription.status == SubscriptionStatus.ACTIVE.value, + Subscription.is_trial == True, + Subscription.start_date.isnot(None), + Subscription.start_date <= one_hour_ago, + Subscription.end_date > now, + ) + ) + ) + + subscriptions = result.scalars().all() + sent_1h = 0 + sent_24h = 0 + + for subscription in subscriptions: + user = subscription.user + if not user: + continue + + if (subscription.traffic_used_gb or 0) > 0: + continue + + start_date = subscription.start_date + if not start_date: + continue + + time_since_start = now - start_date + + if (NotificationSettingsService.is_trial_inactive_1h_enabled() + and timedelta(hours=1) <= time_since_start < timedelta(hours=24)): + if not await notification_sent(db, user.id, subscription.id, "trial_inactive_1h"): + success = await self._send_trial_inactive_notification(user, subscription, 1) + if success: + await record_notification(db, user.id, subscription.id, "trial_inactive_1h") + sent_1h += 1 + + if NotificationSettingsService.is_trial_inactive_24h_enabled() and time_since_start >= timedelta(hours=24): + if not await notification_sent(db, user.id, subscription.id, "trial_inactive_24h"): + success = await self._send_trial_inactive_notification(user, subscription, 24) + if success: + await record_notification(db, user.id, subscription.id, "trial_inactive_24h") + sent_24h += 1 + + if sent_1h or sent_24h: + await self._log_monitoring_event( + db, + "trial_inactivity_notifications", + f"Отправлено {sent_1h} уведомлений спустя 1 час и {sent_24h} спустя 24 часа", + {"sent_1h": sent_1h, "sent_24h": sent_24h}, + ) + + except Exception as e: + logger.error(f"Ошибка проверки неактивных тестовых подписок: {e}") + + async def _check_trial_channel_subscriptions(self, db: AsyncSession): + if not settings.CHANNEL_IS_REQUIRED_SUB: + return + + channel_id = settings.CHANNEL_SUB_ID + if not channel_id: + return + + if not self.bot: + logger.debug("⚠️ Пропускаем проверку подписки на канал — бот недоступен") + return + + try: + now = datetime.utcnow() + result = await db.execute( + select(Subscription) + .options(selectinload(Subscription.user)) + .where( + and_( + Subscription.is_trial.is_(True), + Subscription.end_date > now, + Subscription.status.in_( + [ + SubscriptionStatus.ACTIVE.value, + SubscriptionStatus.DISABLED.value, + ] + ), + ) + ) + ) + + subscriptions = result.scalars().all() + if not subscriptions: + return + + disabled_count = 0 + restored_count = 0 + + for subscription in subscriptions: + user = subscription.user + if not user or not user.telegram_id: + continue + + try: + member = await self.bot.get_chat_member(channel_id, user.telegram_id) + member_status = member.status + is_member = member_status in ( + ChatMemberStatus.MEMBER, + ChatMemberStatus.ADMINISTRATOR, + ChatMemberStatus.CREATOR, + ) + except TelegramForbiddenError as error: + logger.error( + "❌ Не удалось проверить подписку пользователя %s на канал %s: бот заблокирован (%s)", + user.telegram_id, + channel_id, + error, + ) + continue + except TelegramBadRequest as error: + logger.error( + "❌ Ошибка Telegram при проверке подписки пользователя %s: %s", + user.telegram_id, + error, + ) + continue + except Exception as error: + logger.error( + "❌ Неожиданная ошибка при проверке подписки пользователя %s: %s", + user.telegram_id, + error, + ) + continue + + if subscription.status == SubscriptionStatus.ACTIVE.value and not is_member: + subscription = await deactivate_subscription(db, subscription) + disabled_count += 1 + logger.info( + "🚫 Триальная подписка пользователя %s (ID %s) отключена из-за отписки от канала", + user.telegram_id, + subscription.id, + ) + + if user.remnawave_uuid: + try: + await self.subscription_service.disable_remnawave_user(user.remnawave_uuid) + except Exception as api_error: + logger.error( + "❌ Не удалось отключить пользователя RemnaWave %s: %s", + user.remnawave_uuid, + api_error, + ) + elif subscription.status == SubscriptionStatus.DISABLED.value and is_member: + subscription.status = SubscriptionStatus.ACTIVE.value + subscription.updated_at = datetime.utcnow() + await db.commit() + await db.refresh(subscription) + restored_count += 1 + + logger.info( + "✅ Триальная подписка пользователя %s (ID %s) восстановлена после повторной подписки на канал", + user.telegram_id, + subscription.id, + ) + + try: + if user.remnawave_uuid: + await self.subscription_service.update_remnawave_user(db, subscription) + else: + await self.subscription_service.create_remnawave_user(db, subscription) + except Exception as api_error: + logger.error( + "❌ Не удалось обновить RemnaWave пользователя %s: %s", + user.telegram_id, + api_error, + ) + + if disabled_count or restored_count: + await self._log_monitoring_event( + db, + "trial_channel_subscription_check", + ( + "Проверено {total} триальных подписок: отключено {disabled}, " + "восстановлено {restored}" + ).format( + total=len(subscriptions), + disabled=disabled_count, + restored=restored_count, + ), + { + "checked": len(subscriptions), + "disabled": disabled_count, + "restored": restored_count, + }, + ) + + except Exception as error: + logger.error(f"Ошибка проверки подписки на канал для триальных пользователей: {error}") + + async def _check_expired_subscription_followups(self, db: AsyncSession): + if not NotificationSettingsService.are_notifications_globally_enabled(): + return + if not self.bot: + return + + try: + now = datetime.utcnow() + + result = await db.execute( + select(Subscription) + .options(selectinload(Subscription.user)) + .where( + and_( + Subscription.is_trial == False, + Subscription.end_date <= now, + ) + ) + ) + + subscriptions = result.scalars().all() + sent_day1 = 0 + sent_wave2 = 0 + sent_wave3 = 0 + + for subscription in subscriptions: + user = subscription.user + if not user: + continue + + if subscription.end_date is None: + continue + + time_since_end = now - subscription.end_date + if time_since_end.total_seconds() < 0: + continue + + days_since = time_since_end.total_seconds() / 86400 + + # Day 1 reminder + if NotificationSettingsService.is_expired_1d_enabled() and 1 <= days_since < 2: + if not await notification_sent(db, user.id, subscription.id, "expired_1d"): + success = await self._send_expired_day1_notification(user, subscription) + if success: + await record_notification(db, user.id, subscription.id, "expired_1d") + sent_day1 += 1 + + # Second wave (2-3 days) discount + if NotificationSettingsService.is_second_wave_enabled() and 2 <= days_since < 4: + if not await notification_sent(db, user.id, subscription.id, "expired_discount_wave2"): + percent = NotificationSettingsService.get_second_wave_discount_percent() + valid_hours = NotificationSettingsService.get_second_wave_valid_hours() + bonus_amount = settings.PRICE_30_DAYS * percent // 100 + offer = await upsert_discount_offer( + db, + user_id=user.id, + subscription_id=subscription.id, + notification_type="expired_discount_wave2", + discount_percent=percent, + bonus_amount_kopeks=bonus_amount, + valid_hours=valid_hours, + ) + success = await self._send_expired_discount_notification( + user, + subscription, + percent, + offer.expires_at, + offer.id, + "second", + bonus_amount, + ) + if success: + await record_notification(db, user.id, subscription.id, "expired_discount_wave2") + sent_wave2 += 1 + + # Third wave (N days) discount + if NotificationSettingsService.is_third_wave_enabled(): + trigger_days = NotificationSettingsService.get_third_wave_trigger_days() + if trigger_days <= days_since < trigger_days + 1: + if not await notification_sent(db, user.id, subscription.id, "expired_discount_wave3"): + percent = NotificationSettingsService.get_third_wave_discount_percent() + valid_hours = NotificationSettingsService.get_third_wave_valid_hours() + bonus_amount = settings.PRICE_30_DAYS * percent // 100 + offer = await upsert_discount_offer( + db, + user_id=user.id, + subscription_id=subscription.id, + notification_type="expired_discount_wave3", + discount_percent=percent, + bonus_amount_kopeks=bonus_amount, + valid_hours=valid_hours, + ) + success = await self._send_expired_discount_notification( + user, + subscription, + percent, + offer.expires_at, + offer.id, + "third", + bonus_amount, + trigger_days=trigger_days, + ) + if success: + await record_notification(db, user.id, subscription.id, "expired_discount_wave3") + sent_wave3 += 1 + + if sent_day1 or sent_wave2 or sent_wave3: + await self._log_monitoring_event( + db, + "expired_followups_sent", + ( + "Follow-ups: 1д={0}, скидка 2-3д={1}, скидка N={2}".format( + sent_day1, + sent_wave2, + sent_wave3, + ) + ), + { + "day1": sent_day1, + "wave2": sent_wave2, + "wave3": sent_wave3, + }, + ) + + except Exception as e: + logger.error(f"Ошибка проверки напоминаний об истекшей подписке: {e}") + 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) @@ -393,17 +840,30 @@ class MonitoringService: [InlineKeyboardButton(text="💎 Купить подписку", callback_data="menu_buy")], [InlineKeyboardButton(text="💳 Пополнить баланс", callback_data="balance_topup")] ]) - - await self.bot.send_message( - user.telegram_id, - message, + + await self._send_message_with_logo( + chat_id=user.telegram_id, + text=message, parse_mode="HTML", - reply_markup=keyboard + reply_markup=keyboard, ) return True - + + except (TelegramForbiddenError, TelegramBadRequest) as exc: + if self._handle_unreachable_user(user, exc, "уведомление об истечении подписки"): + return True + logger.error( + "Ошибка Telegram API при отправке уведомления об истечении подписки пользователю %s: %s", + user.telegram_id, + exc, + ) + return False except Exception as e: - logger.error(f"Ошибка отправки уведомления об истечении подписки пользователю {user.telegram_id}: {e}") + logger.error( + "Ошибка отправки уведомления об истечении подписки пользователю %s: %s", + user.telegram_id, + e, + ) return False async def _send_subscription_expiring_notification(self, user: User, subscription: Subscription, days: int) -> bool: @@ -437,23 +897,36 @@ class MonitoringService: [InlineKeyboardButton(text="💳 Пополнить баланс", callback_data="balance_topup")], [InlineKeyboardButton(text="📱 Моя подписка", callback_data="menu_subscription")] ]) - - await self.bot.send_message( - user.telegram_id, - message, + + await self._send_message_with_logo( + chat_id=user.telegram_id, + text=message, parse_mode="HTML", - reply_markup=keyboard + reply_markup=keyboard, ) return True - + + except (TelegramForbiddenError, TelegramBadRequest) as exc: + if self._handle_unreachable_user(user, exc, "уведомление об истекающей подписке"): + return True + logger.error( + "Ошибка Telegram API при отправке уведомления об истечении подписки пользователю %s: %s", + user.telegram_id, + exc, + ) + return False except Exception as e: - logger.error(f"Ошибка отправки уведомления об истечении подписки пользователю {user.telegram_id}: {e}") + logger.error( + "Ошибка отправки уведомления об истечении подписки пользователю %s: %s", + user.telegram_id, + e, + ) return False async def _send_trial_ending_notification(self, user: User, subscription: Subscription) -> bool: try: texts = get_texts(user.language) - + message = f""" 🎁 Тестовая подписка скоро закончится! @@ -477,19 +950,213 @@ class MonitoringService: [InlineKeyboardButton(text="💎 Купить подписку", callback_data="menu_buy")], [InlineKeyboardButton(text="💰 Пополнить баланс", callback_data="balance_topup")] ]) - - await self.bot.send_message( - user.telegram_id, - message, + + await self._send_message_with_logo( + chat_id=user.telegram_id, + text=message, parse_mode="HTML", - reply_markup=keyboard + reply_markup=keyboard, ) return True - - except Exception as e: - logger.error(f"Ошибка отправки уведомления об окончании тестовой подписки пользователю {user.telegram_id}: {e}") + + except (TelegramForbiddenError, TelegramBadRequest) as exc: + if self._handle_unreachable_user(user, exc, "уведомление о завершении тестовой подписки"): + return True + logger.error( + "Ошибка Telegram API при отправке уведомления о завершении тестовой подписки пользователю %s: %s", + user.telegram_id, + exc, + ) return False - + except Exception as e: + logger.error( + "Ошибка отправки уведомления об окончании тестовой подписки пользователю %s: %s", + user.telegram_id, + e, + ) + return False + + async def _send_trial_inactive_notification(self, user: User, subscription: Subscription, hours: int) -> bool: + try: + texts = get_texts(user.language) + if hours >= 24: + template = texts.get( + "TRIAL_INACTIVE_24H", + ( + "⏳ Вы ещё не подключились к VPN\n\n" + "Прошли сутки с активации тестового периода, но трафик не зафиксирован." + "\n\nНажмите кнопку ниже, чтобы подключиться." + ), + ) + else: + template = texts.get( + "TRIAL_INACTIVE_1H", + ( + "⏳ Прошёл час, а подключения нет\n\n" + "Если возникли сложности с запуском — воспользуйтесь инструкциями." + ), + ) + + message = template.format( + price=settings.format_price(settings.PRICE_30_DAYS), + end_date=subscription.end_date.strftime("%d.%m.%Y %H:%M"), + ) + + from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton + + keyboard = InlineKeyboardMarkup(inline_keyboard=[ + [InlineKeyboardButton(text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"), callback_data="subscription_connect")], + [InlineKeyboardButton(text=texts.t("MY_SUBSCRIPTION_BUTTON", "📱 Моя подписка"), callback_data="menu_subscription")], + [InlineKeyboardButton(text=texts.t("SUPPORT_BUTTON", "🆘 Поддержка"), callback_data="menu_support")], + ]) + + await self._send_message_with_logo( + chat_id=user.telegram_id, + text=message, + parse_mode="HTML", + reply_markup=keyboard, + ) + return True + + except (TelegramForbiddenError, TelegramBadRequest) as exc: + if self._handle_unreachable_user(user, exc, "уведомление о бездействии на тесте"): + return True + logger.error( + "Ошибка Telegram API при отправке уведомления об отсутствии подключения пользователю %s: %s", + user.telegram_id, + exc, + ) + return False + except Exception as e: + logger.error( + "Ошибка отправки уведомления об отсутствии подключения пользователю %s: %s", + user.telegram_id, + e, + ) + return False + + async def _send_expired_day1_notification(self, user: User, subscription: Subscription) -> bool: + try: + texts = get_texts(user.language) + template = texts.get( + "SUBSCRIPTION_EXPIRED_1D", + ( + "⛔ Подписка закончилась\n\n" + "Доступ был отключён {end_date}. Продлите подписку, чтобы вернуться в сервис." + ), + ) + message = template.format( + end_date=subscription.end_date.strftime("%d.%m.%Y %H:%M"), + price=settings.format_price(settings.PRICE_30_DAYS), + ) + + from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton + + keyboard = InlineKeyboardMarkup(inline_keyboard=[ + [InlineKeyboardButton(text=texts.t("SUBSCRIPTION_EXTEND", "💎 Продлить подписку"), callback_data="subscription_extend")], + [InlineKeyboardButton(text=texts.t("BALANCE_TOPUP", "💳 Пополнить баланс"), callback_data="balance_topup")], + [InlineKeyboardButton(text=texts.t("SUPPORT_BUTTON", "🆘 Поддержка"), callback_data="menu_support")], + ]) + + await self._send_message_with_logo( + chat_id=user.telegram_id, + text=message, + parse_mode="HTML", + reply_markup=keyboard, + ) + return True + + except (TelegramForbiddenError, TelegramBadRequest) as exc: + if self._handle_unreachable_user(user, exc, "напоминание об истекшей подписке"): + return True + logger.error( + "Ошибка Telegram API при отправке напоминания об истекшей подписке пользователю %s: %s", + user.telegram_id, + exc, + ) + return False + except Exception as e: + logger.error( + "Ошибка отправки напоминания об истекшей подписке пользователю %s: %s", + user.telegram_id, + e, + ) + return False + + async def _send_expired_discount_notification( + self, + user: User, + subscription: Subscription, + percent: int, + expires_at: datetime, + offer_id: int, + wave: str, + bonus_amount: int, + trigger_days: int = None, + ) -> bool: + try: + texts = get_texts(user.language) + + if wave == "second": + template = texts.get( + "SUBSCRIPTION_EXPIRED_SECOND_WAVE", + ( + "🔥 Скидка {percent}% на продление\n\n" + "Нажмите «Получить скидку», и мы начислим {bonus} на баланс. " + "Предложение действует до {expires_at}." + ), + ) + else: + template = texts.get( + "SUBSCRIPTION_EXPIRED_THIRD_WAVE", + ( + "🎁 Индивидуальная скидка {percent}%\n\n" + "Прошло {trigger_days} дней без подписки — возвращайтесь, и мы добавим {bonus} на баланс. " + "Скидка действует до {expires_at}." + ), + ) + + message = template.format( + percent=percent, + bonus=settings.format_price(bonus_amount), + expires_at=expires_at.strftime("%d.%m.%Y %H:%M"), + trigger_days=trigger_days or "", + ) + + from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton + + keyboard = InlineKeyboardMarkup(inline_keyboard=[ + [InlineKeyboardButton(text="🎁 Получить скидку", callback_data=f"claim_discount_{offer_id}")], + [InlineKeyboardButton(text=texts.t("SUBSCRIPTION_EXTEND", "💎 Продлить подписку"), callback_data="subscription_extend")], + [InlineKeyboardButton(text=texts.t("BALANCE_TOPUP", "💳 Пополнить баланс"), callback_data="balance_topup")], + [InlineKeyboardButton(text=texts.t("SUPPORT_BUTTON", "🆘 Поддержка"), callback_data="menu_support")], + ]) + + await self._send_message_with_logo( + chat_id=user.telegram_id, + text=message, + parse_mode="HTML", + reply_markup=keyboard, + ) + return True + + except (TelegramForbiddenError, TelegramBadRequest) as exc: + if self._handle_unreachable_user(user, exc, "скидочное уведомление"): + return True + logger.error( + "Ошибка Telegram API при отправке скидочного уведомления пользователю %s: %s", + user.telegram_id, + exc, + ) + return False + except Exception as e: + logger.error( + "Ошибка отправки скидочного уведомления пользователю %s: %s", + user.telegram_id, + e, + ) + return False + async def _send_autopay_success_notification(self, user: User, amount: int, days: int): try: texts = get_texts(user.language) @@ -497,10 +1164,25 @@ class MonitoringService: days=days, amount=settings.format_price(amount) ) - await self.bot.send_message(user.telegram_id, message, parse_mode="HTML") + await self._send_message_with_logo( + chat_id=user.telegram_id, + text=message, + parse_mode="HTML", + ) + except (TelegramForbiddenError, TelegramBadRequest) as exc: + if not self._handle_unreachable_user(user, exc, "уведомление об успешном автоплатеже"): + logger.error( + "Ошибка Telegram API при отправке уведомления об автоплатеже пользователю %s: %s", + user.telegram_id, + exc, + ) except Exception as e: - logger.error(f"Ошибка отправки уведомления об автоплатеже пользователю {user.telegram_id}: {e}") - + logger.error( + "Ошибка отправки уведомления об автоплатеже пользователю %s: %s", + user.telegram_id, + e, + ) + async def _send_autopay_failed_notification(self, user: User, balance: int, required: int): try: texts = get_texts(user.language) @@ -516,15 +1198,26 @@ class MonitoringService: [InlineKeyboardButton(text="📱 Моя подписка", callback_data="menu_subscription")] ]) - await self.bot.send_message( - user.telegram_id, - message, + await self._send_message_with_logo( + chat_id=user.telegram_id, + text=message, parse_mode="HTML", - reply_markup=keyboard + reply_markup=keyboard, ) - + + except (TelegramForbiddenError, TelegramBadRequest) as exc: + if not self._handle_unreachable_user(user, exc, "уведомление о неудачном автоплатеже"): + logger.error( + "Ошибка Telegram API при отправке уведомления о неудачном автоплатеже пользователю %s: %s", + user.telegram_id, + exc, + ) except Exception as e: - logger.error(f"Ошибка отправки уведомления о неудачном автоплатеже пользователю {user.telegram_id}: {e}") + logger.error( + "Ошибка отправки уведомления о неудачном автоплатеже пользователю %s: %s", + user.telegram_id, + e, + ) async def _cleanup_inactive_users(self, db: AsyncSession): try: @@ -576,6 +1269,107 @@ class MonitoringService: is_success=False ) + async def _check_ticket_sla(self, db: AsyncSession): + try: + # Quick guards + # Allow runtime toggle from SupportSettingsService + try: + from app.services.support_settings_service import SupportSettingsService + sla_enabled_runtime = SupportSettingsService.get_sla_enabled() + except Exception: + sla_enabled_runtime = getattr(settings, 'SUPPORT_TICKET_SLA_ENABLED', True) + if not sla_enabled_runtime: + return + if not self.bot: + return + if not settings.is_admin_notifications_enabled(): + return + + from datetime import datetime, timedelta + try: + from app.services.support_settings_service import SupportSettingsService + sla_minutes = max(1, int(SupportSettingsService.get_sla_minutes())) + except Exception: + sla_minutes = max(1, int(getattr(settings, 'SUPPORT_TICKET_SLA_MINUTES', 5))) + cooldown_minutes = max(1, int(getattr(settings, 'SUPPORT_TICKET_SLA_REMINDER_COOLDOWN_MINUTES', 15))) + now = datetime.utcnow() + stale_before = now - timedelta(minutes=sla_minutes) + cooldown_before = now - timedelta(minutes=cooldown_minutes) + + # Tickets to remind: open, no admin reply yet after user's last message (status OPEN), stale by SLA, + # and either never reminded or cooldown passed + result = await db.execute( + select(Ticket) + .options(selectinload(Ticket.user)) + .where( + and_( + Ticket.status == TicketStatus.OPEN.value, + Ticket.updated_at <= stale_before, + or_(Ticket.last_sla_reminder_at.is_(None), Ticket.last_sla_reminder_at <= cooldown_before), + ) + ) + ) + tickets = result.scalars().all() + if not tickets: + return + + from app.services.admin_notification_service import AdminNotificationService + + reminders_sent = 0 + service = AdminNotificationService(self.bot) + + for ticket in tickets: + try: + waited_minutes = max(0, int((now - ticket.updated_at).total_seconds() // 60)) + title = (ticket.title or '').strip() + if len(title) > 60: + title = title[:57] + '...' + + text = ( + f"⏰ Ожидание ответа на тикет превышено\n\n" + f"🆔 ID: {ticket.id}\n" + f"👤 User ID: {ticket.user_id}\n" + f"📝 Заголовок: {title or '—'}\n" + f"⏱️ Ожидает ответа: {waited_minutes} мин\n" + ) + + sent = await service.send_ticket_event_notification(text) + if sent: + ticket.last_sla_reminder_at = now + reminders_sent += 1 + # commit after each to persist timestamp and avoid duplicate reminders on crash + await db.commit() + except Exception as notify_error: + logger.error(f"Ошибка отправки SLA-уведомления по тикету {ticket.id}: {notify_error}") + + if reminders_sent > 0: + await self._log_monitoring_event( + db, + "ticket_sla_reminders_sent", + f"Отправлено {reminders_sent} SLA-напоминаний по тикетам", + {"count": reminders_sent}, + ) + except Exception as e: + logger.error(f"Ошибка проверки SLA тикетов: {e}") + + async def _sla_loop(self): + try: + interval_seconds = max(10, int(getattr(settings, 'SUPPORT_TICKET_SLA_CHECK_INTERVAL_SECONDS', 60))) + except Exception: + interval_seconds = 60 + while self.is_running: + try: + async for db in get_db(): + try: + await self._check_ticket_sla(db) + finally: + break + except asyncio.CancelledError: + break + except Exception as e: + logger.error(f"Ошибка в SLA-цикле: {e}") + await asyncio.sleep(interval_seconds) + async def _log_monitoring_event( self, db: AsyncSession, diff --git a/app/services/mulenpay_service.py b/app/services/mulenpay_service.py new file mode 100644 index 00000000..afcb44d9 --- /dev/null +++ b/app/services/mulenpay_service.py @@ -0,0 +1,126 @@ +import hashlib +import logging +from typing import Optional, Dict, Any + +import aiohttp + +from app.config import settings + +logger = logging.getLogger(__name__) + + +class MulenPayService: + """Интеграция с Mulen Pay API.""" + + def __init__(self) -> None: + self.api_key = settings.MULENPAY_API_KEY + self.shop_id = settings.MULENPAY_SHOP_ID + self.secret_key = settings.MULENPAY_SECRET_KEY + self.base_url = settings.MULENPAY_BASE_URL.rstrip("/") + + @property + def is_configured(self) -> bool: + return bool( + settings.is_mulenpay_enabled() + and self.api_key + and self.shop_id + and self.secret_key + ) + + async def _request( + self, + method: str, + endpoint: str, + *, + json_data: Optional[Dict[str, Any]] = None, + params: Optional[Dict[str, Any]] = None, + ) -> Optional[Dict[str, Any]]: + if not self.is_configured: + logger.error("MulenPay service is not configured") + return None + + url = f"{self.base_url}{endpoint}" + headers = { + "Authorization": f"Bearer {self.api_key}", + "Content-Type": "application/json", + } + + try: + timeout = aiohttp.ClientTimeout(total=30) + async with aiohttp.ClientSession(timeout=timeout) as session: + async with session.request( + method, + url, + headers=headers, + json=json_data, + params=params, + ) as response: + data = await response.json(content_type=None) + + if response.status >= 400: + logger.error( + "MulenPay API error %s %s: %s", response.status, endpoint, data + ) + return None + + return data + except aiohttp.ClientError as error: + logger.error("MulenPay API request error: %s", error) + return None + except Exception as error: # pragma: no cover - safety + logger.error("Unexpected MulenPay error: %s", error, exc_info=True) + return None + + @staticmethod + def _format_amount(amount_kopeks: int) -> str: + return f"{amount_kopeks / 100:.2f}" + + def _build_signature(self, currency: str, amount_str: str) -> str: + raw_string = f"{currency}{amount_str}{self.shop_id}{self.secret_key}".encode() + return hashlib.sha1(raw_string).hexdigest() + + async def create_payment( + self, + *, + amount_kopeks: int, + description: str, + uuid: str, + items: list, + language: str = "ru", + subscribe: Optional[str] = None, + hold_time: Optional[int] = None, + website_url: Optional[str] = None, + ) -> Optional[Dict[str, Any]]: + if not self.is_configured: + logger.error("MulenPay service is not configured") + return None + + amount_str = self._format_amount(amount_kopeks) + currency = "rub" + payload = { + "currency": currency, + "amount": amount_str, + "uuid": uuid, + "shopId": self.shop_id, + "description": description, + "items": items, + "language": language, + "sign": self._build_signature(currency, amount_str), + } + + if subscribe: + payload["subscribe"] = subscribe + if hold_time is not None: + payload["holdTime"] = hold_time + if website_url: + payload["website_url"] = website_url + + response = await self._request("POST", "/v2/payments", json_data=payload) + if not response or not response.get("success"): + logger.error("Failed to create MulenPay payment: %s", response) + return None + + return response + + async def get_payment(self, payment_id: int) -> Optional[Dict[str, Any]]: + return await self._request("GET", f"/v2/payments/{payment_id}") diff --git a/app/services/notification_settings_service.py b/app/services/notification_settings_service.py new file mode 100644 index 00000000..a19edffd --- /dev/null +++ b/app/services/notification_settings_service.py @@ -0,0 +1,249 @@ +import json +import json +import logging +from copy import deepcopy +from pathlib import Path +from typing import Any, Dict + +from app.config import settings + + +logger = logging.getLogger(__name__) + + +class NotificationSettingsService: + """Runtime-editable notification settings stored on disk.""" + + _storage_path: Path = Path("data/notification_settings.json") + _data: Dict[str, Dict[str, Any]] = {} + _loaded: bool = False + + _DEFAULTS: Dict[str, Dict[str, Any]] = { + "trial_inactive_1h": {"enabled": True}, + "trial_inactive_24h": {"enabled": True}, + "expired_1d": {"enabled": True}, + "expired_second_wave": { + "enabled": True, + "discount_percent": 10, + "valid_hours": 24, + }, + "expired_third_wave": { + "enabled": True, + "discount_percent": 20, + "valid_hours": 24, + "trigger_days": 5, + }, + } + + @classmethod + def _ensure_dir(cls) -> None: + try: + cls._storage_path.parent.mkdir(parents=True, exist_ok=True) + except Exception as exc: # pragma: no cover - filesystem guard + logger.error("Failed to create notification settings dir: %s", exc) + + @classmethod + def _load(cls) -> None: + if cls._loaded: + return + + cls._ensure_dir() + try: + if cls._storage_path.exists(): + raw = cls._storage_path.read_text(encoding="utf-8") + cls._data = json.loads(raw) if raw.strip() else {} + else: + cls._data = {} + except Exception as exc: + logger.error("Failed to load notification settings: %s", exc) + cls._data = {} + + changed = cls._apply_defaults() + if changed: + cls._save() + cls._loaded = True + + @classmethod + def _apply_defaults(cls) -> bool: + changed = False + for key, defaults in cls._DEFAULTS.items(): + current = cls._data.get(key) + if not isinstance(current, dict): + cls._data[key] = deepcopy(defaults) + changed = True + continue + + for def_key, def_value in defaults.items(): + if def_key not in current: + current[def_key] = def_value + changed = True + return changed + + @classmethod + def _save(cls) -> bool: + cls._ensure_dir() + try: + cls._storage_path.write_text( + json.dumps(cls._data, ensure_ascii=False, indent=2), + encoding="utf-8", + ) + return True + except Exception as exc: + logger.error("Failed to save notification settings: %s", exc) + return False + + @classmethod + def _get(cls, key: str) -> Dict[str, Any]: + cls._load() + value = cls._data.get(key) + if not isinstance(value, dict): + value = deepcopy(cls._DEFAULTS.get(key, {})) + cls._data[key] = value + return value + + @classmethod + def get_config(cls) -> Dict[str, Dict[str, Any]]: + cls._load() + return deepcopy(cls._data) + + @classmethod + def _set_field(cls, key: str, field: str, value: Any) -> bool: + cls._load() + section = cls._get(key) + section[field] = value + cls._data[key] = section + return cls._save() + + @classmethod + def set_enabled(cls, key: str, enabled: bool) -> bool: + return cls._set_field(key, "enabled", bool(enabled)) + + @classmethod + def is_enabled(cls, key: str) -> bool: + return bool(cls._get(key).get("enabled", True)) + + # Trial inactivity helpers + @classmethod + def is_trial_inactive_1h_enabled(cls) -> bool: + return cls.is_enabled("trial_inactive_1h") + + @classmethod + def set_trial_inactive_1h_enabled(cls, enabled: bool) -> bool: + return cls.set_enabled("trial_inactive_1h", enabled) + + @classmethod + def is_trial_inactive_24h_enabled(cls) -> bool: + return cls.is_enabled("trial_inactive_24h") + + @classmethod + def set_trial_inactive_24h_enabled(cls, enabled: bool) -> bool: + return cls.set_enabled("trial_inactive_24h", enabled) + + # Expired subscription notifications + @classmethod + def is_expired_1d_enabled(cls) -> bool: + return cls.is_enabled("expired_1d") + + @classmethod + def set_expired_1d_enabled(cls, enabled: bool) -> bool: + return cls.set_enabled("expired_1d", enabled) + + @classmethod + def is_second_wave_enabled(cls) -> bool: + return cls.is_enabled("expired_second_wave") + + @classmethod + def set_second_wave_enabled(cls, enabled: bool) -> bool: + return cls.set_enabled("expired_second_wave", enabled) + + @classmethod + def get_second_wave_discount_percent(cls) -> int: + value = cls._get("expired_second_wave").get("discount_percent", 10) + try: + return max(0, min(100, int(value))) + except (TypeError, ValueError): + return 10 + + @classmethod + def set_second_wave_discount_percent(cls, percent: int) -> bool: + try: + percent_int = max(0, min(100, int(percent))) + except (TypeError, ValueError): + return False + return cls._set_field("expired_second_wave", "discount_percent", percent_int) + + @classmethod + def get_second_wave_valid_hours(cls) -> int: + value = cls._get("expired_second_wave").get("valid_hours", 24) + try: + return max(1, min(168, int(value))) + except (TypeError, ValueError): + return 24 + + @classmethod + def set_second_wave_valid_hours(cls, hours: int) -> bool: + try: + hours_int = max(1, min(168, int(hours))) + except (TypeError, ValueError): + return False + return cls._set_field("expired_second_wave", "valid_hours", hours_int) + + @classmethod + def is_third_wave_enabled(cls) -> bool: + return cls.is_enabled("expired_third_wave") + + @classmethod + def set_third_wave_enabled(cls, enabled: bool) -> bool: + return cls.set_enabled("expired_third_wave", enabled) + + @classmethod + def get_third_wave_discount_percent(cls) -> int: + value = cls._get("expired_third_wave").get("discount_percent", 20) + try: + return max(0, min(100, int(value))) + except (TypeError, ValueError): + return 20 + + @classmethod + def set_third_wave_discount_percent(cls, percent: int) -> bool: + try: + percent_int = max(0, min(100, int(percent))) + except (TypeError, ValueError): + return False + return cls._set_field("expired_third_wave", "discount_percent", percent_int) + + @classmethod + def get_third_wave_valid_hours(cls) -> int: + value = cls._get("expired_third_wave").get("valid_hours", 24) + try: + return max(1, min(168, int(value))) + except (TypeError, ValueError): + return 24 + + @classmethod + def set_third_wave_valid_hours(cls, hours: int) -> bool: + try: + hours_int = max(1, min(168, int(hours))) + except (TypeError, ValueError): + return False + return cls._set_field("expired_third_wave", "valid_hours", hours_int) + + @classmethod + def get_third_wave_trigger_days(cls) -> int: + value = cls._get("expired_third_wave").get("trigger_days", 5) + try: + return max(2, min(60, int(value))) + except (TypeError, ValueError): + return 5 + + @classmethod + def set_third_wave_trigger_days(cls, days: int) -> bool: + try: + days_int = max(2, min(60, int(days))) + except (TypeError, ValueError): + return False + return cls._set_field("expired_third_wave", "trigger_days", days_int) + + @classmethod + def are_notifications_globally_enabled(cls) -> bool: + return bool(getattr(settings, "ENABLE_NOTIFICATIONS", True)) diff --git a/app/services/pal24_service.py b/app/services/pal24_service.py new file mode 100644 index 00000000..a4f72047 --- /dev/null +++ b/app/services/pal24_service.py @@ -0,0 +1,116 @@ +"""High level integration with PayPalych API.""" + +from __future__ import annotations + +import logging +from datetime import datetime, timedelta +from decimal import Decimal +from typing import Any, Dict, Optional + +from app.config import settings +from app.external.pal24_client import Pal24Client, Pal24APIError + +logger = logging.getLogger(__name__) + + +class Pal24Service: + """Wrapper around :class:`Pal24Client` providing domain helpers.""" + + BILL_SUCCESS_STATES = {"SUCCESS", "OVERPAID"} + BILL_FAILED_STATES = {"FAIL", "CANCELLED"} + BILL_PENDING_STATES = {"NEW", "PROCESS", "UNDERPAID"} + + def __init__(self, client: Optional[Pal24Client] = None) -> None: + self.client = client or Pal24Client() + + @property + def is_configured(self) -> bool: + return self.client.is_configured and settings.is_pal24_enabled() + + async def create_bill( + self, + *, + amount_kopeks: int, + user_id: int, + order_id: str, + description: str, + ttl_seconds: Optional[int] = None, + custom_payload: Optional[Dict[str, Any]] = None, + payer_email: Optional[str] = None, + ) -> Dict[str, Any]: + if not self.is_configured: + raise Pal24APIError("Pal24 service is not configured") + + amount_decimal = Pal24Client.normalize_amount(amount_kopeks) + extra_payload: Dict[str, Any] = { + "custom": custom_payload or {}, + "ttl": ttl_seconds, + } + + if payer_email: + extra_payload["payer_email"] = payer_email + + filtered_payload = {k: v for k, v in extra_payload.items() if v not in (None, {})} + + logger.info( + "Создаем Pal24 счет: user_id=%s, order_id=%s, amount=%s, ttl=%s", + user_id, + order_id, + amount_decimal, + ttl_seconds, + ) + + response = await self.client.create_bill( + amount=amount_decimal, + shop_id=settings.PAL24_SHOP_ID, + order_id=order_id, + description=description, + type_="normal", + **filtered_payload, + ) + + logger.info("Pal24 счет создан: %s", response) + return response + + async def get_bill_status(self, bill_id: str) -> Dict[str, Any]: + logger.debug("Запрашиваем статус Pal24 счета %s", bill_id) + return await self.client.get_bill_status(bill_id) + + async def get_payment_status(self, payment_id: str) -> Dict[str, Any]: + logger.debug("Запрашиваем статус Pal24 платежа %s", payment_id) + return await self.client.get_payment_status(payment_id) + + @staticmethod + def parse_postback(payload: Dict[str, Any]) -> Dict[str, Any]: + required_fields = ["InvId", "OutSum", "Status", "SignatureValue"] + missing = [field for field in required_fields if field not in payload] + if missing: + raise Pal24APIError(f"Pal24 postback missing fields: {', '.join(missing)}") + + inv_id = str(payload["InvId"]) + out_sum = str(payload["OutSum"]) + signature = str(payload["SignatureValue"]) + + if not Pal24Client.verify_signature(out_sum, inv_id, signature): + raise Pal24APIError("Pal24 postback signature mismatch") + + logger.info( + "Получен Pal24 postback: InvId=%s, Status=%s, TrsId=%s", + inv_id, + payload.get("Status"), + payload.get("TrsId"), + ) + + return payload + + @staticmethod + def convert_to_kopeks(amount: str) -> int: + decimal_amount = Decimal(str(amount)) + return int((decimal_amount * Decimal("100")).quantize(Decimal("1"))) + + @staticmethod + def get_expiration(ttl_seconds: Optional[int]) -> Optional[datetime]: + if not ttl_seconds: + return None + return datetime.utcnow() + timedelta(seconds=ttl_seconds) + diff --git a/app/services/payment_service.py b/app/services/payment_service.py index 0855be9e..63d07597 100644 --- a/app/services/payment_service.py +++ b/app/services/payment_service.py @@ -1,6 +1,8 @@ import logging import hashlib import hmac +import uuid +from decimal import Decimal, InvalidOperation from typing import Optional, Dict, Any from datetime import datetime from aiogram import Bot @@ -26,6 +28,24 @@ from app.services.subscription_checkout_service import ( has_subscription_checkout_draft, should_offer_checkout_resume, ) +from app.services.mulenpay_service import MulenPayService +from app.services.pal24_service import Pal24Service, Pal24APIError +from app.database.crud.mulenpay import ( + create_mulenpay_payment, + get_mulenpay_payment_by_local_id, + get_mulenpay_payment_by_uuid, + get_mulenpay_payment_by_mulen_id, + update_mulenpay_payment_status, + link_mulenpay_payment_to_transaction, +) +from app.database.crud.pal24 import ( + create_pal24_payment, + get_pal24_payment_by_bill_id, + get_pal24_payment_by_id, + get_pal24_payment_by_order_id, + link_pal24_payment_to_transaction, + update_pal24_payment_status, +) logger = logging.getLogger(__name__) @@ -37,6 +57,8 @@ class PaymentService: self.yookassa_service = YooKassaService() if settings.is_yookassa_enabled() else None self.stars_service = TelegramStarsService(bot) if bot else None self.cryptobot_service = CryptoBotService() if settings.is_cryptobot_enabled() else None + self.mulenpay_service = MulenPayService() if settings.is_mulenpay_enabled() else None + self.pal24_service = Pal24Service() if settings.is_pal24_enabled() else None async def build_topup_success_keyboard(self, user) -> InlineKeyboardMarkup: texts = get_texts(user.language if user else "ru") @@ -633,7 +655,7 @@ class PaymentService: description: str = "Пополнение баланса", payload: Optional[str] = None ) -> Optional[Dict[str, Any]]: - + if not self.cryptobot_service: logger.error("CryptoBot сервис не инициализирован") return None @@ -686,7 +708,657 @@ class PaymentService: except Exception as e: logger.error(f"Ошибка создания CryptoBot платежа: {e}") return None - + + async def create_mulenpay_payment( + self, + db: AsyncSession, + user_id: int, + amount_kopeks: int, + description: str, + language: Optional[str] = None, + ) -> Optional[Dict[str, Any]]: + + if not self.mulenpay_service: + logger.error("MulenPay сервис не инициализирован") + return None + + if amount_kopeks < settings.MULENPAY_MIN_AMOUNT_KOPEKS: + logger.warning( + "Сумма MulenPay меньше минимальной: %s < %s", + amount_kopeks, + settings.MULENPAY_MIN_AMOUNT_KOPEKS, + ) + return None + + if amount_kopeks > settings.MULENPAY_MAX_AMOUNT_KOPEKS: + logger.warning( + "Сумма MulenPay больше максимальной: %s > %s", + amount_kopeks, + settings.MULENPAY_MAX_AMOUNT_KOPEKS, + ) + return None + + try: + payment_uuid = f"mulen_{user_id}_{uuid.uuid4().hex}" + amount_rubles = amount_kopeks / 100 + + items = [ + { + "description": description[:128], + "quantity": 1, + "price": round(amount_rubles, 2), + "vat_code": settings.MULENPAY_VAT_CODE, + "payment_subject": settings.MULENPAY_PAYMENT_SUBJECT, + "payment_mode": settings.MULENPAY_PAYMENT_MODE, + } + ] + + response = await self.mulenpay_service.create_payment( + amount_kopeks=amount_kopeks, + description=description, + uuid=payment_uuid, + items=items, + language=language or settings.MULENPAY_LANGUAGE, + website_url=settings.WEBHOOK_URL, + ) + + if not response: + logger.error("Ошибка создания MulenPay платежа") + return None + + mulen_payment_id = response.get("id") + payment_url = response.get("paymentUrl") + + metadata = { + "user_id": user_id, + "amount_kopeks": amount_kopeks, + "description": description, + } + + local_payment = await create_mulenpay_payment( + db=db, + user_id=user_id, + amount_kopeks=amount_kopeks, + uuid=payment_uuid, + description=description, + payment_url=payment_url, + mulen_payment_id=mulen_payment_id, + currency="RUB", + status="created", + metadata=metadata, + ) + + logger.info( + "Создан MulenPay платеж %s на %s₽ для пользователя %s", + mulen_payment_id, + amount_rubles, + user_id, + ) + + return { + "local_payment_id": local_payment.id, + "mulen_payment_id": mulen_payment_id, + "payment_url": payment_url, + "amount_kopeks": amount_kopeks, + "uuid": payment_uuid, + "status": "created", + } + + except Exception as e: + logger.error(f"Ошибка создания MulenPay платежа: {e}") + return None + + async def create_pal24_payment( + self, + db: AsyncSession, + *, + user_id: int, + amount_kopeks: int, + description: str, + language: str, + ttl_seconds: Optional[int] = None, + payer_email: Optional[str] = None, + ) -> Optional[Dict[str, Any]]: + + if not self.pal24_service or not self.pal24_service.is_configured: + logger.error("Pal24 сервис не инициализирован") + return None + + if amount_kopeks < settings.PAL24_MIN_AMOUNT_KOPEKS: + logger.warning( + "Сумма Pal24 меньше минимальной: %s < %s", + amount_kopeks, + settings.PAL24_MIN_AMOUNT_KOPEKS, + ) + return None + + if amount_kopeks > settings.PAL24_MAX_AMOUNT_KOPEKS: + logger.warning( + "Сумма Pal24 больше максимальной: %s > %s", + amount_kopeks, + settings.PAL24_MAX_AMOUNT_KOPEKS, + ) + return None + + order_id = f"pal24_{user_id}_{uuid.uuid4().hex}" + + custom_payload = { + "user_id": user_id, + "amount_kopeks": amount_kopeks, + "language": language, + } + + try: + response = await self.pal24_service.create_bill( + amount_kopeks=amount_kopeks, + user_id=user_id, + order_id=order_id, + description=description, + ttl_seconds=ttl_seconds, + custom_payload=custom_payload, + payer_email=payer_email, + ) + except Pal24APIError as error: + logger.error("Ошибка Pal24 API при создании счета: %s", error) + return None + + if not response.get("success", True): + logger.error("Pal24 вернул ошибку при создании счета: %s", response) + return None + + bill_id = response.get("bill_id") + if not bill_id: + logger.error("Pal24 не вернул bill_id: %s", response) + return None + + link_url = response.get("link_url") + link_page_url = response.get("link_page_url") + + payment = await create_pal24_payment( + db, + user_id=user_id, + bill_id=bill_id, + order_id=order_id, + amount_kopeks=amount_kopeks, + description=description, + status=response.get("status", "NEW"), + type_=response.get("type", "normal"), + currency=response.get("currency", "RUB"), + link_url=link_url, + link_page_url=link_page_url, + ttl=ttl_seconds, + metadata={ + "raw_response": response, + "language": language, + }, + ) + + payment_info = { + "bill_id": bill_id, + "order_id": order_id, + "link_url": link_url or link_page_url, + "link_page_url": link_page_url, + "local_payment_id": payment.id, + "amount_kopeks": amount_kopeks, + } + + logger.info( + "Создан Pal24 счет %s для пользователя %s на сумму %s", + bill_id, + user_id, + settings.format_price(amount_kopeks), + ) + + return payment_info + + async def process_mulenpay_callback(self, db: AsyncSession, callback_data: dict) -> bool: + try: + uuid_value = callback_data.get("uuid") + payment_status = (callback_data.get("payment_status") or "").lower() + mulen_payment_id_raw = callback_data.get("id") + mulen_payment_id_int: Optional[int] = None + if mulen_payment_id_raw is not None: + try: + mulen_payment_id_int = int(mulen_payment_id_raw) + except (TypeError, ValueError): + mulen_payment_id_int = None + amount_value = callback_data.get("amount") + + if not uuid_value and mulen_payment_id_raw is None: + logger.error("MulenPay callback без uuid и id") + return False + + payment = None + if uuid_value: + payment = await get_mulenpay_payment_by_uuid(db, uuid_value) + + if not payment and mulen_payment_id_int is not None: + payment = await get_mulenpay_payment_by_mulen_id(db, mulen_payment_id_int) + + if not payment: + logger.error( + "MulenPay платеж не найден (uuid=%s, id=%s)", + uuid_value, + mulen_payment_id_raw, + ) + return False + + if payment.transaction_id and payment.is_paid: + logger.info("MulenPay платеж %s уже обработан", payment.uuid) + return True + + paid_at = datetime.utcnow() + + if payment_status == "success": + try: + amount_kopeks = int(Decimal(str(amount_value)) * 100) + except (InvalidOperation, TypeError): + amount_kopeks = payment.amount_kopeks + logger.warning( + "Не удалось распарсить сумму MulenPay, используем значение из БД: %s", + amount_value, + ) + + if amount_kopeks != payment.amount_kopeks: + logger.warning( + "Несовпадение суммы MulenPay: callback=%s, ожидаемо=%s", + amount_kopeks, + payment.amount_kopeks, + ) + + transaction = await create_transaction( + db, + user_id=payment.user_id, + type=TransactionType.DEPOSIT, + amount_kopeks=payment.amount_kopeks, + description=f"Пополнение через Mulen Pay ({mulen_payment_id_raw})", + payment_method=PaymentMethod.MULENPAY, + external_id=( + str(mulen_payment_id_int) + if mulen_payment_id_int is not None + else payment.uuid + ), + is_completed=True, + ) + + await link_mulenpay_payment_to_transaction( + db, + payment=payment, + transaction_id=transaction.id, + ) + + user = await get_user_by_id(db, payment.user_id) + if not user: + logger.error("Пользователь %s не найден для MulenPay платежа", payment.user_id) + return False + + old_balance = user.balance_kopeks + user.balance_kopeks += payment.amount_kopeks + user.updated_at = datetime.utcnow() + + await db.commit() + await db.refresh(user) + + try: + from app.services.referral_service import process_referral_topup + + await process_referral_topup(db, user.id, payment.amount_kopeks, self.bot) + except Exception as referral_error: + logger.error( + "Ошибка обработки реферального пополнения MulenPay: %s", + referral_error, + ) + + await update_mulenpay_payment_status( + db, + payment=payment, + status="success", + is_paid=True, + paid_at=paid_at, + callback_payload=callback_data, + mulen_payment_id=mulen_payment_id_int, + ) + + if self.bot: + try: + from app.services.admin_notification_service import AdminNotificationService + + notification_service = AdminNotificationService(self.bot) + await notification_service.send_balance_topup_notification( + db, + user, + transaction, + old_balance, + ) + except Exception as notify_error: + logger.error( + "Ошибка отправки админ уведомления MulenPay: %s", + notify_error, + ) + + if self.bot: + try: + keyboard = await self.build_topup_success_keyboard(user) + await self.bot.send_message( + user.telegram_id, + ( + "✅ Пополнение успешно!\n\n" + f"💰 Сумма: {settings.format_price(payment.amount_kopeks)}\n" + "🦊 Способ: Mulen Pay\n" + f"🆔 Транзакция: {transaction.id}\n\n" + "Баланс пополнен автоматически!" + ), + parse_mode="HTML", + reply_markup=keyboard, + ) + except Exception as user_notify_error: + logger.error( + "Ошибка отправки уведомления пользователю MulenPay: %s", + user_notify_error, + ) + + logger.info( + "✅ Обработан MulenPay платеж %s для пользователя %s", + payment.uuid, + payment.user_id, + ) + return True + + if payment_status == "cancel": + await update_mulenpay_payment_status( + db, + payment=payment, + status="canceled", + callback_payload=callback_data, + mulen_payment_id=mulen_payment_id_int, + ) + logger.info("MulenPay платеж %s отменен", payment.uuid) + return True + + await update_mulenpay_payment_status( + db, + payment=payment, + status=payment_status or "unknown", + callback_payload=callback_data, + mulen_payment_id=mulen_payment_id_int, + ) + logger.info( + "Получен MulenPay callback со статусом %s для платежа %s", + payment_status, + payment.uuid, + ) + return True + + except Exception as error: + logger.error(f"Ошибка обработки MulenPay callback: {error}", exc_info=True) + return False + + async def process_pal24_postback(self, db: AsyncSession, payload: Dict[str, Any]) -> bool: + + if not self.pal24_service or not self.pal24_service.is_configured: + logger.error("Pal24 сервис не инициализирован") + return False + + try: + order_id_raw = payload.get("InvId") + order_id = str(order_id_raw) if order_id_raw is not None else None + if not order_id: + logger.error("Pal24 postback без InvId") + return False + + payment = await get_pal24_payment_by_order_id(db, order_id) + if not payment: + bill_id = payload.get("BillId") + if bill_id: + payment = await get_pal24_payment_by_bill_id(db, str(bill_id)) + + if not payment: + logger.error("Pal24 платеж не найден для order_id=%s", order_id) + return False + + if payment.transaction_id and payment.is_paid: + logger.info("Pal24 платеж %s уже обработан", payment.bill_id) + return True + + status = str(payload.get("Status", "UNKNOWN")).upper() + payment_id = payload.get("TrsId") + balance_amount = payload.get("BalanceAmount") + balance_currency = payload.get("BalanceCurrency") + payer_account = payload.get("AccountNumber") + payment_method = payload.get("AccountType") + + try: + amount_kopeks = Pal24Service.convert_to_kopeks(str(payload.get("OutSum"))) + except Exception: + logger.warning("Не удалось распарсить сумму Pal24, используем сохраненное значение") + amount_kopeks = payment.amount_kopeks + + if amount_kopeks != payment.amount_kopeks: + logger.warning( + "Несовпадение суммы Pal24: callback=%s, ожидаемо=%s", + amount_kopeks, + payment.amount_kopeks, + ) + + is_success = status in Pal24Service.BILL_SUCCESS_STATES + is_failed = status in Pal24Service.BILL_FAILED_STATES + + await update_pal24_payment_status( + db, + payment, + status=status, + is_active=not is_failed, + is_paid=is_success, + payment_id=str(payment_id) if payment_id else None, + payment_status=status, + payment_method=str(payment_method) if payment_method else None, + balance_amount=str(balance_amount) if balance_amount is not None else None, + balance_currency=str(balance_currency) if balance_currency is not None else None, + payer_account=str(payer_account) if payer_account is not None else None, + callback_payload=payload, + ) + + if not is_success: + logger.info( + "Получен Pal24 статус %s для платежа %s (успех=%s)", + status, + payment.bill_id, + is_success, + ) + return True + + user = await get_user_by_id(db, payment.user_id) + if not user: + logger.error("Пользователь %s не найден для Pal24 платежа", payment.user_id) + return False + + transaction = await create_transaction( + db=db, + user_id=payment.user_id, + type=TransactionType.DEPOSIT, + amount_kopeks=payment.amount_kopeks, + description=f"Пополнение через Pal24 ({payment_id})", + payment_method=PaymentMethod.PAL24, + external_id=str(payment_id) if payment_id else payment.bill_id, + is_completed=True, + ) + + await link_pal24_payment_to_transaction(db, payment, transaction.id) + + old_balance = user.balance_kopeks + user.balance_kopeks += payment.amount_kopeks + user.updated_at = datetime.utcnow() + await db.commit() + await db.refresh(user) + + try: + from app.services.referral_service import process_referral_topup + + await process_referral_topup(db, user.id, payment.amount_kopeks, self.bot) + except Exception as referral_error: + logger.error("Ошибка обработки реферального пополнения Pal24: %s", referral_error) + + if self.bot: + try: + from app.services.admin_notification_service import AdminNotificationService + + notification_service = AdminNotificationService(self.bot) + await notification_service.send_balance_topup_notification( + db, + user, + transaction, + old_balance, + ) + except Exception as notify_error: + logger.error("Ошибка отправки админ уведомления Pal24: %s", notify_error) + + if self.bot: + try: + keyboard = await self.build_topup_success_keyboard(user) + await self.bot.send_message( + user.telegram_id, + ( + "✅ Пополнение успешно!\n\n" + f"💰 Сумма: {settings.format_price(payment.amount_kopeks)}\n" + "🦊 Способ: PayPalych\n" + f"🆔 Транзакция: {transaction.id}\n\n" + "Баланс пополнен автоматически!" + ), + parse_mode="HTML", + reply_markup=keyboard, + ) + except Exception as user_notify_error: + logger.error("Ошибка отправки уведомления пользователю Pal24: %s", user_notify_error) + + logger.info( + "✅ Обработан Pal24 платеж %s для пользователя %s", + payment.bill_id, + payment.user_id, + ) + + return True + + except Exception as error: + logger.error("Ошибка обработки Pal24 postback: %s", error, exc_info=True) + return False + + @staticmethod + def _map_mulenpay_status(status_code: Optional[int]) -> str: + mapping = { + 0: "created", + 1: "processing", + 2: "canceled", + 3: "success", + 4: "error", + 5: "hold", + 6: "hold", + } + return mapping.get(status_code, "unknown") + + async def get_mulenpay_payment_status( + self, + db: AsyncSession, + local_payment_id: int, + ) -> Optional[Dict[str, Any]]: + try: + payment = await get_mulenpay_payment_by_local_id(db, local_payment_id) + if not payment: + return None + + remote_status_code = None + remote_data = None + + if ( + self.mulenpay_service + and payment.mulen_payment_id is not None + ): + response = await self.mulenpay_service.get_payment(payment.mulen_payment_id) + if response and response.get("success"): + remote_data = response.get("payment") + if isinstance(remote_data, dict): + remote_status_code = remote_data.get("status") + mapped_status = self._map_mulenpay_status(remote_status_code) + + if mapped_status == "success" and not payment.is_paid: + await self.process_mulenpay_callback( + db, + { + "uuid": payment.uuid, + "payment_status": "success", + "id": remote_data.get("id"), + "amount": remote_data.get("amount"), + }, + ) + payment = await get_mulenpay_payment_by_local_id( + db, local_payment_id + ) + elif mapped_status and mapped_status != payment.status: + await update_mulenpay_payment_status( + db, + payment=payment, + status=mapped_status, + mulen_payment_id=remote_data.get("id"), + ) + payment = await get_mulenpay_payment_by_local_id( + db, local_payment_id + ) + + return { + "payment": payment, + "status": payment.status, + "is_paid": payment.is_paid, + "remote_status_code": remote_status_code, + "remote_data": remote_data, + } + + except Exception as error: + logger.error(f"Ошибка получения статуса MulenPay: {error}", exc_info=True) + return None + + async def get_pal24_payment_status( + self, + db: AsyncSession, + local_payment_id: int, + ) -> Optional[Dict[str, Any]]: + try: + payment = await get_pal24_payment_by_id(db, local_payment_id) + if not payment: + return None + + remote_status = None + remote_data = None + + if self.pal24_service and payment.bill_id: + try: + response = await self.pal24_service.get_bill_status(payment.bill_id) + remote_data = response + remote_status = ( + response.get("status") + or response.get("bill", {}).get("status") + ) + + if remote_status and remote_status != payment.status: + await update_pal24_payment_status( + db, + payment, + status=str(remote_status).upper(), + ) + payment = await get_pal24_payment_by_id(db, local_payment_id) + except Pal24APIError as error: + logger.error("Ошибка Pal24 API при получении статуса: %s", error) + + return { + "payment": payment, + "status": payment.status, + "is_paid": payment.is_paid, + "remote_status": remote_status, + "remote_data": remote_data, + } + + except Exception as error: + logger.error("Ошибка получения статуса Pal24: %s", error, exc_info=True) + return None + async def process_cryptobot_webhook(self, db: AsyncSession, webhook_data: dict) -> bool: try: from app.database.crud.cryptobot import ( diff --git a/app/services/promo_group_assignment.py b/app/services/promo_group_assignment.py new file mode 100644 index 00000000..43b29ec1 --- /dev/null +++ b/app/services/promo_group_assignment.py @@ -0,0 +1,123 @@ +import logging +from datetime import datetime +from typing import Optional + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.database.crud.transaction import get_user_total_spent_kopeks +from app.database.models import PromoGroup, User + +logger = logging.getLogger(__name__) + + +async def _get_best_group_for_spending( + db: AsyncSession, + total_spent_kopeks: int, + min_threshold_kopeks: int = 0, +) -> Optional[PromoGroup]: + if total_spent_kopeks <= 0: + return None + + result = await db.execute( + select(PromoGroup) + .where(PromoGroup.auto_assign_total_spent_kopeks.is_not(None)) + .where(PromoGroup.auto_assign_total_spent_kopeks > 0) + .order_by(PromoGroup.auto_assign_total_spent_kopeks.desc(), PromoGroup.id.desc()) + ) + groups = result.scalars().all() + + for group in groups: + threshold = group.auto_assign_total_spent_kopeks or 0 + if ( + threshold + and total_spent_kopeks >= threshold + and threshold > min_threshold_kopeks + ): + return group + + return None + + +async def maybe_assign_promo_group_by_total_spent( + db: AsyncSession, + user_id: int, +) -> Optional[PromoGroup]: + user = await db.get(User, user_id) + if not user: + logger.debug("Не удалось найти пользователя %s для автовыдачи промогруппы", user_id) + return None + + total_spent = await get_user_total_spent_kopeks(db, user_id) + if total_spent <= 0: + return None + + previous_threshold = user.auto_promo_group_threshold_kopeks or 0 + + target_group = await _get_best_group_for_spending( + db, + total_spent, + min_threshold_kopeks=previous_threshold, + ) + if not target_group: + return None + + try: + previous_group_id = user.promo_group_id + target_threshold = target_group.auto_assign_total_spent_kopeks or 0 + + if target_threshold <= previous_threshold: + logger.debug( + "Порог промогруппы '%s' (%s) не превышает ранее назначенный (%s) для пользователя %s", + target_group.name, + target_threshold, + previous_threshold, + user.telegram_id, + ) + return None + + if user.auto_promo_group_assigned and target_group.id == previous_group_id: + logger.debug( + "Пользователь %s уже находится в актуальной промогруппе '%s', повторная выдача не требуется", + user.telegram_id, + target_group.name, + ) + if target_threshold > previous_threshold: + user.auto_promo_group_threshold_kopeks = target_threshold + user.updated_at = datetime.utcnow() + await db.commit() + await db.refresh(user) + return target_group + + user.auto_promo_group_assigned = True + user.auto_promo_group_threshold_kopeks = target_threshold + user.updated_at = datetime.utcnow() + + if target_group.id != previous_group_id: + user.promo_group_id = target_group.id + user.promo_group = target_group + logger.info( + "🤖 Пользователь %s автоматически переведен в промогруппу '%s' за траты %s ₽", + user.telegram_id, + target_group.name, + total_spent / 100, + ) + else: + logger.info( + "🤖 Пользователь %s уже находится в подходящей промогруппе '%s', отмечаем автоприсвоение", + user.telegram_id, + target_group.name, + ) + + await db.commit() + await db.refresh(user) + + return target_group + except Exception as exc: + logger.error( + "Ошибка при автоматическом назначении промогруппы пользователю %s: %s", + user_id, + exc, + ) + await db.rollback() + return None diff --git a/app/services/remnawave_service.py b/app/services/remnawave_service.py index dd6ee50c..94add099 100644 --- a/app/services/remnawave_service.py +++ b/app/services/remnawave_service.py @@ -458,6 +458,7 @@ class RemnaWaveService: 'usedTrafficBytes': user_obj.used_traffic_bytes, 'hwidDeviceLimit': user_obj.hwid_device_limit, 'subscriptionUrl': user_obj.subscription_url, + 'subscriptionCryptoLink': user_obj.happ_crypto_link, 'activeInternalSquads': user_obj.active_internal_squads } panel_users.append(user_dict) @@ -581,6 +582,7 @@ class RemnaWaveService: subscription.autopay_enabled = False subscription.remnawave_short_uuid = None subscription.subscription_url = "" + subscription.subscription_crypto_link = "" db_user.remnawave_uuid = None @@ -637,14 +639,18 @@ class RemnaWaveService: subscription_data = { 'user_id': user.id, 'status': status.value, - 'is_trial': False, + 'is_trial': False, 'end_date': expire_at, 'traffic_limit_gb': traffic_limit_gb, 'traffic_used_gb': traffic_used_gb, 'device_limit': panel_user.get('hwidDeviceLimit', 1) or 1, 'connected_squads': squad_uuids, 'remnawave_short_uuid': panel_user.get('shortUuid'), - 'subscription_url': panel_user.get('subscriptionUrl', '') + 'subscription_url': panel_user.get('subscriptionUrl', ''), + 'subscription_crypto_link': ( + panel_user.get('subscriptionCryptoLink') + or (panel_user.get('happ') or {}).get('cryptoLink', '') + ) } subscription = await create_subscription(db, **subscription_data) @@ -667,7 +673,11 @@ class RemnaWaveService: device_limit=1, connected_squads=[], remnawave_short_uuid=panel_user.get('shortUuid'), - subscription_url=panel_user.get('subscriptionUrl', '') + subscription_url=panel_user.get('subscriptionUrl', ''), + subscription_crypto_link=( + panel_user.get('subscriptionCryptoLink') + or (panel_user.get('happ') or {}).get('cryptoLink', '') + ) ) logger.info(f"✅ Создана базовая подписка для пользователя {user.telegram_id}") except Exception as basic_error: @@ -733,6 +743,13 @@ class RemnaWaveService: panel_url = panel_user.get('subscriptionUrl', '') if not subscription.subscription_url or subscription.subscription_url != panel_url: subscription.subscription_url = panel_url + + panel_crypto_link = ( + panel_user.get('subscriptionCryptoLink') + or (panel_user.get('happ') or {}).get('cryptoLink', '') + ) + if panel_crypto_link and subscription.subscription_crypto_link != panel_crypto_link: + subscription.subscription_crypto_link = panel_crypto_link active_squads = panel_user.get('activeInternalSquads', []) squad_uuids = [] @@ -1113,6 +1130,7 @@ class RemnaWaveService: user.subscription.autopay_days_before = 3 user.subscription.remnawave_short_uuid = None user.subscription.subscription_url = "" + user.subscription.subscription_crypto_link = "" user.subscription.updated_at = datetime.utcnow() await db.commit() @@ -1295,6 +1313,7 @@ class RemnaWaveService: if rw_user: subscription.remnawave_short_uuid = rw_user.short_uuid subscription.subscription_url = rw_user.subscription_url + subscription.subscription_crypto_link = rw_user.happ_crypto_link logger.info(f"🔧 Восстановлены данные Remnawave для {user.telegram_id}") issues_fixed += 1 except Exception as rw_error: diff --git a/app/services/reporting_service.py b/app/services/reporting_service.py new file mode 100644 index 00000000..3f9161a1 --- /dev/null +++ b/app/services/reporting_service.py @@ -0,0 +1,351 @@ +import asyncio +import logging +from dataclasses import dataclass +from datetime import date, datetime, time as datetime_time, timedelta, timezone +from enum import Enum +from typing import Optional, Tuple + +from zoneinfo import ZoneInfo + +from aiogram import Bot +from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError +from sqlalchemy import func, select + +from app.config import settings +from app.database.crud.subscription import get_subscriptions_statistics +from app.database.database import AsyncSessionLocal +from app.database.models import ( + Subscription, + SubscriptionConversion, + Transaction, + TransactionType, +) + + +logger = logging.getLogger(__name__) + + +class ReportingServiceError(RuntimeError): + """Base error for the reporting service.""" + + +class ReportPeriod(Enum): + DAILY = "daily" + WEEKLY = "weekly" + MONTHLY = "monthly" + + +@dataclass(slots=True) +class ReportPeriodRange: + start_msk: datetime + end_msk: datetime + label: str + + +class ReportingService: + """Generates admin summary reports and can schedule daily delivery.""" + + def __init__(self) -> None: + self.bot: Optional[Bot] = None + self._task: Optional[asyncio.Task] = None + self._moscow_tz = ZoneInfo("Europe/Moscow") + + def set_bot(self, bot: Bot) -> None: + self.bot = bot + + def is_running(self) -> bool: + return self._task is not None and not self._task.done() + + async def start(self) -> None: + await self.stop() + + if not settings.ADMIN_REPORTS_ENABLED: + logger.info("Сервис отчетов отключен настройками") + return + + if not self.bot: + logger.warning("Невозможно запустить сервис отчетов без экземпляра бота") + return + + chat_id = settings.get_reports_chat_id() + if not chat_id: + logger.warning("Сервис отчетов не запущен: не указан чат для отправки отчетов") + return + + send_time = settings.get_reports_send_time() + if not send_time: + logger.warning("Сервис отчетов не запущен: не указано время ежедневной отправки") + return + + self._task = asyncio.create_task(self._auto_daily_loop(send_time)) + logger.info( + "📊 Сервис отчетов запущен: ежедневная отправка в %s по МСК", + send_time.strftime("%H:%M"), + ) + + async def stop(self) -> None: + if self._task and not self._task.done(): + self._task.cancel() + try: + await self._task + except asyncio.CancelledError: + pass + self._task = None + + async def send_report( + self, + period: ReportPeriod, + *, + report_date: Optional[date] = None, + send_to_topic: bool = False, + ) -> str: + report_text = await self._build_report(period, report_date) + + if send_to_topic: + await self._deliver_report(report_text) + + return report_text + + async def _auto_daily_loop(self, send_time: datetime_time) -> None: + try: + next_run_utc, report_date = self._calculate_next_run(send_time) + + while True: + now_utc = datetime.now(timezone.utc) + delay = (next_run_utc - now_utc).total_seconds() + + if delay > 0: + await asyncio.sleep(delay) + + try: + await self.send_report( + ReportPeriod.DAILY, + report_date=report_date, + send_to_topic=True, + ) + logger.info( + "📊 Автоматический отчет за %s отправлен", + report_date.strftime("%d.%m.%Y"), + ) + except asyncio.CancelledError: + raise + except Exception as exc: # noqa: BLE001 + logger.error("Ошибка автоматической отправки отчета: %s", exc) + + next_run_utc, report_date = self._calculate_next_run(send_time) + + except asyncio.CancelledError: + logger.info("Сервис отчетов остановлен") + raise + except Exception as exc: # noqa: BLE001 + logger.error("Критическая ошибка в сервисе отчетов: %s", exc) + + def _calculate_next_run( + self, + send_time: datetime_time, + ) -> Tuple[datetime, date]: + now_msk = datetime.now(self._moscow_tz) + candidate = datetime.combine(now_msk.date(), send_time, tzinfo=self._moscow_tz) + + if now_msk >= candidate: + candidate += timedelta(days=1) + + report_date = (candidate - timedelta(days=1)).date() + return candidate.astimezone(timezone.utc), report_date + + async def _deliver_report(self, report_text: str) -> None: + if not self.bot: + raise ReportingServiceError("Бот не инициализирован для отправки отчета") + + chat_id = settings.get_reports_chat_id() + if not chat_id: + raise ReportingServiceError("Не задан чат для отправки отчета") + + topic_id = settings.get_reports_topic_id() + + try: + await self.bot.send_message( + chat_id=chat_id, + text=report_text, + message_thread_id=topic_id, + ) + except (TelegramBadRequest, TelegramForbiddenError) as exc: + logger.error("Не удалось отправить отчет: %s", exc) + raise ReportingServiceError("Не удалось отправить отчет в чат") from exc + + async def _build_report( + self, + period: ReportPeriod, + report_date: Optional[date], + ) -> str: + period_range = self._get_period_range(period, report_date) + start_utc = period_range.start_msk.astimezone(timezone.utc).replace(tzinfo=None) + end_utc = period_range.end_msk.astimezone(timezone.utc).replace(tzinfo=None) + + async with AsyncSessionLocal() as session: + totals = await self._collect_current_totals(session) + period_stats = await self._collect_period_stats(session, start_utc, end_utc) + + header = ( + f"📊 Отчет за {period_range.label}" + if period == ReportPeriod.DAILY + else f"📊 Отчет за период {period_range.label}" + ) + + lines = [ + header, + "", + "🎯 Триалы", + f"• Активных сейчас: {totals['active_trials']}", + f"• Новых за период: {period_stats['new_trials']}", + "", + "💎 Платные подписки", + f"• Активных сейчас: {totals['active_paid']}", + f"• Новых за период: {period_stats['new_paid_subscriptions']}", + "", + "💰 Платежи", + f"• Оплат подписок: {period_stats['subscription_payments_count']} на сумму " + f"{self._format_amount(period_stats['subscription_payments_amount'])}", + f"• Пополнений: {period_stats['deposits_count']} на сумму " + f"{self._format_amount(period_stats['deposits_amount'])}", + f"• Всего поступлений: {period_stats['total_payments_count']} на сумму " + f"{self._format_amount(period_stats['total_payments_amount'])}", + ] + + return "\n".join(lines) + + def _get_period_range( + self, + period: ReportPeriod, + report_date: Optional[date], + ) -> ReportPeriodRange: + now_msk = datetime.now(self._moscow_tz) + + if period == ReportPeriod.DAILY: + target_date = report_date or (now_msk.date() - timedelta(days=1)) + start = datetime.combine(target_date, datetime_time.min, tzinfo=self._moscow_tz) + end = start + timedelta(days=1) + elif period == ReportPeriod.WEEKLY: + end_date = report_date or now_msk.date() + start_date = end_date - timedelta(days=7) + start = datetime.combine(start_date, datetime_time.min, tzinfo=self._moscow_tz) + end = datetime.combine(end_date, datetime_time.min, tzinfo=self._moscow_tz) + elif period == ReportPeriod.MONTHLY: + end_date = report_date or now_msk.date() + start_date = end_date - timedelta(days=30) + start = datetime.combine(start_date, datetime_time.min, tzinfo=self._moscow_tz) + end = datetime.combine(end_date, datetime_time.min, tzinfo=self._moscow_tz) + else: # pragma: no cover - defensive branch + raise ReportingServiceError(f"Неизвестный период отчета: {period}") + + label = self._format_period_label(start, end) + return ReportPeriodRange(start, end, label) + + async def _collect_current_totals(self, session) -> dict: + stats = await get_subscriptions_statistics(session) + return { + "active_trials": stats.get("trial_subscriptions", 0) or 0, + "active_paid": stats.get("paid_subscriptions", 0) or 0, + } + + async def _collect_period_stats( + self, + session, + start_utc: datetime, + end_utc: datetime, + ) -> dict: + new_trials_result = await session.execute( + select(func.count(Subscription.id)).where( + Subscription.created_at >= start_utc, + Subscription.created_at < end_utc, + Subscription.is_trial == True, # noqa: E712 + ) + ) + new_trials = int(new_trials_result.scalar() or 0) + + direct_paid_result = await session.execute( + select(func.count(Subscription.id)).where( + Subscription.created_at >= start_utc, + Subscription.created_at < end_utc, + Subscription.is_trial == False, # noqa: E712 + ) + ) + direct_paid = int(direct_paid_result.scalar() or 0) + + conversions_result = await session.execute( + select(func.count(SubscriptionConversion.id)).where( + SubscriptionConversion.converted_at >= start_utc, + SubscriptionConversion.converted_at < end_utc, + ) + ) + conversions_count = int(conversions_result.scalar() or 0) + + subscription_payments_row = ( + await session.execute( + select( + func.count(Transaction.id), + func.coalesce(func.sum(Transaction.amount_kopeks), 0), + ).where( + Transaction.type == TransactionType.SUBSCRIPTION_PAYMENT.value, + Transaction.is_completed == True, # noqa: E712 + Transaction.created_at >= start_utc, + Transaction.created_at < end_utc, + ) + ) + ).one() + + deposits_row = ( + await session.execute( + select( + func.count(Transaction.id), + func.coalesce(func.sum(Transaction.amount_kopeks), 0), + ).where( + Transaction.type == TransactionType.DEPOSIT.value, + Transaction.is_completed == True, # noqa: E712 + Transaction.created_at >= start_utc, + Transaction.created_at < end_utc, + ) + ) + ).one() + + subscription_payments_count = int(subscription_payments_row[0] or 0) + subscription_payments_amount = int(subscription_payments_row[1] or 0) + deposits_count = int(deposits_row[0] or 0) + deposits_amount = int(deposits_row[1] or 0) + + total_payments_count = subscription_payments_count + deposits_count + total_payments_amount = subscription_payments_amount + deposits_amount + + return { + "new_trials": new_trials, + "new_paid_subscriptions": direct_paid + conversions_count, + "subscription_payments_count": subscription_payments_count, + "subscription_payments_amount": subscription_payments_amount, + "deposits_count": deposits_count, + "deposits_amount": deposits_amount, + "total_payments_count": total_payments_count, + "total_payments_amount": total_payments_amount, + } + + def _format_period_label(self, start: datetime, end: datetime) -> str: + start_date = start.astimezone(self._moscow_tz).date() + end_boundary = (end - timedelta(seconds=1)).astimezone(self._moscow_tz) + end_date = end_boundary.date() + + if start_date == end_date: + return start_date.strftime("%d.%m.%Y") + + return ( + f"{start_date.strftime('%d.%m.%Y')} - {end_date.strftime('%d.%m.%Y')}" + ) + + def _format_amount(self, amount_kopeks: int) -> str: + if not amount_kopeks: + return "0 ₽" + + rubles = amount_kopeks / 100 + return f"{rubles:,.2f} ₽".replace(",", " ") + + +reporting_service = ReportingService() + diff --git a/app/services/subscription_checkout_service.py b/app/services/subscription_checkout_service.py index e49b6a5e..94b5e85e 100644 --- a/app/services/subscription_checkout_service.py +++ b/app/services/subscription_checkout_service.py @@ -49,4 +49,10 @@ def should_offer_checkout_resume(user: User, has_draft: bool) -> bool: if subscription is None: return True - return bool(getattr(subscription, "is_trial", False)) + if getattr(subscription, "is_trial", False): + return True + + if getattr(subscription, "actual_status", None) == "expired": + return True + + return False diff --git a/app/services/subscription_service.py b/app/services/subscription_service.py index 1b380dba..54ba6720 100644 --- a/app/services/subscription_service.py +++ b/app/services/subscription_service.py @@ -38,6 +38,26 @@ def _resolve_discount_percent( return 0 + +def _resolve_addon_discount_percent( + user: Optional[User], + promo_group: Optional[PromoGroup], + category: str, + *, + period_days: Optional[int] = None, +) -> int: + group = promo_group or (getattr(user, "promo_group", None) if user else None) + + if group is not None and not getattr(group, "apply_discounts_to_addons", True): + return 0 + + return _resolve_discount_percent( + user, + promo_group, + category, + period_days=period_days, + ) + def get_traffic_reset_strategy(): from app.config import settings strategy = settings.DEFAULT_TRAFFIC_RESET_STRATEGY.upper() @@ -130,7 +150,8 @@ class SubscriptionService: ) subscription.remnawave_short_uuid = updated_user.short_uuid - subscription.subscription_url = updated_user.subscription_url + subscription.subscription_url = updated_user.subscription_url + subscription.subscription_crypto_link = updated_user.happ_crypto_link user.remnawave_uuid = updated_user.uuid await db.commit() @@ -190,6 +211,7 @@ class SubscriptionService: ) subscription.subscription_url = updated_user.subscription_url + subscription.subscription_crypto_link = updated_user.happ_crypto_link await db.commit() status_text = "активным" if is_actually_active else "истёкшим" @@ -233,6 +255,7 @@ class SubscriptionService: subscription.remnawave_short_uuid = updated_user.short_uuid subscription.subscription_url = updated_user.subscription_url + subscription.subscription_crypto_link = updated_user.happ_crypto_link await db.commit() logger.info(f"✅ Обновлена ссылка подписки для пользователя {user.telegram_id}") @@ -299,7 +322,15 @@ class SubscriptionService: if settings.MAX_DEVICES_LIMIT > 0 and devices > settings.MAX_DEVICES_LIMIT: raise ValueError(f"Превышен максимальный лимит устройств: {settings.MAX_DEVICES_LIMIT}") - base_price = PERIOD_PRICES.get(period_days, 0) + base_price_original = PERIOD_PRICES.get(period_days, 0) + period_discount_percent = _resolve_discount_percent( + user, + promo_group, + "period", + period_days=period_days, + ) + base_discount_total = base_price_original * period_discount_percent // 100 + base_price = base_price_original - base_discount_total promo_group = promo_group or (user.promo_group if user else None) @@ -353,7 +384,13 @@ class SubscriptionService: total_price = base_price + discounted_traffic_price + total_servers_price + discounted_devices_price logger.info(f"Расчет стоимости новой подписки:") - logger.info(f" Период {period_days} дней: {base_price/100}₽") + base_log = f" Период {period_days} дней: {base_price_original/100}₽" + if base_discount_total > 0: + base_log += ( + f" → {base_price/100}₽" + f" (скидка {period_discount_percent}%: -{base_discount_total/100}₽)" + ) + logger.info(base_log) if discounted_traffic_price > 0: message = f" Трафик {traffic_gb} ГБ: {traffic_price/100}₽" if traffic_discount > 0: @@ -391,14 +428,16 @@ class SubscriptionService: try: from app.config import PERIOD_PRICES - base_price = PERIOD_PRICES.get(period_days, 0) + base_price_original = PERIOD_PRICES.get(period_days, 0) if user is None: user = getattr(subscription, "user", None) promo_group = promo_group or (user.promo_group if user else None) servers_price, _ = await self.get_countries_price_by_uuids( - subscription.connected_squads, db + subscription.connected_squads, + db, + promo_group_id=promo_group.id if promo_group else None, ) servers_discount_percent = _resolve_discount_percent( @@ -430,6 +469,15 @@ class SubscriptionService: traffic_discount = traffic_price * traffic_discount_percent // 100 discounted_traffic_price = traffic_price - traffic_discount + period_discount_percent = _resolve_discount_percent( + user, + promo_group, + "period", + period_days=period_days, + ) + base_discount_total = base_price_original * period_discount_percent // 100 + base_price = base_price_original - base_discount_total + total_price = ( base_price + discounted_servers_price @@ -438,7 +486,13 @@ class SubscriptionService: ) logger.info(f"💰 Расчет стоимости продления для подписки {subscription.id} (по текущим ценам):") - logger.info(f" 📅 Период {period_days} дней: {base_price/100}₽") + base_log = f" 📅 Период {period_days} дней: {base_price_original/100}₽" + if base_discount_total > 0: + base_log += ( + f" → {base_price/100}₽" + f" (скидка {period_discount_percent}%: -{base_discount_total/100}₽)" + ) + logger.info(base_log) if servers_price > 0: message = f" 🌍 Серверы ({len(subscription.connected_squads)}) по текущим ценам: {discounted_servers_price/100}₽" if servers_discount > 0: @@ -503,6 +557,7 @@ class SubscriptionService: subscription.remnawave_short_uuid = None subscription.subscription_url = "" + subscription.subscription_crypto_link = "" subscription.connected_squads = [] user.remnawave_uuid = None @@ -518,9 +573,11 @@ class SubscriptionService: return False async def get_countries_price_by_uuids( - self, - country_uuids: List[str], - db: AsyncSession + self, + country_uuids: List[str], + db: AsyncSession, + *, + promo_group_id: Optional[int] = None, ) -> Tuple[int, List[int]]: try: from app.database.crud.server_squad import get_server_squad_by_uuid @@ -530,7 +587,12 @@ class SubscriptionService: for country_uuid in country_uuids: server = await get_server_squad_by_uuid(db, country_uuid) - if server and server.is_available and not server.is_full: + is_allowed = True + if promo_group_id is not None and server: + allowed_ids = {pg.id for pg in server.allowed_promo_groups} + is_allowed = promo_group_id in allowed_ids + + if server and server.is_available and not server.is_full and is_allowed: price = server.price_kopeks total_price += price prices_list.append(price) @@ -577,7 +639,15 @@ class SubscriptionService: months_in_period = calculate_months_from_days(period_days) - base_price = PERIOD_PRICES.get(period_days, 0) + base_price_original = PERIOD_PRICES.get(period_days, 0) + period_discount_percent = _resolve_discount_percent( + user, + promo_group, + "period", + period_days=period_days, + ) + base_discount_total = base_price_original * period_discount_percent // 100 + base_price = base_price_original - base_discount_total promo_group = promo_group or (user.promo_group if user else None) @@ -637,7 +707,13 @@ class SubscriptionService: total_price = base_price + total_traffic_price + total_servers_price + total_devices_price logger.info(f"Расчет стоимости новой подписки на {period_days} дней ({months_in_period} мес):") - logger.info(f" Период {period_days} дней: {base_price/100}₽") + base_log = f" Период {period_days} дней: {base_price_original/100}₽" + if base_discount_total > 0: + base_log += ( + f" → {base_price/100}₽" + f" (скидка {period_discount_percent}%: -{base_discount_total/100}₽)" + ) + logger.info(base_log) if total_traffic_price > 0: message = ( f" Трафик {traffic_gb} ГБ: {traffic_price_per_month/100}₽/мес x {months_in_period} = {total_traffic_price/100}₽" @@ -681,14 +757,16 @@ class SubscriptionService: months_in_period = calculate_months_from_days(period_days) - base_price = PERIOD_PRICES.get(period_days, 0) + base_price_original = PERIOD_PRICES.get(period_days, 0) if user is None: user = getattr(subscription, "user", None) promo_group = promo_group or (user.promo_group if user else None) servers_price_per_month, _ = await self.get_countries_price_by_uuids( - subscription.connected_squads, db + subscription.connected_squads, + db, + promo_group_id=promo_group.id if promo_group else None, ) servers_discount_percent = _resolve_discount_percent( user, @@ -723,10 +801,25 @@ class SubscriptionService: discounted_traffic_per_month = traffic_price_per_month - traffic_discount_per_month total_traffic_price = discounted_traffic_per_month * months_in_period + period_discount_percent = _resolve_discount_percent( + user, + promo_group, + "period", + period_days=period_days, + ) + base_discount_total = base_price_original * period_discount_percent // 100 + base_price = base_price_original - base_discount_total + total_price = base_price + total_servers_price + total_devices_price + total_traffic_price logger.info(f"💰 Расчет стоимости продления подписки {subscription.id} на {period_days} дней ({months_in_period} мес):") - logger.info(f" 📅 Период {period_days} дней: {base_price/100}₽") + base_log = f" 📅 Период {period_days} дней: {base_price_original/100}₽" + if base_discount_total > 0: + base_log += ( + f" → {base_price/100}₽" + f" (скидка {period_discount_percent}%: -{base_discount_total/100}₽)" + ) + logger.info(base_log) if total_servers_price > 0: message = ( f" 🌍 Серверы: {servers_price_per_month/100}₽/мес x {months_in_period} = {total_servers_price/100}₽" @@ -785,7 +878,7 @@ class SubscriptionService: if additional_traffic_gb > 0: traffic_price_per_month = settings.get_traffic_price(additional_traffic_gb) - traffic_discount_percent = _resolve_discount_percent( + traffic_discount_percent = _resolve_addon_discount_percent( user, promo_group, "traffic", @@ -808,7 +901,7 @@ class SubscriptionService: if additional_devices > 0: devices_price_per_month = additional_devices * settings.PRICE_PER_DEVICE - devices_discount_percent = _resolve_discount_percent( + devices_discount_percent = _resolve_addon_discount_percent( user, promo_group, "devices", @@ -835,7 +928,7 @@ class SubscriptionService: server = await get_server_squad_by_id(db, server_id) if server and server.is_available: server_price_per_month = server.price_kopeks - servers_discount_percent = _resolve_discount_percent( + servers_discount_percent = _resolve_addon_discount_percent( user, promo_group, "servers", diff --git a/app/services/support_settings_service.py b/app/services/support_settings_service.py index 19be0b10..7943b84a 100644 --- a/app/services/support_settings_service.py +++ b/app/services/support_settings_service.py @@ -110,3 +110,112 @@ class SupportSettingsService: return cls._save() + # Notifications & SLA + @classmethod + def get_admin_ticket_notifications_enabled(cls) -> bool: + cls._load() + if "admin_ticket_notifications_enabled" in cls._data: + return bool(cls._data["admin_ticket_notifications_enabled"]) + # fallback to global admin notifications setting + return bool(settings.is_admin_notifications_enabled()) + + @classmethod + def set_admin_ticket_notifications_enabled(cls, enabled: bool) -> bool: + cls._load() + cls._data["admin_ticket_notifications_enabled"] = bool(enabled) + return cls._save() + + @classmethod + def get_user_ticket_notifications_enabled(cls) -> bool: + cls._load() + if "user_ticket_notifications_enabled" in cls._data: + return bool(cls._data["user_ticket_notifications_enabled"]) + # fallback to global enable notifications + return bool(getattr(settings, "ENABLE_NOTIFICATIONS", True)) + + @classmethod + def set_user_ticket_notifications_enabled(cls, enabled: bool) -> bool: + cls._load() + cls._data["user_ticket_notifications_enabled"] = bool(enabled) + return cls._save() + + @classmethod + def get_sla_enabled(cls) -> bool: + cls._load() + if "ticket_sla_enabled" in cls._data: + return bool(cls._data["ticket_sla_enabled"]) + return bool(getattr(settings, "SUPPORT_TICKET_SLA_ENABLED", True)) + + @classmethod + def set_sla_enabled(cls, enabled: bool) -> bool: + cls._load() + cls._data["ticket_sla_enabled"] = bool(enabled) + return cls._save() + + @classmethod + def get_sla_minutes(cls) -> int: + cls._load() + minutes = cls._data.get("ticket_sla_minutes") + if isinstance(minutes, int) and minutes > 0: + return minutes + return int(getattr(settings, "SUPPORT_TICKET_SLA_MINUTES", 5)) + + @classmethod + def set_sla_minutes(cls, minutes: int) -> bool: + try: + minutes_int = int(minutes) + except Exception: + return False + if minutes_int <= 0: + return False + cls._load() + cls._data["ticket_sla_minutes"] = minutes_int + return cls._save() + + # Moderators management + @classmethod + def get_moderators(cls) -> list[int]: + cls._load() + raw = cls._data.get("moderators") or [] + moderators: list[int] = [] + for item in raw: + try: + moderators.append(int(item)) + except Exception: + continue + return moderators + + @classmethod + def is_moderator(cls, telegram_id: int) -> bool: + try: + tid = int(telegram_id) + except Exception: + return False + return tid in cls.get_moderators() + + @classmethod + def add_moderator(cls, telegram_id: int) -> bool: + try: + tid = int(telegram_id) + except Exception: + return False + cls._load() + moderators = set(cls.get_moderators()) + moderators.add(tid) + cls._data["moderators"] = sorted(moderators) + return cls._save() + + @classmethod + def remove_moderator(cls, telegram_id: int) -> bool: + try: + tid = int(telegram_id) + except Exception: + return False + cls._load() + moderators = set(cls.get_moderators()) + if tid in moderators: + moderators.remove(tid) + cls._data["moderators"] = sorted(moderators) + return cls._save() + return True + diff --git a/app/services/user_service.py b/app/services/user_service.py index dedd672a..5b7f57b4 100644 --- a/app/services/user_service.py +++ b/app/services/user_service.py @@ -17,7 +17,8 @@ from app.database.models import ( User, UserStatus, Subscription, Transaction, PromoCode, PromoCodeUse, ReferralEarning, SubscriptionServer, YooKassaPayment, BroadcastHistory, CryptoBotPayment, SubscriptionConversion, UserMessage, WelcomeText, - SentNotification, PromoGroup + SentNotification, PromoGroup, MulenPayPayment, Pal24Payment, + AdvertisingCampaign ) from app.config import settings @@ -493,7 +494,7 @@ class UserService: select(CryptoBotPayment).where(CryptoBotPayment.user_id == user_id) ) cryptobot_payments = cryptobot_result.scalars().all() - + if cryptobot_payments: logger.info(f"🔄 Удаляем {len(cryptobot_payments)} CryptoBot платежей") await db.execute( @@ -508,7 +509,49 @@ class UserService: await db.flush() except Exception as e: logger.error(f"❌ Ошибка удаления CryptoBot платежей: {e}") - + + try: + mulenpay_result = await db.execute( + select(MulenPayPayment).where(MulenPayPayment.user_id == user_id) + ) + mulenpay_payments = mulenpay_result.scalars().all() + + if mulenpay_payments: + logger.info(f"🔄 Удаляем {len(mulenpay_payments)} MulenPay платежей") + await db.execute( + update(MulenPayPayment) + .where(MulenPayPayment.user_id == user_id) + .values(transaction_id=None) + ) + await db.flush() + await db.execute( + delete(MulenPayPayment).where(MulenPayPayment.user_id == user_id) + ) + await db.flush() + except Exception as e: + logger.error(f"❌ Ошибка удаления MulenPay платежей: {e}") + + try: + pal24_result = await db.execute( + select(Pal24Payment).where(Pal24Payment.user_id == user_id) + ) + pal24_payments = pal24_result.scalars().all() + + if pal24_payments: + logger.info(f"🔄 Удаляем {len(pal24_payments)} Pal24 платежей") + await db.execute( + update(Pal24Payment) + .where(Pal24Payment.user_id == user_id) + .values(transaction_id=None) + ) + await db.flush() + await db.execute( + delete(Pal24Payment).where(Pal24Payment.user_id == user_id) + ) + await db.flush() + except Exception as e: + logger.error(f"❌ Ошибка удаления Pal24 платежей: {e}") + try: transactions_result = await db.execute( select(Transaction).where(Transaction.user_id == user_id) @@ -589,7 +632,7 @@ class UserService: select(BroadcastHistory).where(BroadcastHistory.admin_id == user_id) ) broadcast_history = broadcast_history_result.scalars().all() - + if broadcast_history: logger.info(f"🔄 Удаляем {len(broadcast_history)} записей истории рассылок") await db.execute( @@ -598,6 +641,23 @@ class UserService: await db.flush() except Exception as e: logger.error(f"❌ Ошибка удаления истории рассылок: {e}") + + try: + campaigns_result = await db.execute( + select(AdvertisingCampaign).where(AdvertisingCampaign.created_by == user_id) + ) + campaigns = campaigns_result.scalars().all() + + if campaigns: + logger.info(f"🔄 Очищаем создателя у {len(campaigns)} рекламных кампаний") + await db.execute( + update(AdvertisingCampaign) + .where(AdvertisingCampaign.created_by == user_id) + .values(created_by=None) + ) + await db.flush() + except Exception as e: + logger.error(f"❌ Ошибка обновления рекламных кампаний: {e}") try: if user.subscription: diff --git a/app/states.py b/app/states.py index 2207c448..f824f9a5 100644 --- a/app/states.py +++ b/app/states.py @@ -68,20 +68,26 @@ class AdminStates(StatesGroup): creating_promo_group_traffic_discount = State() creating_promo_group_server_discount = State() creating_promo_group_device_discount = State() + creating_promo_group_period_discount = State() + creating_promo_group_auto_assign = State() + editing_promo_group_menu = State() editing_promo_group_name = State() editing_promo_group_traffic_discount = State() editing_promo_group_server_discount = State() editing_promo_group_device_discount = State() + editing_promo_group_period_discount = State() + editing_promo_group_auto_assign = State() editing_squad_price = State() editing_traffic_price = State() editing_device_price = State() editing_user_devices = State() editing_user_traffic = State() - + editing_rules_page = State() - + editing_notification_value = State() + confirming_sync = State() editing_server_name = State() @@ -89,6 +95,7 @@ class AdminStates(StatesGroup): editing_server_country = State() editing_server_limit = State() editing_server_description = State() + editing_server_promo_groups = State() creating_server_uuid = State() creating_server_name = State() diff --git a/app/utils/cache.py b/app/utils/cache.py index aeed54f7..88eaa1cf 100644 --- a/app/utils/cache.py +++ b/app/utils/cache.py @@ -67,13 +67,28 @@ class CacheService: async def delete(self, key: str) -> bool: if not self._connected: return False - + try: deleted = await self.redis_client.delete(key) return deleted > 0 except Exception as e: logger.error(f"Ошибка удаления из кеша {key}: {e}") return False + + async def delete_pattern(self, pattern: str) -> int: + if not self._connected: + return 0 + + try: + keys = await self.redis_client.keys(pattern) + if not keys: + return 0 + + deleted = await self.redis_client.delete(*keys) + return int(deleted) + except Exception as e: + logger.error(f"Ошибка удаления ключей по шаблону {pattern}: {e}") + return 0 async def exists(self, key: str) -> bool: if not self._connected: diff --git a/app/utils/payment_utils.py b/app/utils/payment_utils.py index 4b6ecaa4..83b704b1 100644 --- a/app/utils/payment_utils.py +++ b/app/utils/payment_utils.py @@ -30,17 +30,35 @@ def get_available_payment_methods() -> List[Dict[str, str]]: if settings.TRIBUTE_ENABLED: methods.append({ "id": "tribute", - "name": "Банковская карта", + "name": "Банковская карта", "icon": "💳", "description": "через Tribute", "callback": "topup_tribute" }) - + + if settings.is_mulenpay_enabled(): + methods.append({ + "id": "mulenpay", + "name": "Банковская карта", + "icon": "💳", + "description": "через Mulen Pay", + "callback": "topup_mulenpay" + }) + + if settings.is_pal24_enabled(): + methods.append({ + "id": "pal24", + "name": "Банковская карта", + "icon": "💳", + "description": "через PayPalych", + "callback": "topup_pal24" + }) + if settings.is_cryptobot_enabled(): methods.append({ "id": "cryptobot", "name": "Криптовалюта", - "icon": "🪙", + "icon": "🪙", "description": "через CryptoBot", "callback": "topup_cryptobot" }) @@ -112,6 +130,10 @@ def is_payment_method_available(method_id: str) -> bool: return settings.is_yookassa_enabled() elif method_id == "tribute": return settings.TRIBUTE_ENABLED + elif method_id == "mulenpay": + return settings.is_mulenpay_enabled() + elif method_id == "pal24": + return settings.is_pal24_enabled() elif method_id == "cryptobot": return settings.is_cryptobot_enabled() elif method_id == "support": @@ -127,6 +149,8 @@ def get_payment_method_status() -> Dict[str, bool]: "stars": settings.TELEGRAM_STARS_ENABLED, "yookassa": settings.is_yookassa_enabled(), "tribute": settings.TRIBUTE_ENABLED, + "mulenpay": settings.is_mulenpay_enabled(), + "pal24": settings.is_pal24_enabled(), "cryptobot": settings.is_cryptobot_enabled(), "support": True } @@ -139,9 +163,13 @@ def get_enabled_payment_methods_count() -> int: if settings.TELEGRAM_STARS_ENABLED: count += 1 if settings.is_yookassa_enabled(): - count += 1 + count += 1 if settings.TRIBUTE_ENABLED: count += 1 + if settings.is_mulenpay_enabled(): + count += 1 + if settings.is_pal24_enabled(): + count += 1 if settings.is_cryptobot_enabled(): count += 1 return count \ No newline at end of file diff --git a/app/utils/pricing_utils.py b/app/utils/pricing_utils.py index f6ca0d5a..40d7f589 100644 --- a/app/utils/pricing_utils.py +++ b/app/utils/pricing_utils.py @@ -1,5 +1,4 @@ from datetime import datetime, timedelta -from datetime import datetime from typing import Tuple import logging diff --git a/app/utils/subscription_utils.py b/app/utils/subscription_utils.py index f3161202..a2741b5d 100644 --- a/app/utils/subscription_utils.py +++ b/app/utils/subscription_utils.py @@ -1,9 +1,11 @@ import logging from datetime import datetime from typing import Optional -from sqlalchemy import select, delete +from urllib.parse import quote +from sqlalchemy import select, delete, func from sqlalchemy.ext.asyncio import AsyncSession from app.database.models import Subscription, User +from app.config import settings logger = logging.getLogger(__name__) @@ -93,5 +95,49 @@ async def cleanup_duplicate_subscriptions(db: AsyncSession) -> int: await db.commit() logger.info(f"🧹 Очищено {total_deleted} дублирующихся подписок") - + return total_deleted + + +def get_display_subscription_link(subscription: Optional[Subscription]) -> Optional[str]: + if not subscription: + return None + + base_link = getattr(subscription, "subscription_url", None) + + if settings.is_happ_cryptolink_mode(): + crypto_link = getattr(subscription, "subscription_crypto_link", None) + return crypto_link or base_link + + return base_link + + +def get_happ_cryptolink_redirect_link(subscription_link: Optional[str]) -> Optional[str]: + if not subscription_link: + return None + + template = settings.get_happ_cryptolink_redirect_template() + if not template: + return None + + encoded_link = quote(subscription_link, safe="") + replacements = { + "{subscription_link}": encoded_link, + "{link}": encoded_link, + "{subscription_link_raw}": subscription_link, + "{link_raw}": subscription_link, + } + + replaced = False + for placeholder, value in replacements.items(): + if placeholder in template: + template = template.replace(placeholder, value) + replaced = True + + if replaced: + return template + + if template.endswith(("=", "?", "&")): + return f"{template}{encoded_link}" + + return f"{template}{encoded_link}" diff --git a/locales/en.json b/locales/en.json index e72fd5e4..57e44987 100644 --- a/locales/en.json +++ b/locales/en.json @@ -2,6 +2,7 @@ "ADD_COUNTRIES_BUTTON": "🌐 Add countries", "ADMIN_MAIN_MENU": "🏠 Main menu", "ADMIN_CAMPAIGNS": "📣 Promotional campaigns", + "ADMIN_REPORTS": "📊 Reports", "AUTOPAY_BUTTON": "💳 Auto payment", "AUTOPAY_SET_DAYS_BUTTON": "⚙️ Configure days", "BACK": "⬅️ Back", @@ -19,6 +20,16 @@ "CONFIRM": "✅ Confirm", "CONFIRM_CHANGE_BUTTON": "✅ Confirm change", "CONNECT_BUTTON": "🔗 Connect", + "HAPP_DOWNLOAD_BUTTON": "⬇️ Download Happ", + "HAPP_DOWNLOAD_PROMPT": "📥 Download Happ\nChoose your device:", + "HAPP_PLATFORM_IOS": "🍎 iOS", + "HAPP_PLATFORM_ANDROID": "🤖 Android", + "HAPP_PLATFORM_MACOS": "🖥️ Mac OS", + "HAPP_PLATFORM_WINDOWS": "💻 Windows", + "HAPP_PLATFORM_PC": "💻 PC", + "HAPP_DOWNLOAD_LINK_MESSAGE": "⬇️ Download Happ for {platform}:", + "HAPP_DOWNLOAD_LINK_NOT_SET": "❌ Download link for this device is not configured", + "HAPP_DOWNLOAD_OPEN_LINK": "🔗 Open link", "CONTINUE": "➡️ Continue", "CONTINUE_BUTTON": "➡️ Continue", "COPY_SUBSCRIPTION_LINK": "📋 Copy subscription link", @@ -37,6 +48,7 @@ "GO_TO_BALANCE_TOP_UP": "💳 Go to balance top up", "RETURN_TO_SUBSCRIPTION_CHECKOUT": "⬅️ Return to subscription checkout", "INSUFFICIENT_BALANCE": "❌ Insufficient balance.\n\nTop up {amount} and try again.", + "ADDON_INSUFFICIENT_FUNDS_MESSAGE": "⚠️ Insufficient funds\n\nService price: {required}\nBalance: {balance}\nMissing: {missing}\n\nChoose a top-up method. The amount will be filled in automatically.", "LANGUAGE_SELECTED": "🌐 Interface language set: English", "LOADING": "⏳ Loading...", "MAIN_MENU": "👤 {user_name}\n\n📱 Subscription: {subscription_status}\n\nChoose an option:\n", @@ -56,6 +68,8 @@ "PAGINATION_PREV": "⬅️", "PAYMENTS_TEMPORARILY_UNAVAILABLE": "⚠️ Payment methods are temporarily unavailable", "PAYMENT_CARD_TRIBUTE": "💳 Bank card (Tribute)", + "PAYMENT_CARD_MULENPAY": "💳 Bank card (Mulen Pay)", + "PAYMENT_CARD_PAL24": "💳 Bank card (PayPalych)", "PAYMENT_CARD_YOOKASSA": "💳 Bank card (YooKassa)", "PAYMENT_CRYPTOBOT": "🪙 Cryptocurrency (CryptoBot)", "PAYMENT_SBP_YOOKASSA": "🏦 Pay via SBP (YooKassa)", @@ -63,6 +77,14 @@ "PAYMENT_VIA_SUPPORT": "🛠️ Via support", "PAY_NOW_BUTTON": "💳 Pay", "PAY_WITH_COINS_BUTTON": "🪙 Pay", + "MULENPAY_TOPUP_PROMPT": "💳 Mulen Pay payment\n\nEnter an amount between 100 and 100,000 ₽.\nThe payment is processed by the secure Mulen Pay platform.", + "MULENPAY_PAYMENT_ERROR": "❌ Failed to create Mulen Pay payment. Please try again later or contact support.", + "MULENPAY_PAY_BUTTON": "💳 Pay with Mulen Pay", + "MULENPAY_PAYMENT_INSTRUCTIONS": "💳 Mulen Pay payment\n\n💰 Amount: {amount}\n🆔 Payment ID: {payment_id}\n\n📱 How to pay:\n1. Press ‘Pay with Mulen Pay’\n2. Follow the instructions on the payment page\n3. Confirm the transfer\n4. Funds will be credited automatically\n\n❓ Need help? Contact {support}", + "PAL24_TOPUP_PROMPT": "💳 PayPalych payment\n\nEnter an amount between 100 and 1,000,000 ₽.\nThe payment is processed by the secure PayPalych platform.", + "PAL24_PAYMENT_ERROR": "❌ Failed to create a PayPalych payment. Please try again later or contact support.", + "PAL24_PAY_BUTTON": "💳 Pay with PayPalych", + "PAL24_PAYMENT_INSTRUCTIONS": "💳 PayPalych payment\n\n💰 Amount: {amount}\n🆔 Invoice ID: {bill_id}\n\n📱 How to pay:\n1. Press ‘Pay with PayPalych’\n2. Follow the system prompts\n3. Confirm the transfer\n4. Funds will be credited automatically\n\n❓ Need help? Contact {support}", "PENDING_CANCEL_BUTTON": "⌛ Cancel", "POST_REGISTRATION_TRIAL_BUTTON": "🚀 Activate free trial 🚀", "REFERRAL_ANALYTICS_BUTTON": "📊 Analytics", @@ -95,6 +117,7 @@ "SUB_STATUS_TRIAL_TODAY": "🎁 Trial subscription\n⚠️ expires today!", "SUB_STATUS_TRIAL_TOMORROW": "🎁 Trial subscription\n⚠️ expires tomorrow!", "SUBSCRIPTION_ACTIVE": "✅ Active", + "SUBSCRIPTION_EXTEND": "💎 Extend subscription", "SUCCESS": "✅ Success", "REGISTRATION_COMPLETING": "✅ Completing registration...", "SWITCH_TRAFFIC_BUTTON": "🔄 Switch traffic", @@ -117,6 +140,7 @@ "ACCESS_DENIED": "❌ Access denied", "ADMIN_MESSAGES": "📨 Broadcasts", "ADMIN_MONITORING": "🔍 Monitoring", + "ADMIN_MONITORING_SETTINGS": "⚙️ Monitoring settings", "ADMIN_PANEL": "\n⚙️ Administration panel\n\nSelect a section to manage:\n", "ADMIN_PROMOCODES": "🎫 Promo codes", "ADMIN_REFERRALS": "🤝 Referral program", @@ -126,13 +150,24 @@ "ADMIN_PROMO_GROUPS": "💳 Promo groups", "ADMIN_PROMO_GROUPS_TITLE": "💳 Promo groups", "ADMIN_PROMO_GROUPS_SUMMARY": "Groups total: {count}\nMembers total: {members}", - "ADMIN_PROMO_GROUPS_DISCOUNTS": "Discounts — servers: {servers}%, traffic: {traffic}%, devices: {devices}%", + "ADMIN_PROMO_GROUP_DISCOUNTS_HEADER": "💸 Promo group discounts:", + "ADMIN_PROMO_GROUP_DISCOUNT_LINE_SERVERS": "• Servers: {percent}%", + "ADMIN_PROMO_GROUP_DISCOUNT_LINE_TRAFFIC": "• Traffic: {percent}%", + "ADMIN_PROMO_GROUP_DISCOUNT_LINE_DEVICES": "• Devices: {percent}%", + "ADMIN_PROMO_GROUP_ADDON_DISCOUNT_ENABLED": "🧩 Add-on discounts: enabled", + "ADMIN_PROMO_GROUP_ADDON_DISCOUNT_DISABLED": "🧩 Add-on discounts: disabled", + "ADMIN_PROMO_GROUP_TOGGLE_ADDON_DISCOUNT_ENABLE": "🧩 Enable add-on discounts", + "ADMIN_PROMO_GROUP_TOGGLE_ADDON_DISCOUNT_DISABLE": "🧩 Disable add-on discounts", + "ADMIN_PROMO_GROUP_ADDON_DISCOUNT_UPDATED_ENABLED": "🧩 Add-on purchase discounts are enabled.", + "ADMIN_PROMO_GROUP_ADDON_DISCOUNT_UPDATED_DISABLED": "🧩 Add-on purchase discounts are disabled.", + "ADMIN_PROMO_GROUP_PERIOD_DISCOUNTS_HEADER": "⏳ Period discounts:", "ADMIN_PROMO_GROUPS_DEFAULT_LABEL": " (default)", "ADMIN_PROMO_GROUPS_MEMBERS_COUNT": "Members: {count}", "ADMIN_PROMO_GROUPS_EMPTY": "No promo groups found.", "CREATE_TICKET_BUTTON": "🎫 Create ticket", "MY_TICKETS_BUTTON": "📋 My tickets", "CONTACT_SUPPORT_BUTTON": "💬 Contact support", + "SUPPORT_BUTTON": "🆘 Support", "TICKET_PRIORITY_SELECT": "Select ticket priority:", "TICKET_PRIORITY_LOW": "🟢 Low", "TICKET_PRIORITY_NORMAL": "🟡 Normal", @@ -178,6 +213,9 @@ "MARK_AS_ANSWERED": "✅ Mark as answered", "TICKET_REPLY_NOTIFICATION": "🎫 Reply received for ticket #{ticket_id}\n\n{reply_preview}\n\nClick the button below to go to the ticket:", "CLOSE_NOTIFICATION": "❌ Close notification", + "REPORT_CLOSE": "❌ Close", + "REPORT_CLOSED": "✅ Report closed.", + "REPORT_CLOSE_ERROR": "❌ Failed to close the report.", "NOTIFICATION_CLOSED": "Notification closed.", "UNBLOCK": "✅ Unblock", "BLOCK_FOREVER": "🚫 Block permanently", @@ -214,14 +252,30 @@ "ADMIN_PROMO_GROUP_CREATE_TRAFFIC_PROMPT": "Enter traffic discount (0-100):", "ADMIN_PROMO_GROUP_CREATE_SERVERS_PROMPT": "Enter server discount (0-100):", "ADMIN_PROMO_GROUP_CREATE_DEVICES_PROMPT": "Enter device discount (0-100):", + "ADMIN_PROMO_GROUP_CREATE_PERIOD_PROMPT": "Enter subscription period discounts (e.g. 30:10, 90:15). Send 0 if none.", "ADMIN_PROMO_GROUP_INVALID_PERCENT": "Enter a number from 0 to 100.", + "ADMIN_PROMO_GROUP_INVALID_PERIOD_DISCOUNTS": "Enter period:discount pairs separated by commas, e.g. 30:10, 90:15, or 0.", "ADMIN_PROMO_GROUP_CREATED": "Promo group “{name}” created.", "ADMIN_PROMO_GROUP_CREATED_BACK_BUTTON": "↩️ Back to promo groups", "ADMIN_PROMO_GROUP_EDIT_NAME_PROMPT": "Enter a new name (current: {name}):", - "ADMIN_PROMO_GROUP_EDIT_TRAFFIC_PROMPT": "Enter new traffic discount (0-100):", - "ADMIN_PROMO_GROUP_EDIT_SERVERS_PROMPT": "Enter new server discount (0-100):", - "ADMIN_PROMO_GROUP_EDIT_DEVICES_PROMPT": "Enter new device discount (0-100):", + "ADMIN_PROMO_GROUP_EDIT_TRAFFIC_PROMPT": "Enter new traffic discount (0-100). Current value: {current}.", + "ADMIN_PROMO_GROUP_EDIT_SERVERS_PROMPT": "Enter new server discount (0-100). Current value: {current}.", + "ADMIN_PROMO_GROUP_EDIT_DEVICES_PROMPT": "Enter new device discount (0-100). Current value: {current}.", + "ADMIN_PROMO_GROUP_EDIT_PERIOD_PROMPT": "Enter new period discounts (current: {current}). Send 0 if none.", "ADMIN_PROMO_GROUP_UPDATED": "Promo group “{name}” updated.", + "ADMIN_PROMO_GROUP_AUTO_ASSIGN_DISABLED": "Auto assignment by total spending: disabled", + "ADMIN_PROMO_GROUP_AUTO_ASSIGN_LINE": "Auto assignment by total spending from {amount} ₽", + "ADMIN_PROMO_GROUP_EDIT_MENU_TITLE": "✏️ Promo group settings “{name}”", + "ADMIN_PROMO_GROUP_EDIT_MENU_HINT": "Select a parameter to change:", + "ADMIN_PROMO_GROUP_EDIT_FIELD_NAME": "✏️ Rename", + "ADMIN_PROMO_GROUP_EDIT_FIELD_TRAFFIC": "🌐 Traffic discount", + "ADMIN_PROMO_GROUP_EDIT_FIELD_SERVERS": "🖥 Server discount", + "ADMIN_PROMO_GROUP_EDIT_FIELD_DEVICES": "📱 Device discount", + "ADMIN_PROMO_GROUP_EDIT_FIELD_PERIODS": "⏳ Period discounts", + "ADMIN_PROMO_GROUP_EDIT_FIELD_AUTO_ASSIGN": "🤖 Auto assignment by spending", + "ADMIN_PROMO_GROUP_CREATE_AUTO_ASSIGN_PROMPT": "Enter total spending (in ₽) required for automatic assignment. Send 0 to disable.", + "ADMIN_PROMO_GROUP_INVALID_AUTO_ASSIGN": "Enter a non-negative amount in rubles or 0 to disable.", + "ADMIN_PROMO_GROUP_EDIT_AUTO_ASSIGN_PROMPT": "Enter total spending (in ₽) for auto assignment. Current value: {current}.", "ADMIN_PROMO_GROUP_MEMBERS_TITLE": "👥 Members of {name}", "ADMIN_PROMO_GROUP_MEMBERS_EMPTY": "This group has no members yet.", "ADMIN_PROMO_GROUP_DELETE_FORBIDDEN": "The default promo group cannot be deleted.", @@ -239,6 +293,7 @@ "BALANCE_INFO": "\n💰 Balance: {balance}\n\nChoose an action:\n", "BALANCE_SUPPORT_REQUEST": "🛠️ Request via support", "BALANCE_TOP_UP": "💳 Top up", + "BALANCE_TOPUP": "💳 Top up balance", "CAMPAIGN_EXISTING_USER": "ℹ️ This promo link is available only to new users.", "CAMPAIGN_BONUS_BALANCE": "🎉 You received {amount} for registering via the \"{name}\" campaign!", "CAMPAIGN_BONUS_SUBSCRIPTION": "🎉 You’ve been granted a {days}-day subscription (traffic: {traffic}, devices: {devices}) from the \"{name}\" campaign!", @@ -349,6 +404,7 @@ "SUBSCRIPTION_CONNECT_LINK_PROMPT": "📱 Copy the link and add it to your VPN app", "SUBSCRIPTION_IMPORT_LINK_SECTION": "🔗 Your import link for the VPN app:\n{subscription_url}", "SUBSCRIPTION_IMPORT_INSTRUCTION_PROMPT": "📱 Tap the button below to get setup instructions for your device", + "SUBSCRIPTION_HAPP_LINK_PROMPT": "🔒 Subscription link is ready. Tap the \"Connect\" button below to open it in Happ.", "BACK_TO_MAIN_MENU_BUTTON": "⬅️ Back to main menu", "CUSTOM_MINIAPP_URL_NOT_SET": "⚠ Custom mini-app link is not configured", "SUBSCRIPTION_LINK_GENERATING_NOTICE": "{purchase_text}\n\nThe link is being generated, open the 'My subscription' section in a few seconds.", @@ -360,6 +416,10 @@ "SUBSCRIPTION_LINK_UNAVAILABLE": "❌ Subscription link is unavailable", "SUBSCRIPTION_DEVICE_APPS_NOT_FOUND": "❌ No apps found for this device", "SUBSCRIPTION_DEVICE_GUIDE_TITLE": "📱 Setup for {device_name}", + "SUBSCRIPTION_HAPP_OPEN_TITLE": "🔗 Connect via Happ", + "SUBSCRIPTION_HAPP_OPEN_LINK": "🔓 Open link in Happ", + "SUBSCRIPTION_HAPP_OPEN_HINT": "💡 If the link doesn't open automatically, copy it manually: {subscription_link}", + "SUBSCRIPTION_HAPP_OPEN_BUTTON_HINT": "▶️ Tap the \"Connect\" button below to open Happ and add the subscription automatically.", "SUBSCRIPTION_DEVICE_LINK_TITLE": "🔗 Subscription link:", "SUBSCRIPTION_DEVICE_FEATURED_APP": "📋 Recommended app: {app_name}", "SUBSCRIPTION_DEVICE_STEP_INSTALL_TITLE": "Step 1 - Install:", @@ -444,9 +504,31 @@ "PAYMENT_METHOD_YOOKASSA_DESCRIPTION": "via YooKassa", "PAYMENT_METHOD_TRIBUTE_NAME": "💳 Bank card", "PAYMENT_METHOD_TRIBUTE_DESCRIPTION": "via Tribute", + "PAYMENT_METHOD_MULENPAY_NAME": "💳 Bank card (Mulen Pay)", + "PAYMENT_METHOD_MULENPAY_DESCRIPTION": "via Mulen Pay", + "PAYMENT_METHOD_PAL24_NAME": "💳 Bank card (PayPalych)", + "PAYMENT_METHOD_PAL24_DESCRIPTION": "via PayPalych", "PAYMENT_METHOD_CRYPTOBOT_NAME": "🪙 Cryptocurrency", "PAYMENT_METHOD_CRYPTOBOT_DESCRIPTION": "via CryptoBot", "PAYMENT_METHOD_SUPPORT_NAME": "🛠️ Support team", "PAYMENT_METHOD_SUPPORT_DESCRIPTION": "other options", - "PAYMENT_METHODS_UNAVAILABLE_ALERT": "⚠️ Automated payment methods are temporarily unavailable. Contact support to top up your balance." + "PAYMENT_METHODS_UNAVAILABLE_ALERT": "⚠️ Automated payment methods are temporarily unavailable. Contact support to top up your balance.", + "TRIAL_INACTIVE_1H": "⏳ An hour has passed and we haven't seen any traffic yet\n\nOpen the connection guide and follow the steps. We're always ready to help!", + "TRIAL_INACTIVE_24H": "⏳ A full day passed without activity\n\nWe still don't see traffic from your test subscription. Use the guide or message support and we'll help you connect!", + "SUBSCRIPTION_EXPIRED_1D": "⛔ Your subscription expired\n\nAccess was disabled on {end_date}. Renew to return to the service.\n\n💎 Renewal price: {price}", + "SUBSCRIPTION_EXPIRED_SECOND_WAVE": "🔥 {percent}% discount on renewal\n\nTap “Get discount” and we'll add {bonus} to your balance. The offer is valid until {expires_at}.", + "SUBSCRIPTION_EXPIRED_THIRD_WAVE": "🎁 Personal {percent}% discount\n\nIt's been {trigger_days} days without a subscription. Come back — tap “Get discount” and {bonus} will be credited. Offer valid until {expires_at}.", + "DISCOUNT_CLAIM_SUCCESS": "🎉 Discount of {percent}% activated! {amount} credited to your balance.", + "DISCOUNT_CLAIM_ALREADY": "ℹ️ This discount has already been activated.", + "DISCOUNT_CLAIM_EXPIRED": "⚠️ The offer has expired.", + "DISCOUNT_CLAIM_NOT_FOUND": "❌ Offer not found.", + "DISCOUNT_CLAIM_ERROR": "❌ Failed to credit the discount. Please try again later.", + "DISCOUNT_BONUS_DESCRIPTION": "Renewal discount bonus", + "NOTIFICATION_VALUE_INVALID": "❌ Invalid value, please enter a number.", + "NOTIFICATION_VALUE_UPDATED": "✅ Settings updated.", + "NOTIFY_PROMPT_SECOND_PERCENT": "Enter a new discount percentage for the 2-3 day reminder (0-100):", + "NOTIFY_PROMPT_SECOND_HOURS": "Enter the number of hours the discount is active (1-168):", + "NOTIFY_PROMPT_THIRD_PERCENT": "Enter a new discount percentage for the late offer (0-100):", + "NOTIFY_PROMPT_THIRD_HOURS": "Enter the number of hours the late discount is active (1-168):", + "NOTIFY_PROMPT_THIRD_DAYS": "After how many days without a subscription should we send the offer? (minimum 2):" } diff --git a/locales/ru.json b/locales/ru.json index 2c6c839d..49f1ab43 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -5,6 +5,8 @@ "ADMIN_CAMPAIGNS": "📣 Рекламные кампании", "ADMIN_MESSAGES": "📨 Рассылки", "ADMIN_MONITORING": "🔍 Мониторинг", + "ADMIN_MONITORING_SETTINGS": "⚙️ Настройки мониторинга", + "ADMIN_REPORTS": "📊 Отчеты", "ADMIN_PANEL": "\n⚙️ Административная панель\n\nВыберите раздел для управления:\n", "ADMIN_PROMOCODES": "🎫 Промокоды", "ADMIN_REFERRALS": "🤝 Партнерка", @@ -14,13 +16,24 @@ "ADMIN_PROMO_GROUPS": "💳 Промогруппы", "ADMIN_PROMO_GROUPS_TITLE": "💳 Промогруппы", "ADMIN_PROMO_GROUPS_SUMMARY": "Всего групп: {count}\nВсего участников: {members}", - "ADMIN_PROMO_GROUPS_DISCOUNTS": "Скидки — серверы: {servers}%, трафик: {traffic}%, устройства: {devices}%", + "ADMIN_PROMO_GROUP_DISCOUNTS_HEADER": "💸 Скидки промогруппы:", + "ADMIN_PROMO_GROUP_DISCOUNT_LINE_SERVERS": "• Серверы: {percent}%", + "ADMIN_PROMO_GROUP_DISCOUNT_LINE_TRAFFIC": "• Трафик: {percent}%", + "ADMIN_PROMO_GROUP_DISCOUNT_LINE_DEVICES": "• Устройства: {percent}%", + "ADMIN_PROMO_GROUP_ADDON_DISCOUNT_ENABLED": "🧩 Скидки на доп. услуги: включены", + "ADMIN_PROMO_GROUP_ADDON_DISCOUNT_DISABLED": "🧩 Скидки на доп. услуги: отключены", + "ADMIN_PROMO_GROUP_TOGGLE_ADDON_DISCOUNT_ENABLE": "🧩 Включить скидки на доп. услуги", + "ADMIN_PROMO_GROUP_TOGGLE_ADDON_DISCOUNT_DISABLE": "🧩 Отключить скидки на доп. услуги", + "ADMIN_PROMO_GROUP_ADDON_DISCOUNT_UPDATED_ENABLED": "🧩 Скидки на докупку доп. услуг включены.", + "ADMIN_PROMO_GROUP_ADDON_DISCOUNT_UPDATED_DISABLED": "🧩 Скидки на докупку доп. услуг отключены.", + "ADMIN_PROMO_GROUP_PERIOD_DISCOUNTS_HEADER": "⏳ Скидки по периодам:", "ADMIN_PROMO_GROUPS_DEFAULT_LABEL": " (базовая)", "ADMIN_PROMO_GROUPS_MEMBERS_COUNT": "Участников: {count}", "ADMIN_PROMO_GROUPS_EMPTY": "Промогруппы не найдены.", "CREATE_TICKET_BUTTON": "🎫 Создать тикет", "MY_TICKETS_BUTTON": "📋 Мои тикеты", "CONTACT_SUPPORT_BUTTON": "💬 Связаться с поддержкой", + "SUPPORT_BUTTON": "🆘 Поддержка", "TICKET_PRIORITY_SELECT": "Выберите приоритет тикета:", "TICKET_PRIORITY_LOW": "🟢 Низкий", "TICKET_PRIORITY_NORMAL": "🟡 Обычный", @@ -30,7 +43,7 @@ "TICKET_TITLE_INPUT": "Введите заголовок тикета:", "TICKET_TITLE_TOO_SHORT": "Заголовок должен содержать минимум 5 символов. Попробуйте еще раз:", "TICKET_TITLE_TOO_LONG": "Заголовок слишком длинный. Максимум 255 символов. Попробуйте еще раз:", - "TICKET_MESSAGE_INPUT": "Опишите проблему (до 500 символов) или отправьте фото без текста:", + "TICKET_MESSAGE_INPUT": "Опишите проблему (до 500 символов) или отправьте фото c подписью:", "TICKET_MESSAGE_TOO_SHORT": "Сообщение должно содержать минимум 10 символов. Попробуйте еще раз:", "TICKET_CREATED_SUCCESS": "✅ Тикет #{ticket_id} успешно создан!\n\nЗаголовок: {title}\n\nМы ответим вам в ближайшее время.", "VIEW_TICKET": "👁️ Посмотреть тикет", @@ -66,9 +79,12 @@ "MARK_AS_ANSWERED": "✅ Отметить как отвеченный", "TICKET_REPLY_NOTIFICATION": "🎫 Получен ответ по тикету #{ticket_id}\n\n{reply_preview}\n\nНажмите кнопку ниже, чтобы перейти к тикету:", "CLOSE_NOTIFICATION": "❌ Закрыть уведомление", + "REPORT_CLOSE": "❌ Закрыть", + "REPORT_CLOSED": "✅ Отчет закрыт.", + "REPORT_CLOSE_ERROR": "❌ Не удалось закрыть отчет.", "NOTIFICATION_CLOSED": "Уведомление закрыто.", "UNBLOCK": "✅ Разблокировать", - "BLOCK_FOREVER": "🚫 Блок навсегда", + "BLOCK_FOREVER": "🚫 Заблокировать", "BLOCK_BY_TIME": "⏳ Блокировка по времени", "ENTER_BLOCK_MINUTES": "Введите количество минут для блокировки пользователя (например, 15):", "TICKET_ATTACHMENTS": "📎 Вложения", @@ -102,14 +118,30 @@ "ADMIN_PROMO_GROUP_CREATE_TRAFFIC_PROMPT": "Введите скидку на трафик (0-100):", "ADMIN_PROMO_GROUP_CREATE_SERVERS_PROMPT": "Введите скидку на серверы (0-100):", "ADMIN_PROMO_GROUP_CREATE_DEVICES_PROMPT": "Введите скидку на устройства (0-100):", + "ADMIN_PROMO_GROUP_CREATE_PERIOD_PROMPT": "Введите скидки на периоды подписки (например, 30:10, 90:15). Отправьте 0, если без скидок.", "ADMIN_PROMO_GROUP_INVALID_PERCENT": "Введите число от 0 до 100.", + "ADMIN_PROMO_GROUP_INVALID_PERIOD_DISCOUNTS": "Введите пары период:скидка через запятую, например 30:10, 90:15, или 0.", "ADMIN_PROMO_GROUP_CREATED": "Промогруппа «{name}» создана.", "ADMIN_PROMO_GROUP_CREATED_BACK_BUTTON": "↩️ К промогруппам", "ADMIN_PROMO_GROUP_EDIT_NAME_PROMPT": "Введите новое название промогруппы (текущее: {name}):", - "ADMIN_PROMO_GROUP_EDIT_TRAFFIC_PROMPT": "Введите новую скидку на трафик (0-100):", - "ADMIN_PROMO_GROUP_EDIT_SERVERS_PROMPT": "Введите новую скидку на серверы (0-100):", - "ADMIN_PROMO_GROUP_EDIT_DEVICES_PROMPT": "Введите новую скидку на устройства (0-100):", + "ADMIN_PROMO_GROUP_EDIT_TRAFFIC_PROMPT": "Введите новую скидку на трафик (0-100). Текущее значение: {current}.", + "ADMIN_PROMO_GROUP_EDIT_SERVERS_PROMPT": "Введите новую скидку на серверы (0-100). Текущее значение: {current}.", + "ADMIN_PROMO_GROUP_EDIT_DEVICES_PROMPT": "Введите новую скидку на устройства (0-100). Текущее значение: {current}.", + "ADMIN_PROMO_GROUP_EDIT_PERIOD_PROMPT": "Введите новые скидки на периоды (текущие: {current}). Отправьте 0, если без скидок.", "ADMIN_PROMO_GROUP_UPDATED": "Промогруппа «{name}» обновлена.", + "ADMIN_PROMO_GROUP_AUTO_ASSIGN_DISABLED": "Автовыдача по суммарным тратам: отключена", + "ADMIN_PROMO_GROUP_AUTO_ASSIGN_LINE": "Автовыдача по суммарным тратам: от {amount} ₽", + "ADMIN_PROMO_GROUP_EDIT_MENU_TITLE": "✏️ Настройки промогруппы «{name}»", + "ADMIN_PROMO_GROUP_EDIT_MENU_HINT": "Выберите параметр для изменения:", + "ADMIN_PROMO_GROUP_EDIT_FIELD_NAME": "✏️ Изменить название", + "ADMIN_PROMO_GROUP_EDIT_FIELD_TRAFFIC": "🌐 Скидка на трафик", + "ADMIN_PROMO_GROUP_EDIT_FIELD_SERVERS": "🖥 Скидка на серверы", + "ADMIN_PROMO_GROUP_EDIT_FIELD_DEVICES": "📱 Скидка на устройства", + "ADMIN_PROMO_GROUP_EDIT_FIELD_PERIODS": "⏳ Скидки по периодам", + "ADMIN_PROMO_GROUP_EDIT_FIELD_AUTO_ASSIGN": "🤖 Автовыдача по тратам", + "ADMIN_PROMO_GROUP_CREATE_AUTO_ASSIGN_PROMPT": "Введите сумму общих трат (в ₽) для автоматической выдачи этой группы. Отправьте 0, чтобы отключить.", + "ADMIN_PROMO_GROUP_INVALID_AUTO_ASSIGN": "Введите неотрицательное число в рублях или 0 для отключения.", + "ADMIN_PROMO_GROUP_EDIT_AUTO_ASSIGN_PROMPT": "Введите сумму общих трат (в ₽) для автовыдачи. Текущее значение: {current}.", "ADMIN_PROMO_GROUP_MEMBERS_TITLE": "👥 Участники группы {name}", "ADMIN_PROMO_GROUP_MEMBERS_EMPTY": "В этой группе пока нет участников.", "ADMIN_PROMO_GROUP_DELETE_FORBIDDEN": "Базовую промогруппу нельзя удалить.", @@ -132,6 +164,7 @@ "BALANCE_INFO": "\n💰 Баланс: {balance}\n\nВыберите действие:\n", "BALANCE_SUPPORT_REQUEST": "🛠️ Запрос через поддержку", "BALANCE_TOP_UP": "💳 Пополнить", + "BALANCE_TOPUP": "💳 Пополнить баланс", "CAMPAIGN_EXISTING_USER": "ℹ️ Эта рекламная ссылка доступна только новым пользователям.", "CAMPAIGN_BONUS_BALANCE": "🎉 Вы получили {amount} за регистрацию по кампании «{name}»!", "CAMPAIGN_BONUS_SUBSCRIPTION": "🎉 Вам выдана подписка на {days} д. (трафик: {traffic}, устройств: {devices}) по кампании «{name}»!", @@ -159,6 +192,16 @@ "CONFIRM": "✅ Подтвердить", "CONFIRM_CHANGE_BUTTON": "✅ Подтвердить изменение", "CONNECT_BUTTON": "🔗 Подключиться", + "HAPP_DOWNLOAD_BUTTON": "⬇️ Скачать Happ", + "HAPP_DOWNLOAD_PROMPT": "📥 Скачать Happ\nВыберите ваше устройство:", + "HAPP_PLATFORM_IOS": "🍎 iOS", + "HAPP_PLATFORM_ANDROID": "🤖 Android", + "HAPP_PLATFORM_MACOS": "🖥️ Mac OS", + "HAPP_PLATFORM_WINDOWS": "💻 Windows", + "HAPP_PLATFORM_PC": "💻 ПК", + "HAPP_DOWNLOAD_LINK_MESSAGE": "⬇️ Скачайте Happ для {platform}:", + "HAPP_DOWNLOAD_LINK_NOT_SET": "❌ Ссылка для этого устройства не настроена", + "HAPP_DOWNLOAD_OPEN_LINK": "🔗 Открыть ссылку", "CONTACT_SUPPORT": "💬 Написать в поддержку", "CONTINUE": "➡️ Продолжить", "CONTINUE_BUTTON": "✅ Продолжить", @@ -183,6 +226,7 @@ "GO_TO_BALANCE_TOP_UP": "💳 Перейти к пополнению баланса", "RETURN_TO_SUBSCRIPTION_CHECKOUT": "⬅️ Вернуться к оформлению подписки", "INSUFFICIENT_BALANCE": "❌ Недостаточно средств на балансе. \n \n Пополните баланс на {amount} и попробуйте снова.\n ", + "ADDON_INSUFFICIENT_FUNDS_MESSAGE": "⚠️ Недостаточно средств\n\nСтоимость услуги: {required}\nНа балансе: {balance}\nНе хватает: {missing}\n\nВыберите способ пополнения. Сумма подставится автоматически.", "INVALID_AMOUNT": "❌ Неверная сумма", "LANGUAGE_SELECTED": "🌐 Язык интерфейса установлен: Русский", "LOADING": "⏳ Загрузка...", @@ -214,6 +258,8 @@ "PAGINATION_PREV": "⬅️", "PAYMENTS_TEMPORARILY_UNAVAILABLE": "⚠️ Способы оплаты временно недоступны", "PAYMENT_CARD_TRIBUTE": "💳 Банковская карта (Tribute)", + "PAYMENT_CARD_MULENPAY": "💳 Банковская карта (Mulen Pay)", + "PAYMENT_CARD_PAL24": "💳 Банковская карта (PayPalych)", "PAYMENT_CARD_YOOKASSA": "💳 Банковская карта (YooKassa)", "PAYMENT_CRYPTOBOT": "🪙 Криптовалюта (CryptoBot)", "PAYMENT_SBP_YOOKASSA": "🏬 Оплатить по СБП (YooKassa)", @@ -221,6 +267,14 @@ "PAYMENT_VIA_SUPPORT": "🛠️ Через поддержку", "PAY_NOW_BUTTON": "💳 Оплатить", "PAY_WITH_COINS_BUTTON": "🪙 Оплатить", + "MULENPAY_TOPUP_PROMPT": "💳 Оплата через Mulen Pay\n\nВведите сумму для пополнения от 100 до 100 000 ₽.\nОплата происходит через защищенную платформу Mulen Pay.", + "MULENPAY_PAYMENT_ERROR": "❌ Ошибка создания платежа Mulen Pay. Попробуйте позже или обратитесь в поддержку.", + "MULENPAY_PAY_BUTTON": "💳 Оплатить через Mulen Pay", + "MULENPAY_PAYMENT_INSTRUCTIONS": "💳 Оплата через Mulen Pay\n\n💰 Сумма: {amount}\n🆔 ID платежа: {payment_id}\n\n📱 Инструкция:\n1. Нажмите кнопку ‘Оплатить через Mulen Pay’\n2. Следуйте подсказкам платежной системы\n3. Подтвердите перевод\n4. Средства зачислятся автоматически\n\n❓ Если возникнут проблемы, обратитесь в {support}", + "PAL24_TOPUP_PROMPT": "💳 Оплата через PayPalych\n\nВведите сумму для пополнения от 100 до 1 000 000 ₽.\nОплата проходит через защищенную платформу PayPalych.", + "PAL24_PAYMENT_ERROR": "❌ Ошибка создания платежа PayPalych. Попробуйте позже или обратитесь в поддержку.", + "PAL24_PAY_BUTTON": "💳 Оплатить через PayPalych", + "PAL24_PAYMENT_INSTRUCTIONS": "💳 Оплата через PayPalych\n\n💰 Сумма: {amount}\n🆔 ID счета: {bill_id}\n\n📱 Инструкция:\n1. Нажмите кнопку ‘Оплатить через PayPalych’\n2. Следуйте подсказкам платежной системы\n3. Подтвердите перевод\n4. Средства зачислятся автоматически\n\n❓ Если возникнут проблемы, обратитесь в {support}", "PENDING_CANCEL_BUTTON": "⌛ Отмена", "PERIOD_14_DAYS": "📅 14 дней - {settings.format_price(settings.PRICE_14_DAYS)}", "PERIOD_180_DAYS": "📅 180 дней - {settings.format_price(settings.PRICE_180_DAYS)}", @@ -265,6 +319,7 @@ "SHOW_SUBSCRIPTION_LINK": "📋 Показать ссылку подписки", "SKIP_BUTTON": "⏭️ Пропустить", "SUBSCRIPTION_ACTIVE": "✅ Активна", + "SUBSCRIPTION_EXTEND": "💎 Продлить подписку", "SUBSCRIPTION_EXPIRED": "\n❌ Подписка истекла\n\nВаша подписка истекла. Для восстановления доступа продлите подписку.\n", "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", @@ -349,6 +404,7 @@ "SUBSCRIPTION_CONNECT_LINK_PROMPT": "📱 Скопируйте ссылку и добавьте в ваше VPN приложение", "SUBSCRIPTION_IMPORT_LINK_SECTION": "🔗 Ваша ссылка для импорта в VPN приложение:\n{subscription_url}", "SUBSCRIPTION_IMPORT_INSTRUCTION_PROMPT": "📱 Нажмите кнопку ниже, чтобы получить инструкцию по настройке VPN на вашем устройстве", + "SUBSCRIPTION_HAPP_LINK_PROMPT": "🔒 Ссылка на подписку создана. Нажмите кнопку \"Подключиться\" ниже, чтобы открыть её в Happ.", "BACK_TO_MAIN_MENU_BUTTON": "⬅️ В главное меню", "CUSTOM_MINIAPP_URL_NOT_SET": "⚠ Кастомная ссылка для мини-приложения не настроена", "SUBSCRIPTION_LINK_GENERATING_NOTICE": "{purchase_text}\n\nСсылка генерируется, перейдите в раздел 'Моя подписка' через несколько секунд.", @@ -360,6 +416,10 @@ "SUBSCRIPTION_LINK_UNAVAILABLE": "❌ Ссылка подписки недоступна", "SUBSCRIPTION_DEVICE_APPS_NOT_FOUND": "❌ Приложения для этого устройства не найдены", "SUBSCRIPTION_DEVICE_GUIDE_TITLE": "📱 Настройка для {device_name}", + "SUBSCRIPTION_HAPP_OPEN_TITLE": "🔗 Подключение через Happ", + "SUBSCRIPTION_HAPP_OPEN_LINK": "🔓 Открыть ссылку в Happ", + "SUBSCRIPTION_HAPP_OPEN_HINT": "💡 Если ссылка не открывается автоматически, скопируйте её вручную: {subscription_link}", + "SUBSCRIPTION_HAPP_OPEN_BUTTON_HINT": "▶️ Нажмите кнопку \"Подключиться\" ниже, чтобы открыть Happ и добавить подписку автоматически.", "SUBSCRIPTION_DEVICE_LINK_TITLE": "🔗 Ссылка подписки:", "SUBSCRIPTION_DEVICE_FEATURED_APP": "📋 Рекомендуемое приложение: {app_name}", "SUBSCRIPTION_DEVICE_STEP_INSTALL_TITLE": "Шаг 1 - Установка:", @@ -444,9 +504,31 @@ "PAYMENT_METHOD_YOOKASSA_DESCRIPTION": "через YooKassa", "PAYMENT_METHOD_TRIBUTE_NAME": "💳 Банковская карта", "PAYMENT_METHOD_TRIBUTE_DESCRIPTION": "через Tribute", + "PAYMENT_METHOD_MULENPAY_NAME": "💳 Банковская карта (Mulen Pay)", + "PAYMENT_METHOD_MULENPAY_DESCRIPTION": "через Mulen Pay", + "PAYMENT_METHOD_PAL24_NAME": "💳 Банковская карта (PayPalych)", + "PAYMENT_METHOD_PAL24_DESCRIPTION": "через PayPalych", "PAYMENT_METHOD_CRYPTOBOT_NAME": "🪙 Криптовалюта", "PAYMENT_METHOD_CRYPTOBOT_DESCRIPTION": "через CryptoBot", "PAYMENT_METHOD_SUPPORT_NAME": "🛠️ Через поддержку", "PAYMENT_METHOD_SUPPORT_DESCRIPTION": "другие способы", - "PAYMENT_METHODS_UNAVAILABLE_ALERT": "⚠️ В данный момент автоматические способы оплаты временно недоступны. Для пополнения баланса обратитесь в техподдержку." + "PAYMENT_METHODS_UNAVAILABLE_ALERT": "⚠️ В данный момент автоматические способы оплаты временно недоступны. Для пополнения баланса обратитесь в техподдержку.", + "TRIAL_INACTIVE_1H": "⏳ Прошёл час, а подключение не выполнено\n\nЕсли возникли сложности — откройте инструкцию и следуйте шагам. Мы всегда готовы помочь!", + "TRIAL_INACTIVE_24H": "⏳ Прошли сутки с начала теста\n\nМы не видим трафика по вашей подписке. Загляните в инструкцию или напишите в поддержку — поможем подключиться!", + "SUBSCRIPTION_EXPIRED_1D": "⛔ Подписка закончилась\n\nДоступ был отключён {end_date}. Продлите подписку, чтобы вернуть полный доступ.\n\n💎 Стоимость продления: {price}", + "SUBSCRIPTION_EXPIRED_SECOND_WAVE": "🔥 Скидка {percent}% на продление\n\nНажмите «Получить скидку», и мы начислим {bonus} на ваш баланс. Предложение действительно до {expires_at}.", + "SUBSCRIPTION_EXPIRED_THIRD_WAVE": "🎁 Индивидуальная скидка {percent}%\n\nПрошло {trigger_days} дней без подписки. Вернитесь — нажмите «Получить скидку», и {bonus} поступит на баланс. Предложение действительно до {expires_at}.", + "DISCOUNT_CLAIM_SUCCESS": "🎉 Скидка {percent}% активирована! На баланс начислено {amount}.", + "DISCOUNT_CLAIM_ALREADY": "ℹ️ Скидка уже была активирована ранее.", + "DISCOUNT_CLAIM_EXPIRED": "⚠️ Время действия предложения истекло.", + "DISCOUNT_CLAIM_NOT_FOUND": "❌ Предложение не найдено.", + "DISCOUNT_CLAIM_ERROR": "❌ Не удалось начислить скидку. Попробуйте позже.", + "DISCOUNT_BONUS_DESCRIPTION": "Скидка за продление подписки", + "NOTIFICATION_VALUE_INVALID": "❌ Некорректное значение, укажите число.", + "NOTIFICATION_VALUE_UPDATED": "✅ Настройки обновлены.", + "NOTIFY_PROMPT_SECOND_PERCENT": "Введите новый процент скидки для уведомления через 2-3 дня (0-100):", + "NOTIFY_PROMPT_SECOND_HOURS": "Введите количество часов действия скидки (1-168):", + "NOTIFY_PROMPT_THIRD_PERCENT": "Введите новый процент скидки для позднего предложения (0-100):", + "NOTIFY_PROMPT_THIRD_HOURS": "Введите количество часов действия скидки (1-168):", + "NOTIFY_PROMPT_THIRD_DAYS": "Через сколько дней после истечения отправлять предложение? (минимум 2):" } diff --git a/main.py b/main.py index fccd15e8..254e5c5d 100644 --- a/main.py +++ b/main.py @@ -16,8 +16,10 @@ from app.services.payment_service import PaymentService from app.services.version_service import version_service from app.external.webhook_server import WebhookServer from app.external.yookassa_webhook import start_yookassa_webhook_server +from app.external.pal24_webhook import start_pal24_webhook_server, Pal24WebhookServer from app.database.universal_migration import run_universal_migration from app.services.backup_service import backup_service +from app.services.reporting_service import reporting_service from app.localization.loader import ensure_locale_templates @@ -55,6 +57,7 @@ async def main(): webhook_server = None yookassa_server_task = None + pal24_server: Pal24WebhookServer | None = None monitoring_task = None maintenance_task = None version_check_task = None @@ -109,15 +112,28 @@ async def main(): logger.info("✅ Сервис бекапов инициализирован") except Exception as e: logger.error(f"❌ Ошибка инициализации сервиса бекапов: {e}") - + + logger.info("📊 Инициализация сервиса отчетов...") + try: + reporting_service.set_bot(bot) + await reporting_service.start() + except Exception as e: + logger.error(f"❌ Ошибка запуска сервиса отчетов: {e}") + payment_service = PaymentService(bot) - webhook_needed = settings.TRIBUTE_ENABLED or settings.is_cryptobot_enabled() + webhook_needed = ( + settings.TRIBUTE_ENABLED + or settings.is_cryptobot_enabled() + or settings.is_mulenpay_enabled() + ) if webhook_needed: enabled_services = [] if settings.TRIBUTE_ENABLED: enabled_services.append("Tribute") + if settings.is_mulenpay_enabled(): + enabled_services.append("Mulen Pay") if settings.is_cryptobot_enabled(): enabled_services.append("CryptoBot") @@ -134,7 +150,13 @@ async def main(): ) else: logger.info("ℹ️ YooKassa отключена, webhook сервер не запускается") - + + if settings.is_pal24_enabled(): + logger.info("💳 Запуск PayPalych webhook сервера...") + pal24_server = await start_pal24_webhook_server(payment_service) + else: + logger.info("ℹ️ PayPalych отключен, webhook сервер не запускается") + logger.info("📊 Запуск службы мониторинга...") monitoring_task = asyncio.create_task(monitoring_service.start_monitoring()) @@ -160,14 +182,24 @@ async def main(): if webhook_needed: if settings.TRIBUTE_ENABLED: logger.info(f" Tribute: {settings.WEBHOOK_URL}:{settings.TRIBUTE_WEBHOOK_PORT}{settings.TRIBUTE_WEBHOOK_PATH}") + if settings.is_mulenpay_enabled(): + logger.info(f" Mulen Pay: {settings.WEBHOOK_URL}:{settings.TRIBUTE_WEBHOOK_PORT}{settings.MULENPAY_WEBHOOK_PATH}") if settings.is_cryptobot_enabled(): logger.info(f" CryptoBot: {settings.WEBHOOK_URL}:{settings.TRIBUTE_WEBHOOK_PORT}{settings.CRYPTOBOT_WEBHOOK_PATH}") if settings.is_yookassa_enabled(): logger.info(f" YooKassa: {settings.WEBHOOK_URL}:{settings.YOOKASSA_WEBHOOK_PORT}{settings.YOOKASSA_WEBHOOK_PATH}") + if settings.is_pal24_enabled(): + logger.info( + f" PayPalych: {settings.WEBHOOK_URL}:{settings.PAL24_WEBHOOK_PORT}{settings.PAL24_WEBHOOK_PATH}" + ) logger.info("📄 Активные фоновые сервисы:") logger.info(f" Мониторинг: {'Включен' if monitoring_task else 'Отключен'}") logger.info(f" Техработы: {'Включен' if maintenance_task else 'Отключен'}") logger.info(f" Проверка версий: {'Включен' if version_check_task else 'Отключен'}") + logger.info( + " Отчеты: %s", + "Включен" if reporting_service.is_running() else "Отключен", + ) logger.info("=" * 50) try: @@ -235,6 +267,10 @@ async def main(): await monitoring_task except asyncio.CancelledError: pass + + if pal24_server: + logger.info("ℹ️ Остановка PayPalych webhook сервера...") + await asyncio.get_running_loop().run_in_executor(None, pal24_server.stop) if maintenance_task and not maintenance_task.done(): logger.info("ℹ️ Остановка службы техработ...") @@ -253,6 +289,12 @@ async def main(): except asyncio.CancelledError: pass + logger.info("ℹ️ Остановка сервиса отчетов...") + try: + await reporting_service.stop() + except Exception as e: + logger.error(f"Ошибка остановки сервиса отчетов: {e}") + logger.info("ℹ️ Остановка сервиса бекапов...") try: await backup_service.stop_auto_backup() diff --git a/migrations/alembic/versions/4b6b0f58c8f9_add_period_discounts_to_promo_groups.py b/migrations/alembic/versions/4b6b0f58c8f9_add_period_discounts_to_promo_groups.py new file mode 100644 index 00000000..4f3518cd --- /dev/null +++ b/migrations/alembic/versions/4b6b0f58c8f9_add_period_discounts_to_promo_groups.py @@ -0,0 +1,29 @@ +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +revision: str = "4b6b0f58c8f9" +down_revision: Union[str, None] = "1f5f3a3f5a4d" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + bind = op.get_bind() + dialect = bind.dialect.name if bind else "" + + op.add_column( + "promo_groups", + sa.Column("period_discounts", sa.JSON(), nullable=True), + ) + + if dialect == "postgresql": + op.execute("UPDATE promo_groups SET period_discounts = '{}'::jsonb WHERE period_discounts IS NULL") + else: + op.execute("UPDATE promo_groups SET period_discounts = '{}' WHERE period_discounts IS NULL") + + +def downgrade() -> None: + op.drop_column("promo_groups", "period_discounts") diff --git a/requirements.txt b/requirements.txt index 95294b91..53f98559 100644 --- a/requirements.txt +++ b/requirements.txt @@ -32,3 +32,6 @@ qrcode[pil]==7.4.2 packaging==23.2 aiofiles==23.2.1 + +# Вебхуки PayPalych (Flask) +Flask==3.1.0