From 8f1b71408e829328bc48ca2dc6f41a8d4ee39332 Mon Sep 17 00:00:00 2001 From: evansvl Date: Sun, 11 Jan 2026 06:11:11 +0300 Subject: [PATCH 01/61] Fix formatting and improve readability in README.md; update configuration tables and enhance descriptions for better clarity. --- README.md | 333 +++++++++++++++++++++++++++++++++--------------------- 1 file changed, 203 insertions(+), 130 deletions(-) diff --git a/README.md b/README.md index 9699a6e2..43d2bf45 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ **🤖 Современный Telegram-бот для управления VPN подписками через Remnawave API** -*Полнофункциональное решение с управлением пользователями, платежами и администрированием* +_Полнофункциональное решение с управлением пользователями, платежами и администрированием_ [![Python](https://img.shields.io/badge/Python-3.13+-blue?logo=python&logoColor=white)](https://python.org) [![PostgreSQL](https://img.shields.io/badge/PostgreSQL-15+-blue?logo=postgresql&logoColor=white)](https://postgresql.org) @@ -34,14 +34,16 @@ Вы хотите спать — он позволит и это... ### ⚡ **Полная автоматизация VPN бизнеса** + - 🎯 **Готовое решение** - разверни за 5 минут, начни продавать сегодня - 💰 **Многоканальные платежи** - Telegram Stars + Tribute + CryptoBot + Heleket + YooKassa (СБП + карты) + MulenPay + PayPalych (СБП + карты) + Platega (карты + СБП) + WATA + Freekassa (NSPK СБП + карты) + CloudPayments (карты + СБП) - 🔄 **Автоматизация 99%** - от регистрации до продления подписок - 📱 **MiniApp лк** - личный кабинет с возможностью покупки/продления подписки - 📊 **Детальная аналитика** - полная картина вашего бизнеса - 💬 **Уведомления в топики** об: Активация триала 💎 Покупка подписки 🔄 Конверсия из триала в платную ⏰ Продление подписки 💰 Пополнение баланса 🚧 Включении тех работ ♻️ Появлении новой версии бота - + ### 🎛️ **Гибкость конфигурации** + - 🌐 **Умный выбор серверов** - автоматический пропуск при одном сервере, мультивыбор - 📱 **Управление устройствами** - от 1 до неограниченного количества - 📊 **Режимы продажи трафика** - фиксированный лимит или выбор пакетов @@ -51,6 +53,7 @@ - 🛒 **Умная корзина** - сохранение параметров подписки при недостатке баланса ### 💪 **Enterprise готовность** + - 🗃️ **Современная архитектура** - AsyncIO, PostgreSQL, Redis, модульная структура - 🔒 **Безопасность** - интеграция с системой защиты панели через куки-аутентификацию - 📈 **Масштабируемость** - от стартапа до крупного бизнеса @@ -66,12 +69,12 @@ ### 📚 Поддерживаемые методы авторизации - | Конфигурация | Authorization | X-Api-Key | - |------------------|---------------------|---------------------| - | Только API Key | Bearer | | - | Basic Auth | Bearer | Basic | - | Caddy + API Key | Basic | | - | Cookies (eGames) | Bearer | + cookies | +| Конфигурация | Authorization | X-Api-Key | +| ---------------- | ------------------- | ------------------- | +| Только API Key | Bearer | | +| Basic Auth | Bearer | Basic | +| Caddy + API Key | Basic | | +| Cookies (eGames) | Bearer | + cookies | --- @@ -135,11 +138,11 @@ docker compose logs ### 1. Выбор режима запуска -| `BOT_RUN_MODE` | Что делает | Когда использовать | -|----------------|------------|---------------------| -| `polling` | Бот опрашивает Telegram через long polling. HTTP-сервер можно не поднимать. | Локальная отладка или отсутствие внешнего HTTPS. | -| `webhook` | Aiogram получает апдейты только через вебхук. | Продакшн и серверы за HTTPS-прокси. | -| `both` | Одновременно работают polling и webhook. | Тестирование или повышенная отказоустойчивость. | +| `BOT_RUN_MODE` | Что делает | Когда использовать | +| -------------- | --------------------------------------------------------------------------- | ------------------------------------------------ | +| `polling` | Бот опрашивает Telegram через long polling. HTTP-сервер можно не поднимать. | Локальная отладка или отсутствие внешнего HTTPS. | +| `webhook` | Aiogram получает апдейты только через вебхук. | Продакшн и серверы за HTTPS-прокси. | +| `both` | Одновременно работают polling и webhook. | Тестирование или повышенная отказоустойчивость. | ### 2. Минимальные настройки для webhook @@ -168,6 +171,7 @@ REMNAWAVE_API_KEY=your_api_key ``` **Важно:** + - `WEBHOOK_SECRET_TOKEN` - сгенерируйте командой `openssl rand -hex 32` - `WEB_API_DEFAULT_TOKEN` - сгенерируйте командой `openssl rand -hex 32` - `WEBHOOK_URL` должен быть доступен извне по HTTPS @@ -192,10 +196,10 @@ WEB_API_PORT=8080 WEB_API_ALLOWED_ORIGINS=https://miniapp.domain.com ``` -* `WEBHOOK_URL` — публичный HTTPS-домен прокси. К нему автоматически добавится путь из `WEBHOOK_PATH`. -* `WEBHOOK_SECRET_TOKEN` — защитный токен Telegram, обязательно задайте своё значение. -* Очередь можно тюнить через `WEBHOOK_MAX_QUEUE_SIZE`, `WEBHOOK_WORKERS`, `WEBHOOK_ENQUEUE_TIMEOUT` и `WEBHOOK_WORKER_SHUTDOWN_TIMEOUT`. -* Если миниапка или админка доступны по другим доменам, перечислите их через запятую в `WEB_API_ALLOWED_ORIGINS`. +- `WEBHOOK_URL` — публичный HTTPS-домен прокси. К нему автоматически добавится путь из `WEBHOOK_PATH`. +- `WEBHOOK_SECRET_TOKEN` — защитный токен Telegram, обязательно задайте своё значение. +- Очередь можно тюнить через `WEBHOOK_MAX_QUEUE_SIZE`, `WEBHOOK_WORKERS`, `WEBHOOK_ENQUEUE_TIMEOUT` и `WEBHOOK_WORKER_SHUTDOWN_TIMEOUT`. +- Если миниапка или админка доступны по другим доменам, перечислите их через запятую в `WEB_API_ALLOWED_ORIGINS`. После изменения `.env` перезапустите сервис: `docker compose up -d remnawave_bot`. @@ -206,6 +210,7 @@ WEB_API_ALLOWED_ORIGINS=https://miniapp.domain.com Если бот запускается на отдельном сервере (не там, где панель Remnawave), используйте стандартную конфигурацию: **docker-compose.yml бота:** + ```yaml services: postgres: @@ -216,13 +221,17 @@ services: POSTGRES_DB: ${POSTGRES_DB:-remnawave_bot} POSTGRES_USER: ${POSTGRES_USER:-remnawave_user} POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-secure_password_123} - POSTGRES_INITDB_ARGS: "--encoding=UTF8 --locale=C" + POSTGRES_INITDB_ARGS: '--encoding=UTF8 --locale=C' volumes: - postgres_data:/var/lib/postgresql/data networks: - bot_network healthcheck: - test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-remnawave_user} -d ${POSTGRES_DB:-remnawave_bot}"] + test: + [ + 'CMD-SHELL', + 'pg_isready -U ${POSTGRES_USER:-remnawave_user} -d ${POSTGRES_DB:-remnawave_bot}', + ] interval: 30s timeout: 5s retries: 5 @@ -238,7 +247,7 @@ services: networks: - bot_network healthcheck: - test: ["CMD", "redis-cli", "ping"] + test: ['CMD', 'redis-cli', 'ping'] interval: 30s timeout: 10s retries: 3 @@ -255,16 +264,16 @@ services: env_file: - .env environment: - DOCKER_ENV: "true" - DATABASE_MODE: "auto" - POSTGRES_HOST: "postgres" - POSTGRES_PORT: "5432" - POSTGRES_DB: "${POSTGRES_DB:-remnawave_bot}" - POSTGRES_USER: "${POSTGRES_USER:-remnawave_user}" - POSTGRES_PASSWORD: "${POSTGRES_PASSWORD:-secure_password_123}" - REDIS_URL: "redis://redis:6379/0" - TZ: "Europe/Moscow" - LOCALES_PATH: "${LOCALES_PATH:-/app/locales}" + DOCKER_ENV: 'true' + DATABASE_MODE: 'auto' + POSTGRES_HOST: 'postgres' + POSTGRES_PORT: '5432' + POSTGRES_DB: '${POSTGRES_DB:-remnawave_bot}' + POSTGRES_USER: '${POSTGRES_USER:-remnawave_user}' + POSTGRES_PASSWORD: '${POSTGRES_PASSWORD:-secure_password_123}' + REDIS_URL: 'redis://redis:6379/0' + TZ: 'Europe/Moscow' + LOCALES_PATH: '${LOCALES_PATH:-/app/locales}' volumes: - ./logs:/app/logs:rw - ./data:/app/data:rw @@ -273,11 +282,15 @@ services: - /etc/localtime:/etc/localtime:ro - ./vpn_logo.png:/app/vpn_logo.png:ro ports: - - "${WEB_API_PORT:-8080}:8080" + - '${WEB_API_PORT:-8080}:8080' networks: - bot_network healthcheck: - test: ["CMD-SHELL", "python -c \"import requests, os; requests.get('http://localhost:8080/health', headers={'X-API-Key': os.environ.get('WEB_API_DEFAULT_TOKEN')}, timeout=5) or exit(1)\""] + test: + [ + 'CMD-SHELL', + 'python -c "import requests, os; requests.get(''http://localhost:8080/health'', headers={''X-API-Key'': os.environ.get(''WEB_API_DEFAULT_TOKEN'')}, timeout=5) or exit(1)"', + ] interval: 60s timeout: 10s retries: 3 @@ -297,9 +310,12 @@ networks: config: - subnet: 172.20.0.0/16 gateway: 172.20.0.1 + driver_opts: + com.docker.network.driver.mtu: 1350 ``` **docker-compose.yml для Caddy (отдельный файл):** + ```yaml services: caddy: @@ -307,8 +323,8 @@ services: container_name: remnawave_caddy restart: unless-stopped ports: - - "80:80" - - "443:443" + - '80:80' + - '443:443' volumes: - ./Caddyfile:/etc/caddy/Caddyfile - caddy_data:/data @@ -331,6 +347,7 @@ networks: Если бот и панель Remnawave запускаются на одном сервере, подключите бота к сети панели: **docker-compose.local.yml бота(есть в репо):** + ```yaml services: postgres: @@ -341,14 +358,18 @@ services: POSTGRES_DB: ${POSTGRES_DB:-remnawave_bot} POSTGRES_USER: ${POSTGRES_USER:-remnawave_user} POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-secure_password_123} - POSTGRES_INITDB_ARGS: "--encoding=UTF8 --locale=C" + POSTGRES_INITDB_ARGS: '--encoding=UTF8 --locale=C' volumes: - postgres_data:/var/lib/postgresql/data networks: - bot_network - - remnawave-network # Подключаем к сети панели + - remnawave-network # Подключаем к сети панели healthcheck: - test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-remnawave_user} -d ${POSTGRES_DB:-remnawave_bot}"] + test: + [ + 'CMD-SHELL', + 'pg_isready -U ${POSTGRES_USER:-remnawave_user} -d ${POSTGRES_DB:-remnawave_bot}', + ] interval: 30s timeout: 5s retries: 5 @@ -363,9 +384,9 @@ services: - redis_data:/data networks: - bot_network - - remnawave-network # Подключаем к сети панели + - remnawave-network # Подключаем к сети панели healthcheck: - test: ["CMD", "redis-cli", "ping"] + test: ['CMD', 'redis-cli', 'ping'] interval: 30s timeout: 10s retries: 3 @@ -382,16 +403,16 @@ services: env_file: - .env environment: - DOCKER_ENV: "true" - DATABASE_MODE: "auto" - POSTGRES_HOST: "postgres" - POSTGRES_PORT: "5432" - POSTGRES_DB: "${POSTGRES_DB:-remnawave_bot}" - POSTGRES_USER: "${POSTGRES_USER:-remnawave_user}" - POSTGRES_PASSWORD: "${POSTGRES_PASSWORD:-secure_password_123}" - REDIS_URL: "redis://redis:6379/0" - TZ: "Europe/Moscow" - LOCALES_PATH: "${LOCALES_PATH:-/app/locales}" + DOCKER_ENV: 'true' + DATABASE_MODE: 'auto' + POSTGRES_HOST: 'postgres' + POSTGRES_PORT: '5432' + POSTGRES_DB: '${POSTGRES_DB:-remnawave_bot}' + POSTGRES_USER: '${POSTGRES_USER:-remnawave_user}' + POSTGRES_PASSWORD: '${POSTGRES_PASSWORD:-secure_password_123}' + REDIS_URL: 'redis://redis:6379/0' + TZ: 'Europe/Moscow' + LOCALES_PATH: '${LOCALES_PATH:-/app/locales}' volumes: - ./logs:/app/logs:rw - ./data:/app/data:rw @@ -400,12 +421,16 @@ services: - /etc/localtime:/etc/localtime:ro - ./vpn_logo.png:/app/vpn_logo.png:ro ports: - - "${WEB_API_PORT:-8080}:8080" + - '${WEB_API_PORT:-8080}:8080' networks: - bot_network - - remnawave-network # Подключаем к сети панели + - remnawave-network # Подключаем к сети панели healthcheck: - test: ["CMD-SHELL", "python -c \"import requests, os; requests.get('http://localhost:8080/health', headers={'X-API-Key': os.environ.get('WEB_API_DEFAULT_TOKEN')}, timeout=5) or exit(1)\""] + test: + [ + 'CMD-SHELL', + 'python -c "import requests, os; requests.get(''http://localhost:8080/health'', headers={''X-API-Key'': os.environ.get(''WEB_API_DEFAULT_TOKEN'')}, timeout=5) or exit(1)"', + ] interval: 60s timeout: 10s retries: 3 @@ -424,13 +449,16 @@ networks: config: - subnet: 172.20.0.0/16 gateway: 172.20.0.1 - + driver_opts: + com.docker.network.driver.mtu: 1350 + remnawave-network: name: remnawave-network - external: true # Используем существующую сеть панели + external: true # Используем существующую сеть панели ``` **Важно:** + - Сеть `remnawave-network` должна быть создана панелью Remnawave - В `.env` укажите `REMNAWAVE_API_URL=http://remnawave:3000` (обращение внутри Docker-сети) - Бот сможет напрямую общаться с панелью без внешних запросов @@ -460,10 +488,10 @@ curl -s https://bot.example.com/health/unified | jq `Caddyfile`: ```caddy -# Hooks + API +# Hooks + API hooks.domain.com { encode gzip zstd - + # Webhook пути для платежных систем handle /yookassa-webhook { reverse_proxy remnawave_bot:8080 { @@ -474,7 +502,7 @@ hooks.domain.com { } } } - + handle /platega-webhook { reverse_proxy remnawave_bot:8080 { header_up Host {host} @@ -484,7 +512,7 @@ hooks.domain.com { } } } - + handle /cryptobot-webhook { reverse_proxy remnawave_bot:8080 { header_up Host {host} @@ -494,7 +522,7 @@ hooks.domain.com { } } } - + handle /wata-webhook { reverse_proxy remnawave_bot:8080 { header_up Host {host} @@ -504,7 +532,7 @@ hooks.domain.com { } } } - + handle /heleket-webhook { reverse_proxy remnawave_bot:8080 { header_up Host {host} @@ -514,7 +542,7 @@ hooks.domain.com { } } } - + handle /tribute-webhook { reverse_proxy remnawave_bot:8080 { header_up Host {host} @@ -524,7 +552,7 @@ hooks.domain.com { } } } - + handle /pal24-webhook { reverse_proxy remnawave_bot:8080 { header_up Host {host} @@ -534,7 +562,7 @@ hooks.domain.com { } } } - + handle /mulenpay-webhook { reverse_proxy remnawave_bot:8080 { header_up Host {host} @@ -576,7 +604,7 @@ hooks.domain.com { } } } - + # Все остальные запросы handle { reverse_proxy remnawave_bot:8080 { @@ -592,7 +620,7 @@ hooks.domain.com { # Статика для miniapp miniapp.domain.com { encode gzip zstd - + # API эндпоинты /miniapp/* в приложение handle /miniapp/* { reverse_proxy remnawave_bot:8080 { @@ -603,7 +631,7 @@ miniapp.domain.com { } } } - + # app-config.json с CORS handle /app-config.json { header Access-Control-Allow-Origin "*" @@ -615,7 +643,7 @@ miniapp.domain.com { } } } - + # Статические файлы (корень и всё остальное) handle { root * /var/www/remnawave-miniapp @@ -636,22 +664,22 @@ events {} http { include /etc/nginx/mime.types; sendfile on; - + upstream remnawave_bot_unified { server remnawave_bot:8080; } - + # Hooks + API домен server { listen 80; listen 443 ssl http2; server_name hooks.domain.com; - + ssl_certificate /etc/ssl/private/hooks.fullchain.pem; ssl_certificate_key /etc/ssl/private/hooks.privkey.pem; - + client_max_body_size 32m; - + # Webhook пути для платежных систем location = /yookassa-webhook { proxy_pass http://remnawave_bot_unified; @@ -664,7 +692,7 @@ http { proxy_buffering off; proxy_request_buffering off; } - + location = /platega-webhook { proxy_pass http://remnawave_bot_unified; proxy_set_header Host $host; @@ -676,7 +704,7 @@ http { proxy_buffering off; proxy_request_buffering off; } - + location = /cryptobot-webhook { proxy_pass http://remnawave_bot_unified; proxy_set_header Host $host; @@ -688,7 +716,7 @@ http { proxy_buffering off; proxy_request_buffering off; } - + location = /wata-webhook { proxy_pass http://remnawave_bot_unified; proxy_set_header Host $host; @@ -700,7 +728,7 @@ http { proxy_buffering off; proxy_request_buffering off; } - + location = /heleket-webhook { proxy_pass http://remnawave_bot_unified; proxy_set_header Host $host; @@ -712,7 +740,7 @@ http { proxy_buffering off; proxy_request_buffering off; } - + location = /tribute-webhook { proxy_pass http://remnawave_bot_unified; proxy_set_header Host $host; @@ -724,7 +752,7 @@ http { proxy_buffering off; proxy_request_buffering off; } - + location = /pal24-webhook { proxy_pass http://remnawave_bot_unified; proxy_set_header Host $host; @@ -736,7 +764,7 @@ http { proxy_buffering off; proxy_request_buffering off; } - + location = /mulenpay-webhook { proxy_pass http://remnawave_bot_unified; proxy_set_header Host $host; @@ -782,7 +810,7 @@ http { proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; } - + # Всё остальное location / { proxy_pass http://remnawave_bot_unified; @@ -796,18 +824,18 @@ http { proxy_request_buffering off; } } - + # Miniapp домен (статика + API) server { listen 80; listen 443 ssl http2; server_name miniapp.domain.com; - + ssl_certificate /etc/ssl/private/miniapp.fullchain.pem; ssl_certificate_key /etc/ssl/private/miniapp.privkey.pem; - + client_max_body_size 32m; - + # API эндпоинты /miniapp/* location /miniapp/ { proxy_pass http://remnawave_bot_unified; @@ -820,7 +848,7 @@ http { proxy_buffering off; proxy_request_buffering off; } - + # app-config.json с CORS location = /app-config.json { add_header Access-Control-Allow-Origin "*"; @@ -830,12 +858,12 @@ http { proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; } - + # Статические файлы location / { root /var/www/remnawave-miniapp; try_files $uri $uri/ /index.html; - + # Кэширование expires 1h; add_header Cache-Control "public, immutable"; @@ -855,11 +883,13 @@ http { ### Быстрая настройка SSL для Nginx в Docker #### 1. Установка Certbot + ```bash sudo apt update && sudo apt install certbot -y ``` #### 2. Генерация сертификатов + ```bash # Остановите Nginx Docker контейнер docker compose down @@ -870,6 +900,7 @@ sudo certbot certonly --standalone -d miniapp.domain.com --agree-tos --email you ``` #### 3. Копирование сертификатов + ```bash sudo mkdir -p /etc/ssl/private @@ -882,14 +913,15 @@ sudo chmod 600 /etc/ssl/private/*.pem ``` #### 4. Обновите docker-compose.yml + ```yaml services: nginx: image: nginx:latest container_name: nginx ports: - - "80:80" - - "443:443" + - '80:80' + - '443:443' volumes: - ./nginx.conf:/etc/nginx/nginx.conf:ro - /etc/ssl/private:/etc/ssl/private:ro @@ -904,11 +936,13 @@ networks: ``` #### 5. Запуск Nginx + ```bash docker compose up -d ``` #### 6. Автообновление сертификатов + ```bash # Создайте скрипт обновления sudo tee /opt/renew-certs.sh > /dev/null <<'EOF' @@ -938,12 +972,14 @@ echo "0 3 * * * /opt/renew-certs.sh" | sudo crontab - ``` #### Проверка работы SSL + ```bash curl -I https://hooks.domain.com curl -I https://miniapp.domain.com ``` -**Важно:** +**Важно:** + - Замените `domain.com` и `your-email@example.com` на свои данные - Замените `/путь/к/docker-compose.yml` на реальный путь к вашему docker-compose файлу - Убедитесь, что DNS записи для доменов указывают на IP вашего сервера @@ -954,11 +990,11 @@ curl -I https://miniapp.domain.com ### 🔧 Основные параметры -| Настройка | Где взять | Пример | -|-----------|-----------|---------| -| 🤖 **BOT_TOKEN** | [@BotFather](https://t.me/BotFather) | `1234567890:AABBCCdd...` | -| 👑 **ADMIN_IDS** | Твой Telegram ID | `123456789,987654321` | -| **BOT_RUN_MODE** | определяет способ приёма обновлений: `polling`, `webhook` или `both`, чтобы одновременно использовать оба режима. +| Настройка | Где взять | Пример | +| ---------------- | ----------------------------------------------------------------------------------------------------------------- | ------------------------ | +| 🤖 **BOT_TOKEN** | [@BotFather](https://t.me/BotFather) | `1234567890:AABBCCdd...` | +| 👑 **ADMIN_IDS** | Твой Telegram ID | `123456789,987654321` | +| **BOT_RUN_MODE** | определяет способ приёма обновлений: `polling`, `webhook` или `both`, чтобы одновременно использовать оба режима. | [Полный список доступных параметров:](.env.example) @@ -984,11 +1020,11 @@ curl -I https://miniapp.domain.com ### 📊 Статус серверов в главном меню -| Переменная | Описание | Пример | -|------------|----------|--------| -| `SERVER_STATUS_MODE` | Режим работы кнопки: `disabled`, `external_link`, `external_link_miniapp` или `xray` | `xray` | -| `SERVER_STATUS_EXTERNAL_URL` | Прямаяссылка на внешний мониторинг | `https://status.example.com` | -| `SERVER_STATUS_METRICS_URL` | URL страницы метрик XrayChecker | `https://sub.example.com/metrics` | +| Переменная | Описание | Пример | +| ---------------------------- | ------------------------------------------------------------------------------------ | --------------------------------- | +| `SERVER_STATUS_MODE` | Режим работы кнопки: `disabled`, `external_link`, `external_link_miniapp` или `xray` | `xray` | +| `SERVER_STATUS_EXTERNAL_URL` | Прямаяссылка на внешний мониторинг | `https://status.example.com` | +| `SERVER_STATUS_METRICS_URL` | URL страницы метрик XrayChecker | `https://sub.example.com/metrics` | ### 🛡️ Защита панели Remnawave @@ -1017,6 +1053,7 @@ FREEKASSA_PAYMENT_SYSTEM_ID=44 # 44 = NSPK СБП, 42 = обычный СБП ``` **Важно:** + - `FREEKASSA_USE_API=true` - использовать API для создания заказов (рекомендуется для NSPK) - `FREEKASSA_USE_API=false` - редирект на платежную форму - `FREEKASSA_PAYMENT_SYSTEM_ID=44` - для NSPK СБП (быстрее и надежнее) @@ -1034,6 +1071,7 @@ CLOUDPAYMENTS_CURRENCY=RUB ``` **Важно:** + - `CLOUDPAYMENTS_PUBLIC_ID` - Public ID из личного кабинета CloudPayments - `CLOUDPAYMENTS_API_SECRET` - API Secret для подписи запросов - Настройте webhook (Pay/Fail уведомления) на `https://your-domain.com/cloudpayments-webhook` @@ -1068,12 +1106,14 @@ PLATEGA_WEBHOOK_PATH=/platega-webhook ### 📊 Режимы продажи трафика #### **Выбираемые пакеты** (по умолчанию) + ```env TRAFFIC_SELECTION_MODE=selectable TRAFFIC_PACKAGES_CONFIG="5:2000:false,10:3500:false,25:7000:false,50:11000:true,100:15000:true,250:17000:false,500:19000:false,1000:19500:true,0:20000:true" ``` #### **Фиксированный лимит** + ```env TRAFFIC_SELECTION_MODE=fixed FIXED_TRAFFIC_LIMIT_GB=100 # 0 = безлимит @@ -1093,6 +1133,7 @@ SALES_MODE=tariffs # classic | tariffs ``` **Преимущества режима тарифов:** + - Готовые пакеты без сложного выбора параметров - Упрощенный UX для пользователей - Возможность создавать акционные тарифы @@ -1104,6 +1145,7 @@ SALES_MODE=tariffs # classic | tariffs **Базовая цена + Стоимость трафика + Доп. устройства + Доп. серверы** **Пример расчета для подписки на 180 дней:** + - Базовый период: 400₽ - Трафик безлимит: 200₽/мес × 6 мес = 1200₽ - 4 устройства: 50₽/мес × 6 мес = 300₽ @@ -1190,6 +1232,7 @@ CONTEST_BUTTON_VISIBLE=true ``` **Возможности системы конкурсов:** + - Реферальные конкурсы с рейтингами - Ежедневные игры с гарантированными призами - Детальная статистика участников @@ -1237,12 +1280,14 @@ CONTEST_BUTTON_VISIBLE=true ### 💤 **Для пользователей** 🧭 **Онбординг и доступ** + - 🌐 Выбор языка интерфейса (RU/EN), динамическая локализация - 📜 Принятие правил, оферты и политики конфиденциальности - 📡 Проверка подписки на обязательный канал - 🔗 Deeplink-инвайты, UTM-кампании и реферальные коды 🛒 **Умная покупка подписок** + - 📅 Гибкие периоды (14—360 дней) со скидками - 📊 Выбор трафика: фиксированный лимит, пакеты или безлимит - 🌐 Автоматический выбор сервера или мультивыбор @@ -1254,6 +1299,7 @@ CONTEST_BUTTON_VISIBLE=true - 🛒 **Простая покупка** - упрощенный режим для быстрого оформления 🧪 **Тестовая подписка** + - 🎁 Гибко настраиваемый триал и welcome-цепочка - 🔔 Уведомления об истечении и автоконверсия - 💎 Автовыдача бонусов за кампании и инвайты @@ -1262,6 +1308,7 @@ CONTEST_BUTTON_VISIBLE=true - 💳 **Платный триал** с выбором способа оплаты 💰 **Платежи и баланс** + - ⭐ Telegram Stars - 💳 Tribute - 💳 YooKassa (СБП + банковские карты) @@ -1281,6 +1328,7 @@ CONTEST_BUTTON_VISIBLE=true - 🔄 **Умная автоактивация** подписки после пополнения баланса 📱 **Управление подписками** + - 📈 Реальный трафик, устройства и серверы - 🌐 Переключение серверов и стран - 📱 Сброс HWID @@ -1290,11 +1338,13 @@ CONTEST_BUTTON_VISIBLE=true - 📡 **Модем-режим** - дополнительные устройства для модемов 🛟 **Поддержка и самообслуживание** + - 🎫 **Система тикетов** с вложениями - 📚 FAQ, правила, оферта и политика - 💬 Быстрые ссылки на поддержку 🧩 **Бонусы и промо** + - 🎫 Промокоды на деньги, дни, триал подписку, промогруппу - 🎁 **Персональные промо-предложения** от админов - 💰 **Тестовый доступ к серверам** через промо-акции @@ -1304,12 +1354,14 @@ CONTEST_BUTTON_VISIBLE=true - 🔗 Генерация реферальных ссылок и QR кодов 🎮 **Конкурсы и игры** + - 🎲 **Ежедневные игры** с призами (баланс, подписка, промогруппа) - 🏆 **Реферальные конкурсы** с рейтингами и наградами - 📊 **Детальная статистика** участия в конкурсах - 🎁 **Разнообразные призы** - баланс, дни подписки, промогруппы 💎 **Промо-группы и скидки** + - 🏷️ **Система промогрупп** с индивидуальными скидками с приоритетами - 💰 Скидки на серверы, трафик и устройства - 📊 **Скидочные уровни за траты** - прозрачная система лояльности @@ -1317,6 +1369,7 @@ CONTEST_BUTTON_VISIBLE=true - 🎯 **Скидки за длительные периоды** подписки для базовых юзеров 📱 **Mini App и гайды** + - 🖥️ **Полноценный личный кабинет** в Telegram WebApp - 📊 Управление подпиской и параметрами - 💳 Интегрированные платежи @@ -1334,6 +1387,7 @@ CONTEST_BUTTON_VISIBLE=true ### ⚙️ **Для администраторов** 📊 **Аналитика и отчётность** + - 📈 Дашборды по пользователям, подпискам и трафику - 💰 Детализация платежей по всем источникам - 🧮 Продажи по тарифам, устройствам и странам @@ -1344,6 +1398,7 @@ CONTEST_BUTTON_VISIBLE=true - 📡 **Мониторинг трафика** и выявление злоупотреблений 💥 **Управление пользователями** + - 🔍 Поиск по ID, имени, юзернейму, Telegram ID и фильтры - 💰 Ручное изменение баланса - 📱 Изменение лимитов устройств, трафика, серверов @@ -1361,6 +1416,7 @@ CONTEST_BUTTON_VISIBLE=true - 🗑️ **Черный список** пользователей с управлением 🎯 **Продажи, маркетинг и удержание** + - 🎫 Промокоды - 💳 Промо-группы со скидками - 🎁 **Персональные промо-предложения** с поиском получателей @@ -1376,6 +1432,7 @@ CONTEST_BUTTON_VISIBLE=true - 👁️ **Видимость кнопок** - управление отображением конкурсов и реферальной программы 🛟 **Поддержка и модерация** + - 🎫 **Центр тикетов** с приоритетами и статусами - ⏱️ **SLA таймеры** и автоуведомления - 🧑‍⚖️ Роли модераторов с ограниченным доступом @@ -1385,12 +1442,14 @@ CONTEST_BUTTON_VISIBLE=true - 🔔 **Уведомления об ответах** на тикеты для админов 🔔 **Уведомления и коммуникации** + - 📢 **Топики для событий** (покупки, триалы, техработы) - 🔔 Настройка уведомлений и расписаний - 📨 **Управление контентом** - политика, оферта, FAQ - 💬 Автоматические сообщения о задолженностях 🧰 **Обслуживание и DevOps** + - 🚧 Ручной и авто-режим техработ - 🗒️ Просмотр системных логов и health-check - 🔄 **Автосинхронизация Remnawave** по расписанию и при старте бота @@ -1400,6 +1459,7 @@ CONTEST_BUTTON_VISIBLE=true - 🔄 **Массовая синхронизация** пользователей с Remnawave 🗄️ **Бекапы и восстановление** + - 🗓️ **Умные автобекапы** с гибким расписанием - 📦 Ручные бекапы с выбором содержимого - 📤 Отправка архивов в выделенный чат/топик @@ -1407,6 +1467,7 @@ CONTEST_BUTTON_VISIBLE=true - ✅ Автоматическая синхронизация sequences после восстановления 💳 **Биллинг и настройки** + - ⚙️ **Управление ценами** без перезапуска бота - 📘 **Управление пакетами трафика** (включение/отключение) - 🧪 Тестовые платежи для каждого провайдера @@ -1418,6 +1479,7 @@ CONTEST_BUTTON_VISIBLE=true - 👁️ **Скрытие реферальной** программы ⚙️ **Remnawave** + - Синхронизация юзеров из панели в бота (Ручная/автоматическая по таймеру) - Синхронизация юзеров из бота в панель - Синхронизация сквадов(серверов) из панели в бота @@ -1426,6 +1488,7 @@ CONTEST_BUTTON_VISIBLE=true - Создание/Редактивание сквадов в боте 🗃️ **REST API для интеграций** + - 🔌 **FastAPI Web API** с полной документацией - 🔒 Управление API-ключами и токенами - 📊 Эндпоинты для подписок, пользователей, транзакций @@ -1465,12 +1528,12 @@ CONTEST_BUTTON_VISIBLE=true ## 🚀 Производительность -| Пользователей | Память | CPU | Диск | Описание | -|---------------|--------|-----|------|----------| -| **1,000** | 512MB | 1 vCPU | 10GB | ✅ Стартап | -| **10,000** | 2GB | 2 vCPU | 50GB | ✅ Малый бизнес | -| **50,000** | 4GB | 4 vCPU | 100GB | ✅ Средний бизнес | -| **100,000+** | 8GB+ | 8+ vCPU | 200GB+ | 🚀 Enterprise | +| Пользователей | Память | CPU | Диск | Описание | +| ------------- | ------ | ------- | ------ | ----------------- | +| **1,000** | 512MB | 1 vCPU | 10GB | ✅ Стартап | +| **10,000** | 2GB | 2 vCPU | 50GB | ✅ Малый бизнес | +| **50,000** | 4GB | 4 vCPU | 100GB | ✅ Средний бизнес | +| **100,000+** | 8GB+ | 8+ vCPU | 200GB+ | 🚀 Enterprise | --- @@ -1494,15 +1557,18 @@ CONTEST_BUTTON_VISIBLE=true После запуска необходимо: 1. **📡 Синхронизация серверов** (обязательно!) + - Зайди в бот → **Админ панель** → **Подписки** → **Управление серверами** - Нажми **Синхронизация** и дождись завершения - Без этого пользователи не смогут выбирать страны! 2. **💥 Синхронизация пользователей** (если есть база) + - **Админ панель** → **Remnawave** → **Синхронизация** - **Синхронизировать всех** → дождись импорта 3. **💳 Настройка платежных систем** + - **Telegram Stars**: Работает автоматически - **Tribute**: Настрой webhook на `https://your-domain.com/tribute-webhook` - **YooKassa**: Настрой webhook на `https://your-domain.com/yookassa-webhook` @@ -1555,11 +1621,13 @@ ADMIN_NOTIFICATIONS_TICKET_TOPIC_ID=126 # ID топика для тикет ## 🛠 Устранение неполадок ### 🥼 Health Checks + - **Unified сервер**: `http://localhost:8080/health/unified` (или `/health`, если административное API отключено) - **Telegram webhook**: `http://localhost:8080/health/telegram-webhook` - **Платёжные webhooks**: `http://localhost:8080/health/payment-webhooks` ### 🔧 Полезные команды + ```bash # Просмотр логов в реальном времени docker compose logs -f bot @@ -1588,14 +1656,14 @@ docker system prune ### 🚨 Частые проблемы и решения -| Проблема | Диагностика | Решение | -|----------|-------------|---------| -| **Бот не отвечает** | `docker logs remnawave_bot` | Проверь `BOT_TOKEN` и интернет | -| **Ошибки БД** | `docker compose ps postgres` | Проверь статус PostgreSQL | -| **Webhook не работает** | `curl http://localhost:8080/health/telegram-webhook` | Проверь `WEBHOOK_URL`, прокси и секрет | -| **API недоступен** | Проверь логи бота | Проверь `REMNAWAVE_API_URL` | -| **Корзина не сохраняется** | `docker compose ps redis` | Проверь статус Redis | -| **Платежи не проходят** | Проверь webhook'и | Настрой URL в платежных системах | +| Проблема | Диагностика | Решение | +| -------------------------- | ---------------------------------------------------- | -------------------------------------- | +| **Бот не отвечает** | `docker logs remnawave_bot` | Проверь `BOT_TOKEN` и интернет | +| **Ошибки БД** | `docker compose ps postgres` | Проверь статус PostgreSQL | +| **Webhook не работает** | `curl http://localhost:8080/health/telegram-webhook` | Проверь `WEBHOOK_URL`, прокси и секрет | +| **API недоступен** | Проверь логи бота | Проверь `REMNAWAVE_API_URL` | +| **Корзина не сохраняется** | `docker compose ps redis` | Проверь статус Redis | +| **Платежи не проходят** | Проверь webhook'и | Настрой URL в платежных системах | --- @@ -1639,14 +1707,14 @@ docker system prune # Для защищенных панелей REMNAWAVE_SECRET_KEY=secret_name:secret_value -# Для панелей eGames скрипта +# Для панелей eGames скрипта REMNAWAVE_SECRET_KEY=XXXXXXX:DDDDDDDD ``` ### 🔒 Дополнительные меры безопасности - **Валидация всех входящих данных** -- **Rate limiting для защиты от спама** +- **Rate limiting для защиты от спама** - **Шифрование чувствительных данных** - **Автоматическое управление сессиями** - **Мониторинг подозрительной активности** @@ -1677,7 +1745,7 @@ REMNAWAVE_SECRET_KEY=XXXXXXX:DDDDDDDD ### 📚 **Полезные ресурсы** - **📖 [https://docs.remna.st](https://docs.rw)))** - документация панели -- **🤖 [Telegram Bot API](https://core.telegram.org/bots/api)** - API ботов +- **🤖 [Telegram Bot API](https://core.telegram.org/bots/api)** - API ботов - **🐳 [Docker Guide](https://docs.docker.com/get-started/)** - обучение Docker - **🛡️ [Reverse Proxy](https://github.com/eGamesAPI/remnawave-reverse-proxy)** - защита панели @@ -1764,16 +1832,16 @@ REMNAWAVE_SECRET_KEY=XXXXXXX:DDDDDDDD ## 🎯 Ключевые особенности в цифрах -| Метрика | Значение | -|---------|----------| -| 💳 **Платёжных систем** | 11 (Stars, YooKassa, Tribute, CryptoBot, Heleket, MulenPay, Pal24, Platega, WATA, Freekassa, CloudPayments) | -| 🌍 **Языков интерфейса** | 2 (RU, EN) с возможностью расширения | -| 📊 **Периодов подписки** | 6 (от 14 дней до года) | -| 🎁 **Типов промо-акций** | 5 (коды, группы, предложения, скидки, кампании) | -| 🔌 **REST API эндпоинтов** | 50+ для полного управления | -| 📱 **Режимов работы** | 3 (классический + тарифы + MiniApp focus) | -| 🛡️ **Методов авторизации** | 4 (API Key, Bearer, Basic Auth, eGames) | -| 🗄️ **Способов хранения** | 2 (PostgreSQL, SQLite) с автовыбором | +| Метрика | Значение | +| -------------------------- | ----------------------------------------------------------------------------------------------------------- | +| 💳 **Платёжных систем** | 11 (Stars, YooKassa, Tribute, CryptoBot, Heleket, MulenPay, Pal24, Platega, WATA, Freekassa, CloudPayments) | +| 🌍 **Языков интерфейса** | 2 (RU, EN) с возможностью расширения | +| 📊 **Периодов подписки** | 6 (от 14 дней до года) | +| 🎁 **Типов промо-акций** | 5 (коды, группы, предложения, скидки, кампании) | +| 🔌 **REST API эндпоинтов** | 50+ для полного управления | +| 📱 **Режимов работы** | 3 (классический + тарифы + MiniApp focus) | +| 🛡️ **Методов авторизации** | 4 (API Key, Bearer, Basic Auth, eGames) | +| 🗄️ **Способов хранения** | 2 (PostgreSQL, SQLite) с автовыбором | --- @@ -1786,7 +1854,7 @@ REMNAWAVE_SECRET_KEY=XXXXXXX:DDDDDDDD ✅ **Прозрачная аналитика** - всегда знаете, сколько зарабатываете ✅ **Гибкие тарифы** - настройте цены под свою аудиторию ✅ **Система лояльности** - удерживайте клиентов промо-группами и скидками -✅ **Масштабируемость** - от 10 до 100,000+ пользователей +✅ **Масштабируемость** - от 10 до 100,000+ пользователей ### 🛠️ **Для разработчиков** @@ -1795,7 +1863,7 @@ REMNAWAVE_SECRET_KEY=XXXXXXX:DDDDDDDD ✅ **Полное API** - интегрируйте с любыми сервисами ✅ **Docker-ready** - разворачивается за минуты ✅ **Подробная документация** - все описано и понятно -✅ **Активное сообщество** - помощь в Telegram чате +✅ **Активное сообщество** - помощь в Telegram чате ### 👥 **Для пользователей** @@ -1804,7 +1872,7 @@ REMNAWAVE_SECRET_KEY=XXXXXXX:DDDDDDDD ✅ **Быстрая поддержка** - система тикетов с приоритетами ✅ **Прозрачность** - всегда видите, за что платите ✅ **Бонусы и скидки** - реферальная программа и промо-акции -✅ **Удобное управление** - все в одном месте, в Telegram +✅ **Удобное управление** - все в одном месте, в Telegram --- @@ -1866,6 +1934,7 @@ REMNAWAVE_SECRET_KEY=XXXXXXX:DDDDDDDD Какие требования к серверу? Минимальные: + - 1 vCPU - 512 MB RAM - 10 GB диск @@ -1873,6 +1942,7 @@ REMNAWAVE_SECRET_KEY=XXXXXXX:DDDDDDDD - Docker и Docker Compose Рекомендуемые: + - 2+ vCPU - 2+ GB RAM - 50+ GB SSD @@ -1897,6 +1967,7 @@ REMNAWAVE_SECRET_KEY=XXXXXXX:DDDDDDDD Как обновить бота? **Вручную:** + ```bash cd /root/remnawave-bedolaga-telegram-bot @@ -1926,10 +1997,12 @@ make reload-follow Как сделать бэкап? **Автоматически:** + - Настройте в `.env`: `BACKUP_AUTO_ENABLED=true` - Бэкапы создаются по расписанию **Через админ-панель:** + - Админ панель → Настройки → Бэкапы → Создать From 2e4e62d3241c59c1b7c346a7e8d696149b6d785d Mon Sep 17 00:00:00 2001 From: Egor Date: Mon, 12 Jan 2026 14:31:13 +0300 Subject: [PATCH 02/61] Update index.html --- miniapp/index.html | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/miniapp/index.html b/miniapp/index.html index 60b3525f..9e97474f 100644 --- a/miniapp/index.html +++ b/miniapp/index.html @@ -5539,6 +5539,7 @@
Top up your balance after activation to stay connected.
+
@@ -6804,6 +6805,7 @@ 'subscription_missing.hint': 'After activation you can top up your balance here to stay connected.', 'subscription_missing.action.trial': 'Activate trial', 'subscription_missing.action.trial.loading': 'Activating…', + 'subscription_missing.action.buy': 'Buy subscription', 'trial.activation.title': 'Trial activation', 'trial.activation.success': 'Trial activated! Enjoy {days} days of access.', 'trial.activation.success.short': 'Trial activated successfully.', @@ -7266,6 +7268,7 @@ 'subscription_missing.hint': 'После активации вы сможете пополнить баланс здесь для бесперебойной работы.', 'subscription_missing.action.trial': 'Активировать триал', 'subscription_missing.action.trial.loading': 'Активация…', + 'subscription_missing.action.buy': 'Купить подписку', 'trial.activation.title': 'Активация триала', 'trial.activation.success': 'Триал активирован! Доступ открыт на {days} дн.', 'trial.activation.success.short': 'Триал успешно активирован.', @@ -9383,6 +9386,16 @@ trialButton.classList.toggle('hidden', !trialAvailable && !trialActivationInProgress); trialButton.disabled = !trialAvailable || trialActivationInProgress; } + + const buyButton = document.getElementById('subscriptionMissingBuyBtn'); + if (buyButton) { + const buyLabel = t('subscription_missing.action.buy'); + buyButton.textContent = buyLabel && buyLabel !== 'subscription_missing.action.buy' + ? buyLabel + : (preferredLanguage === 'ru' ? 'Купить подписку' : 'Buy subscription'); + // Показываем кнопку покупки всегда когда subscription_missing + buyButton.classList.remove('hidden'); + } } function resolvePromoOfferIcon(offer) { @@ -17084,6 +17097,13 @@ if (subscriptionPurchaseModalOpen) { return true; } + // Показываем для пользователей с subscription_missing (без активированного триала/подписки) + const subscriptionMissing = Boolean( + userData?.subscription_missing ?? userData?.subscriptionMissing + ); + if (subscriptionMissing) { + return true; + } // Показываем для пользователей без оплаченной подписки ИЛИ с триалом return Boolean(userData?.user) && (!hasPaidSubscription() || isTrialSubscription()); } @@ -20076,6 +20096,7 @@ document.getElementById('purchaseBtn')?.addEventListener('click', handlePurchaseAction); document.getElementById('subscriptionMissingTrialBtn')?.addEventListener('click', handleTrialAction); + document.getElementById('subscriptionMissingBuyBtn')?.addEventListener('click', handlePurchaseAction); // ============================================ // Tariffs Mode Support From e15aed6c19cb31ca738523aa59caa682c0154dcb Mon Sep 17 00:00:00 2001 From: Egor Date: Mon, 12 Jan 2026 15:23:27 +0300 Subject: [PATCH 03/61] Update main.py --- main.py | 38 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/main.py b/main.py index 07e992bd..aa1ff426 100644 --- a/main.py +++ b/main.py @@ -36,6 +36,7 @@ from app.services.referral_contest_service import referral_contest_service from app.services.contest_rotation_service import contest_rotation_service from app.services.nalogo_queue_service import nalogo_queue_service from app.services.traffic_monitoring_service import traffic_monitoring_scheduler +from app.services.daily_subscription_service import daily_subscription_service from app.utils.startup_timeline import StartupTimeline from app.utils.timezone import TimezoneAwareFormatter from app.utils.log_handlers import LevelFilterHandler, ExcludePaymentFilter @@ -174,6 +175,7 @@ async def main(): maintenance_task = None version_check_task = None traffic_monitoring_task = None + daily_subscription_task = None polling_task = None web_api_server = None telegram_webhook_enabled = False @@ -240,6 +242,7 @@ async def main(): maintenance_service.set_bot(bot) broadcast_service.set_bot(bot) traffic_monitoring_scheduler.set_bot(bot) + daily_subscription_service.set_bot(bot) from app.services.admin_notification_service import AdminNotificationService @@ -597,6 +600,21 @@ async def main(): traffic_monitoring_task = None stage.skip("Мониторинг трафика отключен настройками") + async with timeline.stage( + "Суточные подписки", + "💳", + success_message="Сервис суточных подписок запущен", + ) as stage: + if daily_subscription_service.is_enabled(): + daily_subscription_task = asyncio.create_task( + daily_subscription_service.start_monitoring() + ) + interval_minutes = daily_subscription_service.get_check_interval_minutes() + stage.log(f"Интервал проверки: {interval_minutes} мин") + else: + daily_subscription_task = None + stage.skip("Суточные подписки отключены настройками") + async with timeline.stage( "Сервис проверки версий", "📄", @@ -661,6 +679,7 @@ async def main(): f"Мониторинг: {'Включен' if monitoring_task else 'Отключен'}", f"Техработы: {'Включен' if maintenance_task else 'Отключен'}", f"Мониторинг трафика: {'Включен' if traffic_monitoring_task else 'Отключен'}", + f"Суточные подписки: {'Включен' if daily_subscription_task else 'Отключен'}", f"Проверка версий: {'Включен' if version_check_task else 'Отключен'}", f"Отчеты: {'Включен' if reporting_service.is_running() else 'Отключен'}", ] @@ -715,6 +734,16 @@ async def main(): traffic_monitoring_scheduler.start_monitoring() ) + if daily_subscription_task and daily_subscription_task.done(): + exception = daily_subscription_task.exception() + if exception: + logger.error(f"Сервис суточных подписок завершился с ошибкой: {exception}") + if daily_subscription_service.is_enabled(): + logger.info("🔄 Перезапуск сервиса суточных подписок...") + daily_subscription_task = asyncio.create_task( + daily_subscription_service.start_monitoring() + ) + if auto_verification_active and not auto_payment_verification_service.is_running(): logger.warning( "Сервис автопроверки пополнений остановился, пробуем перезапустить..." @@ -784,6 +813,15 @@ async def main(): except asyncio.CancelledError: pass + if daily_subscription_task and not daily_subscription_task.done(): + logger.info("ℹ️ Остановка сервиса суточных подписок...") + daily_subscription_service.stop_monitoring() + daily_subscription_task.cancel() + try: + await daily_subscription_task + except asyncio.CancelledError: + pass + logger.info("ℹ️ Остановка сервиса отчетов...") try: await reporting_service.stop() From 49bd69f987b46b277e5cdc93bab05a0457cd1beb Mon Sep 17 00:00:00 2001 From: Egor Date: Mon, 12 Jan 2026 15:24:17 +0300 Subject: [PATCH 04/61] Add files via upload --- app/services/daily_subscription_service.py | 277 +++++++++++++++++++++ 1 file changed, 277 insertions(+) create mode 100644 app/services/daily_subscription_service.py diff --git a/app/services/daily_subscription_service.py b/app/services/daily_subscription_service.py new file mode 100644 index 00000000..92e075da --- /dev/null +++ b/app/services/daily_subscription_service.py @@ -0,0 +1,277 @@ +""" +Сервис для автоматического списания суточных подписок. +Проверяет подписки с суточным тарифом и списывает плату раз в сутки. +""" +import logging +import asyncio +from datetime import datetime +from typing import Optional + +from aiogram import Bot + +from app.config import settings +from app.database.database import get_db +from app.database.crud.subscription import ( + get_daily_subscriptions_for_charge, + update_daily_charge_time, + suspend_daily_subscription_insufficient_balance, +) +from app.database.crud.user import subtract_user_balance, get_user_by_id +from app.database.crud.transaction import create_transaction +from app.database.models import TransactionType, PaymentMethod +from app.localization.texts import get_texts + + +logger = logging.getLogger(__name__) + + +class DailySubscriptionService: + """ + Сервис автоматического списания для суточных подписок. + """ + + def __init__(self): + self._running = False + self._bot: Optional[Bot] = None + self._check_interval_minutes = 30 # Проверка каждые 30 минут + + def set_bot(self, bot: Bot): + """Устанавливает бота для отправки уведомлений.""" + self._bot = bot + + def is_enabled(self) -> bool: + """Проверяет, включен ли сервис суточных подписок.""" + return getattr(settings, 'DAILY_SUBSCRIPTIONS_ENABLED', True) + + def get_check_interval_minutes(self) -> int: + """Возвращает интервал проверки в минутах.""" + return getattr(settings, 'DAILY_SUBSCRIPTIONS_CHECK_INTERVAL_MINUTES', 30) + + async def process_daily_charges(self) -> dict: + """ + Обрабатывает суточные списания. + + Returns: + dict: Статистика обработки + """ + stats = { + "checked": 0, + "charged": 0, + "suspended": 0, + "errors": 0, + } + + try: + async for db in get_db(): + subscriptions = await get_daily_subscriptions_for_charge(db) + stats["checked"] = len(subscriptions) + + for subscription in subscriptions: + try: + result = await self._process_single_charge(db, subscription) + if result == "charged": + stats["charged"] += 1 + elif result == "suspended": + stats["suspended"] += 1 + elif result == "error": + stats["errors"] += 1 + except Exception as e: + logger.error( + f"Ошибка обработки суточной подписки {subscription.id}: {e}", + exc_info=True + ) + stats["errors"] += 1 + + except Exception as e: + logger.error(f"Ошибка при получении подписок для списания: {e}", exc_info=True) + + return stats + + async def _process_single_charge(self, db, subscription) -> str: + """ + Обрабатывает списание для одной подписки. + + Returns: + str: "charged", "suspended", "error", "skipped" + """ + user = subscription.user + if not user: + user = await get_user_by_id(db, subscription.user_id) + + if not user: + logger.warning(f"Пользователь не найден для подписки {subscription.id}") + return "error" + + tariff = subscription.tariff + if not tariff: + logger.warning(f"Тариф не найден для подписки {subscription.id}") + return "error" + + daily_price = tariff.daily_price_kopeks + if daily_price <= 0: + logger.warning(f"Некорректная суточная цена для тарифа {tariff.id}") + return "error" + + # Проверяем баланс + if user.balance_kopeks < daily_price: + # Недостаточно средств - приостанавливаем подписку + await suspend_daily_subscription_insufficient_balance(db, subscription) + + # Уведомляем пользователя + if self._bot: + await self._notify_insufficient_balance(user, subscription, daily_price) + + logger.info( + f"Подписка {subscription.id} приостановлена: недостаточно средств " + f"(баланс: {user.balance_kopeks}, требуется: {daily_price})" + ) + return "suspended" + + # Списываем средства + description = f"Суточная оплата тарифа «{tariff.name}»" + + try: + deducted = await subtract_user_balance( + db, + user, + daily_price, + description, + ) + + if not deducted: + logger.warning(f"Не удалось списать средства для подписки {subscription.id}") + return "error" + + # Создаём транзакцию + await create_transaction( + db=db, + user_id=user.id, + type=TransactionType.SUBSCRIPTION_PAYMENT, + amount_kopeks=daily_price, + description=description, + payment_method=PaymentMethod.BALANCE, + ) + + # Обновляем время последнего списания + await update_daily_charge_time(db, subscription) + + logger.info( + f"✅ Суточное списание: подписка {subscription.id}, " + f"сумма {daily_price} коп., пользователь {user.telegram_id}" + ) + + # Уведомляем пользователя + if self._bot: + await self._notify_daily_charge(user, subscription, daily_price) + + return "charged" + + except Exception as e: + logger.error( + f"Ошибка при списании средств для подписки {subscription.id}: {e}", + exc_info=True + ) + return "error" + + async def _notify_daily_charge(self, user, subscription, amount_kopeks: int): + """Уведомляет пользователя о суточном списании.""" + if not self._bot: + return + + try: + texts = get_texts(getattr(user, "language", "ru")) + amount_rubles = amount_kopeks / 100 + balance_rubles = user.balance_kopeks / 100 + + message = ( + f"💳 Суточное списание\n\n" + f"Списано: {amount_rubles:.2f} ₽\n" + f"Остаток баланса: {balance_rubles:.2f} ₽\n\n" + f"Следующее списание через 24 часа." + ) + + await self._bot.send_message( + chat_id=user.telegram_id, + text=message, + parse_mode="HTML", + ) + except Exception as e: + logger.warning(f"Не удалось отправить уведомление о списании: {e}") + + async def _notify_insufficient_balance(self, user, subscription, required_amount: int): + """Уведомляет пользователя о недостатке средств.""" + if not self._bot: + return + + try: + from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton + + texts = get_texts(getattr(user, "language", "ru")) + required_rubles = required_amount / 100 + balance_rubles = user.balance_kopeks / 100 + + message = ( + f"⚠️ Подписка приостановлена\n\n" + f"Недостаточно средств для суточной оплаты.\n\n" + f"Требуется: {required_rubles:.2f} ₽\n" + f"Баланс: {balance_rubles:.2f} ₽\n\n" + f"Пополните баланс, чтобы возобновить подписку." + ) + + keyboard = InlineKeyboardMarkup( + inline_keyboard=[ + [InlineKeyboardButton( + text="💳 Пополнить баланс", + callback_data="menu_balance" + )], + [InlineKeyboardButton( + text="📱 Моя подписка", + callback_data="menu_subscription" + )], + ] + ) + + await self._bot.send_message( + chat_id=user.telegram_id, + text=message, + reply_markup=keyboard, + parse_mode="HTML", + ) + except Exception as e: + logger.warning(f"Не удалось отправить уведомление о недостатке средств: {e}") + + async def start_monitoring(self): + """Запускает периодическую проверку суточных подписок.""" + self._running = True + interval_minutes = self.get_check_interval_minutes() + + logger.info( + f"🔄 Запуск сервиса суточных подписок (интервал: {interval_minutes} мин)" + ) + + while self._running: + try: + stats = await self.process_daily_charges() + + if stats["charged"] > 0 or stats["suspended"] > 0: + logger.info( + f"📊 Суточные списания: проверено={stats['checked']}, " + f"списано={stats['charged']}, приостановлено={stats['suspended']}, " + f"ошибок={stats['errors']}" + ) + except Exception as e: + logger.error(f"Ошибка в цикле проверки суточных подписок: {e}", exc_info=True) + + await asyncio.sleep(interval_minutes * 60) + + def stop_monitoring(self): + """Останавливает периодическую проверку.""" + self._running = False + logger.info("⏹️ Сервис суточных подписок остановлен") + + +# Глобальный экземпляр сервиса +daily_subscription_service = DailySubscriptionService() + + +__all__ = ["DailySubscriptionService", "daily_subscription_service"] From 9e4fa9defe3e71b4a36c8a560055efd6a3017592 Mon Sep 17 00:00:00 2001 From: Egor Date: Mon, 12 Jan 2026 15:24:59 +0300 Subject: [PATCH 05/61] Add files via upload --- app/database/models.py | 39 ++++++++- app/database/universal_migration.py | 130 ++++++++++++++++++++++++++++ 2 files changed, 168 insertions(+), 1 deletion(-) diff --git a/app/database/models.py b/app/database/models.py index 9f47209b..4e847922 100644 --- a/app/database/models.py +++ b/app/database/models.py @@ -769,6 +769,10 @@ class Tariff(Base): # Максимальный лимит трафика после докупки (0 = без ограничений) max_topup_traffic_gb = Column(Integer, default=0, nullable=False) + # Суточный тариф - ежедневное списание + is_daily = Column(Boolean, default=False, nullable=False) # Является ли тариф суточным + daily_price_kopeks = Column(Integer, default=0, nullable=False) # Цена за день в копейках + created_at = Column(DateTime, default=func.now()) updated_at = Column(DateTime, default=func.now(), onupdate=func.now()) @@ -835,6 +839,10 @@ class Tariff(Base): and not self.is_unlimited_traffic ) + def get_daily_price_rubles(self) -> float: + """Возвращает суточную цену в рублях.""" + return self.daily_price_kopeks / 100 if self.daily_price_kopeks else 0 + def __repr__(self): return f"" @@ -991,6 +999,10 @@ class Subscription(Base): # Тариф (для режима продаж "Тарифы") tariff_id = Column(Integer, ForeignKey("tariffs.id", ondelete="SET NULL"), nullable=True, index=True) + # Суточная подписка + is_daily_paused = Column(Boolean, default=False, nullable=False) # Приостановлена ли суточная подписка пользователем + last_daily_charge_at = Column(DateTime, nullable=True) # Время последнего суточного списания + user = relationship("User", back_populates="subscription") tariff = relationship("Tariff", back_populates="subscriptions") discount_offers = relationship("DiscountOffer", back_populates="subscription") @@ -1123,10 +1135,35 @@ class Subscription(Base): self.status = SubscriptionStatus.ACTIVE.value def add_traffic(self, gb: int): - if self.traffic_limit_gb == 0: + if self.traffic_limit_gb == 0: return self.traffic_limit_gb += gb + @property + def is_daily_tariff(self) -> bool: + """Проверяет, является ли тариф подписки суточным.""" + if self.tariff: + return getattr(self.tariff, 'is_daily', False) + return False + + @property + def daily_price_kopeks(self) -> int: + """Возвращает суточную цену тарифа в копейках.""" + if self.tariff: + return getattr(self.tariff, 'daily_price_kopeks', 0) + return 0 + + @property + def can_charge_daily(self) -> bool: + """Проверяет, можно ли списать суточную оплату.""" + if not self.is_daily_tariff: + return False + if self.is_daily_paused: + return False + if self.status != SubscriptionStatus.ACTIVE.value: + return False + return True + class Transaction(Base): __tablename__ = "transactions" diff --git a/app/database/universal_migration.py b/app/database/universal_migration.py index 2c5afdfd..18469e7d 100644 --- a/app/database/universal_migration.py +++ b/app/database/universal_migration.py @@ -5413,6 +5413,122 @@ async def add_tariff_traffic_topup_columns() -> bool: return False +async def add_tariff_daily_columns() -> bool: + """Добавляет колонки для суточных тарифов.""" + try: + columns_added = 0 + + # Колонка is_daily + if not await check_column_exists('tariffs', 'is_daily'): + async with engine.begin() as conn: + db_type = await get_database_type() + + if db_type == 'sqlite': + await conn.execute(text( + "ALTER TABLE tariffs ADD COLUMN is_daily INTEGER DEFAULT 0 NOT NULL" + )) + elif db_type == 'postgresql': + await conn.execute(text( + "ALTER TABLE tariffs ADD COLUMN is_daily BOOLEAN DEFAULT FALSE NOT NULL" + )) + else: # MySQL + await conn.execute(text( + "ALTER TABLE tariffs ADD COLUMN is_daily TINYINT(1) DEFAULT 0 NOT NULL" + )) + + logger.info("✅ Колонка is_daily добавлена в tariffs") + columns_added += 1 + else: + logger.info("ℹ️ Колонка is_daily уже существует в tariffs") + + # Колонка daily_price_kopeks + if not await check_column_exists('tariffs', 'daily_price_kopeks'): + async with engine.begin() as conn: + db_type = await get_database_type() + + if db_type == 'sqlite': + await conn.execute(text( + "ALTER TABLE tariffs ADD COLUMN daily_price_kopeks INTEGER DEFAULT 0 NOT NULL" + )) + elif db_type == 'postgresql': + await conn.execute(text( + "ALTER TABLE tariffs ADD COLUMN daily_price_kopeks INTEGER DEFAULT 0 NOT NULL" + )) + else: # MySQL + await conn.execute(text( + "ALTER TABLE tariffs ADD COLUMN daily_price_kopeks INT DEFAULT 0 NOT NULL" + )) + + logger.info("✅ Колонка daily_price_kopeks добавлена в tariffs") + columns_added += 1 + else: + logger.info("ℹ️ Колонка daily_price_kopeks уже существует в tariffs") + + return True + + except Exception as error: + logger.error(f"❌ Ошибка добавления колонок суточного тарифа: {error}") + return False + + +async def add_subscription_daily_columns() -> bool: + """Добавляет колонки для суточных подписок.""" + try: + columns_added = 0 + + # Колонка is_daily_paused + if not await check_column_exists('subscriptions', 'is_daily_paused'): + async with engine.begin() as conn: + db_type = await get_database_type() + + if db_type == 'sqlite': + await conn.execute(text( + "ALTER TABLE subscriptions ADD COLUMN is_daily_paused INTEGER DEFAULT 0 NOT NULL" + )) + elif db_type == 'postgresql': + await conn.execute(text( + "ALTER TABLE subscriptions ADD COLUMN is_daily_paused BOOLEAN DEFAULT FALSE NOT NULL" + )) + else: # MySQL + await conn.execute(text( + "ALTER TABLE subscriptions ADD COLUMN is_daily_paused TINYINT(1) DEFAULT 0 NOT NULL" + )) + + logger.info("✅ Колонка is_daily_paused добавлена в subscriptions") + columns_added += 1 + else: + logger.info("ℹ️ Колонка is_daily_paused уже существует в subscriptions") + + # Колонка last_daily_charge_at + if not await check_column_exists('subscriptions', 'last_daily_charge_at'): + async with engine.begin() as conn: + db_type = await get_database_type() + + if db_type == 'sqlite': + await conn.execute(text( + "ALTER TABLE subscriptions ADD COLUMN last_daily_charge_at DATETIME NULL" + )) + elif db_type == 'postgresql': + await conn.execute(text( + "ALTER TABLE subscriptions ADD COLUMN last_daily_charge_at TIMESTAMP NULL" + )) + else: # MySQL + await conn.execute(text( + "ALTER TABLE subscriptions ADD COLUMN last_daily_charge_at DATETIME NULL" + )) + + logger.info("✅ Колонка last_daily_charge_at добавлена в subscriptions") + columns_added += 1 + else: + logger.info("ℹ️ Колонка last_daily_charge_at уже существует в subscriptions") + + return True + + except Exception as error: + logger.error(f"❌ Ошибка добавления колонок суточной подписки: {error}") + return False + + async def run_universal_migration(): logger.info("=== НАЧАЛО УНИВЕРСАЛЬНОЙ МИГРАЦИИ ===") @@ -5921,6 +6037,20 @@ async def run_universal_migration(): else: logger.warning("⚠️ Проблемы с колонками докупки трафика в tariffs") + logger.info("=== ДОБАВЛЕНИЕ КОЛОНОК СУТОЧНЫХ ТАРИФОВ ===") + daily_tariff_columns_ready = await add_tariff_daily_columns() + if daily_tariff_columns_ready: + logger.info("✅ Колонки суточных тарифов в tariffs готовы") + else: + logger.warning("⚠️ Проблемы с колонками суточных тарифов в tariffs") + + logger.info("=== ДОБАВЛЕНИЕ КОЛОНОК СУТОЧНЫХ ПОДПИСОК ===") + daily_subscription_columns_ready = await add_subscription_daily_columns() + if daily_subscription_columns_ready: + logger.info("✅ Колонки суточных подписок в subscriptions готовы") + else: + logger.warning("⚠️ Проблемы с колонками суточных подписок в subscriptions") + logger.info("=== ОБНОВЛЕНИЕ ВНЕШНИХ КЛЮЧЕЙ ===") fk_updated = await fix_foreign_keys_for_user_deletion() if fk_updated: From 472ef3749073c1da11bebe36c7b2591534495724 Mon Sep 17 00:00:00 2001 From: Egor Date: Mon, 12 Jan 2026 15:25:25 +0300 Subject: [PATCH 06/61] Add files via upload --- app/database/crud/subscription.py | 164 +++++++++++++++++++++++++++++- app/database/crud/tariff.py | 10 ++ 2 files changed, 172 insertions(+), 2 deletions(-) diff --git a/app/database/crud/subscription.py b/app/database/crud/subscription.py index 37df85f2..8786ffff 100644 --- a/app/database/crud/subscription.py +++ b/app/database/crud/subscription.py @@ -1756,7 +1756,167 @@ async def activate_pending_subscription( await db.commit() await db.refresh(pending_subscription) - + logger.info(f"Подписка пользователя {user_id} активирована, ID: {pending_subscription.id}") - + return pending_subscription + + +# ==================== СУТОЧНЫЕ ПОДПИСКИ ==================== + + +async def get_daily_subscriptions_for_charge(db: AsyncSession) -> List[Subscription]: + """ + Получает все суточные подписки, которые нужно обработать для списания. + + Критерии: + - Тариф подписки суточный (is_daily=True) + - Подписка активна + - Подписка не приостановлена пользователем + - Прошло более 24 часов с последнего списания (или списания ещё не было) + """ + from app.database.models import Tariff + + now = datetime.utcnow() + one_day_ago = now - timedelta(hours=24) + + query = ( + select(Subscription) + .join(Tariff, Subscription.tariff_id == Tariff.id) + .options( + selectinload(Subscription.user), + selectinload(Subscription.tariff), + ) + .where( + and_( + Tariff.is_daily.is_(True), + Tariff.is_active.is_(True), + Subscription.status == SubscriptionStatus.ACTIVE.value, + Subscription.is_daily_paused.is_(False), + # Списания ещё не было ИЛИ прошло более 24 часов + ( + (Subscription.last_daily_charge_at.is_(None)) | + (Subscription.last_daily_charge_at < one_day_ago) + ), + ) + ) + ) + + result = await db.execute(query) + subscriptions = result.scalars().all() + + logger.info( + f"🔍 Найдено {len(subscriptions)} суточных подписок для списания" + ) + + return list(subscriptions) + + +async def pause_daily_subscription( + db: AsyncSession, + subscription: Subscription, +) -> Subscription: + """Приостанавливает суточную подписку (списание не будет происходить).""" + if not subscription.is_daily_tariff: + logger.warning( + f"Попытка приостановить не-суточную подписку {subscription.id}" + ) + return subscription + + subscription.is_daily_paused = True + await db.commit() + await db.refresh(subscription) + + logger.info( + f"⏸️ Суточная подписка {subscription.id} приостановлена пользователем {subscription.user_id}" + ) + + return subscription + + +async def resume_daily_subscription( + db: AsyncSession, + subscription: Subscription, +) -> Subscription: + """Возобновляет суточную подписку (списание продолжится).""" + if not subscription.is_daily_tariff: + logger.warning( + f"Попытка возобновить не-суточную подписку {subscription.id}" + ) + return subscription + + subscription.is_daily_paused = False + await db.commit() + await db.refresh(subscription) + + logger.info( + f"▶️ Суточная подписка {subscription.id} возобновлена пользователем {subscription.user_id}" + ) + + return subscription + + +async def update_daily_charge_time( + db: AsyncSession, + subscription: Subscription, + charge_time: datetime = None, +) -> Subscription: + """Обновляет время последнего суточного списания.""" + subscription.last_daily_charge_at = charge_time or datetime.utcnow() + await db.commit() + await db.refresh(subscription) + + return subscription + + +async def suspend_daily_subscription_insufficient_balance( + db: AsyncSession, + subscription: Subscription, +) -> Subscription: + """ + Приостанавливает подписку из-за недостатка баланса. + Отличается от pause_daily_subscription тем, что меняет статус на DISABLED. + """ + subscription.status = SubscriptionStatus.DISABLED.value + await db.commit() + await db.refresh(subscription) + + logger.info( + f"⚠️ Суточная подписка {subscription.id} приостановлена: недостаточно средств (user_id={subscription.user_id})" + ) + + return subscription + + +async def get_subscription_with_tariff( + db: AsyncSession, + user_id: int, +) -> Optional[Subscription]: + """Получает подписку пользователя с загруженным тарифом.""" + result = await db.execute( + select(Subscription) + .options( + selectinload(Subscription.user), + selectinload(Subscription.tariff), + ) + .where(Subscription.user_id == user_id) + .order_by(Subscription.created_at.desc()) + .limit(1) + ) + subscription = result.scalar_one_or_none() + + if subscription: + subscription = await check_and_update_subscription_status(db, subscription) + + return subscription + + +async def toggle_daily_subscription_pause( + db: AsyncSession, + subscription: Subscription, +) -> Subscription: + """Переключает состояние паузы суточной подписки.""" + if subscription.is_daily_paused: + return await resume_daily_subscription(db, subscription) + else: + return await pause_daily_subscription(db, subscription) diff --git a/app/database/crud/tariff.py b/app/database/crud/tariff.py index 04c4f193..b607608c 100644 --- a/app/database/crud/tariff.py +++ b/app/database/crud/tariff.py @@ -170,6 +170,8 @@ async def create_tariff( traffic_topup_enabled: bool = False, traffic_topup_packages: Optional[Dict[str, int]] = None, max_topup_traffic_gb: int = 0, + is_daily: bool = False, + daily_price_kopeks: int = 0, ) -> Tariff: """Создает новый тариф.""" normalized_prices = _normalize_period_prices(period_prices) @@ -188,6 +190,8 @@ async def create_tariff( traffic_topup_enabled=traffic_topup_enabled, traffic_topup_packages=traffic_topup_packages or {}, max_topup_traffic_gb=max(0, max_topup_traffic_gb), + is_daily=is_daily, + daily_price_kopeks=max(0, daily_price_kopeks), ) db.add(tariff) @@ -236,6 +240,8 @@ async def update_tariff( traffic_topup_enabled: Optional[bool] = None, traffic_topup_packages: Optional[Dict[str, int]] = None, max_topup_traffic_gb: Optional[int] = None, + is_daily: Optional[bool] = None, + daily_price_kopeks: Optional[int] = None, ) -> Tariff: """Обновляет существующий тариф.""" if name is not None: @@ -267,6 +273,10 @@ async def update_tariff( tariff.traffic_topup_packages = traffic_topup_packages if max_topup_traffic_gb is not None: tariff.max_topup_traffic_gb = max(0, max_topup_traffic_gb) + if is_daily is not None: + tariff.is_daily = is_daily + if daily_price_kopeks is not None: + tariff.daily_price_kopeks = max(0, daily_price_kopeks) # Обновляем промогруппы если указаны if promo_group_ids is not None: From 538c002f8f9cf34a146618aee6b1a7e761a60503 Mon Sep 17 00:00:00 2001 From: Egor Date: Mon, 12 Jan 2026 15:25:47 +0300 Subject: [PATCH 07/61] Add files via upload --- app/config.py | 4 ++++ app/states.py | 1 + 2 files changed, 5 insertions(+) diff --git a/app/config.py b/app/config.py index b97779ff..fd169be7 100644 --- a/app/config.py +++ b/app/config.py @@ -242,6 +242,10 @@ class Settings(BaseSettings): TRAFFIC_MONITORING_INTERVAL_HOURS: int = 24 # Интервал проверки в часах (по умолчанию - раз в сутки) SUSPICIOUS_NOTIFICATIONS_TOPIC_ID: Optional[int] = None + # Настройки суточных подписок + DAILY_SUBSCRIPTIONS_ENABLED: bool = True # Включить автоматическое списание для суточных тарифов + DAILY_SUBSCRIPTIONS_CHECK_INTERVAL_MINUTES: int = 30 # Интервал проверки в минутах + AUTOPAY_WARNING_DAYS: str = "3,1" ENABLE_AUTOPAY: bool = False diff --git a/app/states.py b/app/states.py index 88ebce08..7e608051 100644 --- a/app/states.py +++ b/app/states.py @@ -182,6 +182,7 @@ class AdminStates(StatesGroup): editing_tariff_promo_groups = State() editing_tariff_traffic_topup_packages = State() editing_tariff_max_topup_traffic = State() + editing_tariff_daily_price = State() class SupportStates(StatesGroup): From 785eabaa4770a10294fd04a9d4ad927450bd12ac Mon Sep 17 00:00:00 2001 From: Egor Date: Mon, 12 Jan 2026 15:26:20 +0300 Subject: [PATCH 08/61] Update tariffs.py --- app/handlers/admin/tariffs.py | 156 ++++++++++++++++++++++++++++++++++ 1 file changed, 156 insertions(+) diff --git a/app/handlers/admin/tariffs.py b/app/handlers/admin/tariffs.py index 71434a7b..73e25e30 100644 --- a/app/handlers/admin/tariffs.py +++ b/app/handlers/admin/tariffs.py @@ -199,6 +199,20 @@ def get_tariff_view_keyboard( InlineKeyboardButton(text="👥 Промогруппы", callback_data=f"admin_tariff_edit_promo:{tariff.id}"), ]) + # Суточный режим + is_daily = getattr(tariff, 'is_daily', False) + if is_daily: + buttons.append([ + InlineKeyboardButton(text="🔄 ❌ Отключить суточный режим", callback_data=f"admin_tariff_toggle_daily:{tariff.id}"), + ]) + buttons.append([ + InlineKeyboardButton(text="💰 Суточная цена", callback_data=f"admin_tariff_edit_daily_price:{tariff.id}"), + ]) + else: + buttons.append([ + InlineKeyboardButton(text="🔄 Включить суточный режим", callback_data=f"admin_tariff_toggle_daily:{tariff.id}"), + ]) + # Переключение триала if tariff.is_trial_available: buttons.append([ @@ -287,6 +301,14 @@ def format_tariff_info(tariff: Tariff, language: str, subs_count: int = 0) -> st # Форматируем докупку трафика traffic_topup_display = _format_traffic_topup_packages(tariff) + # Форматируем суточный тариф + is_daily = getattr(tariff, 'is_daily', False) + daily_price_kopeks = getattr(tariff, 'daily_price_kopeks', 0) + if is_daily: + daily_status = f"✅ Включен ({_format_price_kopeks(daily_price_kopeks)}/день)" + else: + daily_status = "❌ Отключен" + return f"""📦 Тариф: {tariff.name} {status} @@ -300,6 +322,8 @@ def format_tariff_info(tariff: Tariff, language: str, subs_count: int = 0) -> st • Триал: {trial_status} • Дней триала: {trial_days_display} +🔄 Суточный режим: {daily_status} + Докупка трафика: {traffic_topup_display} @@ -496,6 +520,133 @@ async def toggle_trial_tariff( ) +@admin_required +@error_handler +async def toggle_daily_tariff( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession, +): + """Переключает суточный режим тарифа.""" + tariff_id = int(callback.data.split(":")[1]) + tariff = await get_tariff_by_id(db, tariff_id) + + if not tariff: + await callback.answer("Тариф не найден", show_alert=True) + return + + is_daily = getattr(tariff, 'is_daily', False) + + if is_daily: + # Отключаем суточный режим + tariff = await update_tariff(db, tariff, is_daily=False, daily_price_kopeks=0) + await callback.answer("Суточный режим отключен", show_alert=True) + else: + # Включаем суточный режим (с ценой по умолчанию) + tariff = await update_tariff(db, tariff, is_daily=True, daily_price_kopeks=5000) # 50 руб по умолчанию + await callback.answer( + f"Суточный режим включен. Цена: 50 ₽/день\n" + "Настройте цену через кнопку «💰 Суточная цена»", + show_alert=True + ) + + subs_count = await get_tariff_subscriptions_count(db, tariff_id) + + await callback.message.edit_text( + format_tariff_info(tariff, db_user.language, subs_count), + reply_markup=get_tariff_view_keyboard(tariff, db_user.language), + parse_mode="HTML" + ) + + +@admin_required +@error_handler +async def start_edit_daily_price( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession, + state: FSMContext, +): + """Начинает редактирование суточной цены.""" + texts = get_texts(db_user.language) + + tariff_id = int(callback.data.split(":")[1]) + tariff = await get_tariff_by_id(db, tariff_id) + + if not tariff: + await callback.answer("Тариф не найден", show_alert=True) + return + + current_price = getattr(tariff, 'daily_price_kopeks', 0) + current_rubles = current_price / 100 if current_price else 0 + + await state.set_state(AdminStates.editing_tariff_daily_price) + await state.update_data(tariff_id=tariff_id, language=db_user.language) + + await callback.message.edit_text( + f"💰 Редактирование суточной цены\n\n" + f"Тариф: {tariff.name}\n" + f"Текущая цена: {_format_price_kopeks(current_price)}/день\n\n" + "Введите новую цену за день в рублях.\n" + "Пример: 50 или 99.90", + reply_markup=InlineKeyboardMarkup(inline_keyboard=[ + [InlineKeyboardButton(text=texts.CANCEL, callback_data=f"admin_tariff_view:{tariff_id}")] + ]), + parse_mode="HTML" + ) + await callback.answer() + + +@admin_required +@error_handler +async def process_daily_price_input( + message: types.Message, + db_user: User, + db: AsyncSession, + state: FSMContext, +): + """Обрабатывает ввод суточной цены.""" + texts = get_texts(db_user.language) + data = await state.get_data() + tariff_id = data.get("tariff_id") + + if not tariff_id: + await state.clear() + return + + tariff = await get_tariff_by_id(db, tariff_id) + if not tariff: + await message.answer("Тариф не найден") + await state.clear() + return + + try: + price_rubles = float(message.text.strip().replace(",", ".")) + if price_rubles < 0: + raise ValueError("Цена не может быть отрицательной") + + price_kopeks = int(price_rubles * 100) + except ValueError: + await message.answer( + "❌ Некорректная цена. Введите число.\n" + "Пример: 50 или 99.90", + parse_mode="HTML" + ) + return + + tariff = await update_tariff(db, tariff, daily_price_kopeks=price_kopeks) + await state.clear() + + subs_count = await get_tariff_subscriptions_count(db, tariff_id) + + await message.answer( + f"✅ Суточная цена установлена: {_format_price_kopeks(price_kopeks)}/день\n\n" + + format_tariff_info(tariff, db_user.language, subs_count), + reply_markup=get_tariff_view_keyboard(tariff, db_user.language), + parse_mode="HTML" + ) + + # ============ СОЗДАНИЕ ТАРИФА ============ @admin_required @@ -2318,3 +2469,8 @@ def register_handlers(dp: Dispatcher): dp.callback_query.register(start_edit_tariff_promo_groups, F.data.startswith("admin_tariff_edit_promo:")) dp.callback_query.register(toggle_tariff_promo_group, F.data.startswith("admin_tariff_toggle_promo:")) dp.callback_query.register(clear_tariff_promo_groups, F.data.startswith("admin_tariff_clear_promo:")) + + # Суточный режим + dp.callback_query.register(toggle_daily_tariff, F.data.startswith("admin_tariff_toggle_daily:")) + dp.callback_query.register(start_edit_daily_price, F.data.startswith("admin_tariff_edit_daily_price:")) + dp.message.register(process_daily_price_input, AdminStates.editing_tariff_daily_price) From 22884b5cddff8433c58cfb52c2d08856746b7cb1 Mon Sep 17 00:00:00 2001 From: Egor Date: Mon, 12 Jan 2026 15:27:05 +0300 Subject: [PATCH 09/61] Update purchase.py --- app/handlers/subscription/purchase.py | 118 +++++++++++++++++++++++++- 1 file changed, 117 insertions(+), 1 deletion(-) diff --git a/app/handlers/subscription/purchase.py b/app/handlers/subscription/purchase.py index 3a1680a2..281ca051 100644 --- a/app/handlers/subscription/purchase.py +++ b/app/handlers/subscription/purchase.py @@ -3016,7 +3016,12 @@ async def handle_subscription_settings( await callback.message.edit_text( settings_text, - reply_markup=get_updated_subscription_settings_keyboard(db_user.language, show_countries, tariff=tariff), + reply_markup=get_updated_subscription_settings_keyboard( + db_user.language, + show_countries, + tariff=tariff, + subscription=subscription + ), parse_mode="HTML" ) await callback.answer() @@ -3037,6 +3042,112 @@ async def clear_saved_cart( await callback.answer("🗑️ Корзина очищена") +# ============== ХЕНДЛЕР ПАУЗЫ СУТОЧНОЙ ПОДПИСКИ ============== + +async def handle_toggle_daily_subscription_pause( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession +): + """Переключает паузу суточной подписки.""" + from app.database.crud.subscription import toggle_daily_subscription_pause + from app.database.crud.tariff import get_tariff_by_id + + texts = get_texts(db_user.language) + subscription = db_user.subscription + + if not subscription: + await callback.answer( + texts.t("NO_SUBSCRIPTION_ERROR", "❌ У вас нет активной подписки"), + show_alert=True + ) + return + + # Проверяем что это суточный тариф + tariff = None + if subscription.tariff_id: + tariff = await get_tariff_by_id(db, subscription.tariff_id) + + if not tariff or not getattr(tariff, 'is_daily', False): + await callback.answer( + texts.t("NOT_DAILY_TARIFF_ERROR", "❌ Эта функция доступна только для суточных тарифов"), + show_alert=True + ) + return + + # Переключаем статус паузы + was_paused = subscription.is_daily_paused + subscription = await toggle_daily_subscription_pause(db, subscription) + + if was_paused: + # Была пауза, теперь возобновили + message = texts.t( + "DAILY_SUBSCRIPTION_RESUMED", + "▶️ Суточная подписка возобновлена.\n\nСписание будет произведено в ближайший цикл проверки." + ) + else: + # Была активна, теперь на паузе + message = texts.t( + "DAILY_SUBSCRIPTION_PAUSED", + "⏸️ Суточная подписка приостановлена.\n\nСписания не будут производиться до возобновления." + ) + + await callback.answer(message, show_alert=True) + + # Обновляем клавиатуру настроек + show_countries = await _should_show_countries_management(db_user) + + settings_template = texts.t( + "SUBSCRIPTION_SETTINGS_OVERVIEW", + ( + "⚙️ Настройки подписки\n\n" + "📊 Текущие параметры:\n" + "🌐 Стран: {countries_count}\n" + "📈 Трафик: {traffic_used} / {traffic_limit}\n" + "📱 Устройства: {devices_used} / {devices_limit}\n\n" + "Выберите что хотите изменить:" + ), + ) + + show_devices = settings.is_devices_selection_enabled() + if not show_devices: + settings_template = settings_template.replace( + "\n📱 Устройства: {devices_used} / {devices_limit}", + "", + ) + + if show_devices: + devices_used = await get_current_devices_count(db_user) + else: + devices_used = 0 + + modem_enabled = getattr(subscription, 'modem_enabled', False) or False + if modem_enabled and settings.is_modem_enabled(): + visible_device_limit = (subscription.device_limit or 1) - 1 + devices_limit_display = f"{visible_device_limit} + модем" + else: + devices_limit_display = str(subscription.device_limit) + + settings_text = settings_template.format( + countries_count=len(subscription.connected_squads), + traffic_used=texts.format_traffic(subscription.traffic_used_gb), + traffic_limit=texts.format_traffic(subscription.traffic_limit_gb), + devices_used=devices_used, + devices_limit=devices_limit_display, + ) + + await callback.message.edit_text( + settings_text, + reply_markup=get_updated_subscription_settings_keyboard( + db_user.language, + show_countries, + tariff=tariff, + subscription=subscription + ), + parse_mode="HTML" + ) + + # ============== ХЕНДЛЕРЫ ПЛАТНОГО ТРИАЛА ============== @error_handler @@ -3993,6 +4104,11 @@ def register_handlers(dp: Dispatcher): F.data == "subscription_settings" ) + dp.callback_query.register( + handle_toggle_daily_subscription_pause, + F.data == "toggle_daily_subscription_pause" + ) + dp.callback_query.register( handle_no_traffic_packages, F.data == "no_traffic_packages" From 42978e2a373c919f0dd3e7e7e0aaf07c8f0db77b Mon Sep 17 00:00:00 2001 From: Egor Date: Mon, 12 Jan 2026 15:27:37 +0300 Subject: [PATCH 10/61] Update inline.py --- app/keyboards/inline.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/app/keyboards/inline.py b/app/keyboards/inline.py index 2b4e4ca5..1979df1f 100644 --- a/app/keyboards/inline.py +++ b/app/keyboards/inline.py @@ -2514,6 +2514,7 @@ def get_updated_subscription_settings_keyboard( language: str = DEFAULT_LANGUAGE, show_countries_management: bool = True, tariff=None, # Тариф подписки (если есть - ограничиваем настройки) + subscription=None, # Подписка (для проверки суточной паузы) ) -> InlineKeyboardMarkup: from app.config import settings @@ -2523,6 +2524,17 @@ def get_updated_subscription_settings_keyboard( # Если подписка на тарифе - отключаем страны, модем, трафик has_tariff = tariff is not None + # Кнопка паузы/возобновления суточной подписки + if tariff and getattr(tariff, 'is_daily', False) and subscription: + is_paused = getattr(subscription, 'is_daily_paused', False) + if is_paused: + button_text = texts.t("RESUME_DAILY_SUBSCRIPTION_BUTTON", "▶️ Возобновить суточный тариф") + else: + button_text = texts.t("PAUSE_DAILY_SUBSCRIPTION_BUTTON", "⏸️ Приостановить суточный тариф") + keyboard.append([ + InlineKeyboardButton(text=button_text, callback_data="toggle_daily_subscription_pause") + ]) + if show_countries_management and not has_tariff: keyboard.append([ InlineKeyboardButton(text=texts.t("ADD_COUNTRIES_BUTTON", "🌐 Добавить страны"), callback_data="subscription_add_countries") From 24666dd15596a0eb8af18ad902fe76ab7a10e0ff Mon Sep 17 00:00:00 2001 From: Egor Date: Mon, 12 Jan 2026 15:48:20 +0300 Subject: [PATCH 11/61] Update tariffs.py --- app/handlers/admin/tariffs.py | 192 ++++++++++++++++++++++++++-------- 1 file changed, 147 insertions(+), 45 deletions(-) diff --git a/app/handlers/admin/tariffs.py b/app/handlers/admin/tariffs.py index 73e25e30..888cccb6 100644 --- a/app/handlers/admin/tariffs.py +++ b/app/handlers/admin/tariffs.py @@ -183,10 +183,17 @@ def get_tariff_view_keyboard( InlineKeyboardButton(text="📊 Трафик", callback_data=f"admin_tariff_edit_traffic:{tariff.id}"), InlineKeyboardButton(text="📱 Устройства", callback_data=f"admin_tariff_edit_devices:{tariff.id}"), ]) - buttons.append([ - InlineKeyboardButton(text="💰 Цены", callback_data=f"admin_tariff_edit_prices:{tariff.id}"), - InlineKeyboardButton(text="🎚️ Уровень", callback_data=f"admin_tariff_edit_tier:{tariff.id}"), - ]) + # Цены за периоды только для обычных тарифов (не суточных) + is_daily = getattr(tariff, 'is_daily', False) + if not is_daily: + buttons.append([ + InlineKeyboardButton(text="💰 Цены", callback_data=f"admin_tariff_edit_prices:{tariff.id}"), + InlineKeyboardButton(text="🎚️ Уровень", callback_data=f"admin_tariff_edit_tier:{tariff.id}"), + ]) + else: + buttons.append([ + InlineKeyboardButton(text="🎚️ Уровень", callback_data=f"admin_tariff_edit_tier:{tariff.id}"), + ]) buttons.append([ InlineKeyboardButton(text="📱💰 Цена за устройство", callback_data=f"admin_tariff_edit_device_price:{tariff.id}"), InlineKeyboardButton(text="⏰ Дни триала", callback_data=f"admin_tariff_edit_trial_days:{tariff.id}"), @@ -199,19 +206,13 @@ def get_tariff_view_keyboard( InlineKeyboardButton(text="👥 Промогруппы", callback_data=f"admin_tariff_edit_promo:{tariff.id}"), ]) - # Суточный режим - is_daily = getattr(tariff, 'is_daily', False) + # Суточный режим - только для уже суточных тарифов показываем настройки + # Новые тарифы делаются суточными только при создании if is_daily: - buttons.append([ - InlineKeyboardButton(text="🔄 ❌ Отключить суточный режим", callback_data=f"admin_tariff_toggle_daily:{tariff.id}"), - ]) buttons.append([ InlineKeyboardButton(text="💰 Суточная цена", callback_data=f"admin_tariff_edit_daily_price:{tariff.id}"), ]) - else: - buttons.append([ - InlineKeyboardButton(text="🔄 Включить суточный режим", callback_data=f"admin_tariff_toggle_daily:{tariff.id}"), - ]) + # Примечание: отключение суточного режима убрано - это необратимое решение при создании # Переключение триала if tariff.is_trial_available: @@ -304,14 +305,18 @@ def format_tariff_info(tariff: Tariff, language: str, subs_count: int = 0) -> st # Форматируем суточный тариф is_daily = getattr(tariff, 'is_daily', False) daily_price_kopeks = getattr(tariff, 'daily_price_kopeks', 0) + + # Формируем блок цен в зависимости от типа тарифа if is_daily: - daily_status = f"✅ Включен ({_format_price_kopeks(daily_price_kopeks)}/день)" + price_block = f"💰 Суточная цена: {_format_price_kopeks(daily_price_kopeks)}/день" + tariff_type = "🔄 Суточный" else: - daily_status = "❌ Отключен" + price_block = f"Цены:\n{prices_display}" + tariff_type = "📅 Периодный" return f"""📦 Тариф: {tariff.name} -{status} +{status} | {tariff_type} 🎚️ Уровень: {tariff.tier_level} 📊 Порядок: {tariff.display_order} @@ -322,13 +327,10 @@ def format_tariff_info(tariff: Tariff, language: str, subs_count: int = 0) -> st • Триал: {trial_status} • Дней триала: {trial_days_display} -🔄 Суточный режим: {daily_status} - Докупка трафика: {traffic_topup_display} -Цены: -{prices_display} +{price_block} Серверы: {squads_display} Промогруппы: {promo_display} @@ -605,46 +607,73 @@ async def process_daily_price_input( db: AsyncSession, state: FSMContext, ): - """Обрабатывает ввод суточной цены.""" + """Обрабатывает ввод суточной цены (создание и редактирование).""" texts = get_texts(db_user.language) data = await state.get_data() tariff_id = data.get("tariff_id") - if not tariff_id: - await state.clear() - return - - tariff = await get_tariff_by_id(db, tariff_id) - if not tariff: - await message.answer("Тариф не найден") - await state.clear() - return - + # Парсим цену try: price_rubles = float(message.text.strip().replace(",", ".")) - if price_rubles < 0: - raise ValueError("Цена не может быть отрицательной") + if price_rubles <= 0: + raise ValueError("Цена должна быть положительной") price_kopeks = int(price_rubles * 100) except ValueError: await message.answer( - "❌ Некорректная цена. Введите число.\n" + "❌ Некорректная цена. Введите положительное число.\n" "Пример: 50 или 99.90", parse_mode="HTML" ) return - tariff = await update_tariff(db, tariff, daily_price_kopeks=price_kopeks) - await state.clear() + # Проверяем - это создание или редактирование + is_creating = data.get("tariff_is_daily") and not tariff_id - subs_count = await get_tariff_subscriptions_count(db, tariff_id) + if is_creating: + # Создаем новый суточный тариф + tariff = await create_tariff( + db, + name=data['tariff_name'], + traffic_limit_gb=data['tariff_traffic'], + device_limit=data['tariff_devices'], + tier_level=data['tariff_tier'], + period_prices={}, + is_active=True, + is_daily=True, + daily_price_kopeks=price_kopeks, + ) + await state.clear() - await message.answer( - f"✅ Суточная цена установлена: {_format_price_kopeks(price_kopeks)}/день\n\n" - + format_tariff_info(tariff, db_user.language, subs_count), - reply_markup=get_tariff_view_keyboard(tariff, db_user.language), - parse_mode="HTML" - ) + await message.answer( + f"✅ Суточный тариф создан!\n\n" + + format_tariff_info(tariff, db_user.language, 0), + reply_markup=get_tariff_view_keyboard(tariff, db_user.language), + parse_mode="HTML" + ) + else: + # Редактируем существующий тариф + if not tariff_id: + await state.clear() + return + + tariff = await get_tariff_by_id(db, tariff_id) + if not tariff: + await message.answer("Тариф не найден") + await state.clear() + return + + tariff = await update_tariff(db, tariff, daily_price_kopeks=price_kopeks) + await state.clear() + + subs_count = await get_tariff_subscriptions_count(db, tariff_id) + + await message.answer( + f"✅ Суточная цена установлена: {_format_price_kopeks(price_kopeks)}/день\n\n" + + format_tariff_info(tariff, db_user.language, subs_count), + reply_markup=get_tariff_view_keyboard(tariff, db_user.language), + parse_mode="HTML" + ) # ============ СОЗДАНИЕ ТАРИФА ============ @@ -811,17 +840,51 @@ async def process_tariff_tier( data = await state.get_data() await state.update_data(tariff_tier=tier) - await state.set_state(AdminStates.creating_tariff_prices) traffic_display = _format_traffic(data['tariff_traffic']) + # Шаг 5/6: Выбор типа тарифа await message.answer( "📦 Создание тарифа\n\n" f"Название: {data['tariff_name']}\n" f"Трафик: {traffic_display}\n" f"Устройств: {data['tariff_devices']}\n" f"Уровень: {tier}\n\n" - "Шаг 5/6: Введите цены на периоды\n\n" + "Шаг 5/6: Выберите тип тарифа", + reply_markup=InlineKeyboardMarkup(inline_keyboard=[ + [InlineKeyboardButton(text="📅 Периодный (месяцы)", callback_data="tariff_type_periodic")], + [InlineKeyboardButton(text="🔄 Суточный (оплата за день)", callback_data="tariff_type_daily")], + [InlineKeyboardButton(text=texts.CANCEL, callback_data="admin_tariffs")] + ]), + parse_mode="HTML" + ) + + +@admin_required +@error_handler +async def select_tariff_type_periodic( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession, + state: FSMContext, +): + """Выбирает периодный тип тарифа.""" + texts = get_texts(db_user.language) + data = await state.get_data() + + await state.update_data(tariff_is_daily=False) + await state.set_state(AdminStates.creating_tariff_prices) + + traffic_display = _format_traffic(data['tariff_traffic']) + + await callback.message.edit_text( + "📦 Создание тарифа\n\n" + f"Название: {data['tariff_name']}\n" + f"Трафик: {traffic_display}\n" + f"Устройств: {data['tariff_devices']}\n" + f"Уровень: {data['tariff_tier']}\n" + f"Тип: 📅 Периодный\n\n" + "Шаг 6/6: Введите цены на периоды\n\n" "Формат: дней:цена_в_копейках\n" "Несколько периодов через запятую\n\n" "Пример:\n30:9900, 90:24900, 180:44900, 360:79900", @@ -830,6 +893,43 @@ async def process_tariff_tier( ]), parse_mode="HTML" ) + await callback.answer() + + +@admin_required +@error_handler +async def select_tariff_type_daily( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession, + state: FSMContext, +): + """Выбирает суточный тип тарифа.""" + from app.states import AdminStates + + texts = get_texts(db_user.language) + data = await state.get_data() + + await state.update_data(tariff_is_daily=True) + await state.set_state(AdminStates.editing_tariff_daily_price) + + traffic_display = _format_traffic(data['tariff_traffic']) + + await callback.message.edit_text( + "📦 Создание суточного тарифа\n\n" + f"Название: {data['tariff_name']}\n" + f"Трафик: {traffic_display}\n" + f"Устройств: {data['tariff_devices']}\n" + f"Уровень: {data['tariff_tier']}\n" + f"Тип: 🔄 Суточный\n\n" + "Шаг 6/6: Введите суточную цену в рублях\n\n" + "Пример: 50 (50 ₽/день), 99.90 (99.90 ₽/день)", + reply_markup=InlineKeyboardMarkup(inline_keyboard=[ + [InlineKeyboardButton(text=texts.CANCEL, callback_data="admin_tariffs")] + ]), + parse_mode="HTML" + ) + await callback.answer() @admin_required @@ -2411,6 +2511,8 @@ def register_handlers(dp: Dispatcher): dp.message.register(process_tariff_traffic, AdminStates.creating_tariff_traffic) dp.message.register(process_tariff_devices, AdminStates.creating_tariff_devices) dp.message.register(process_tariff_tier, AdminStates.creating_tariff_tier) + dp.callback_query.register(select_tariff_type_periodic, F.data == "tariff_type_periodic") + dp.callback_query.register(select_tariff_type_daily, F.data == "tariff_type_daily") dp.message.register(process_tariff_prices, AdminStates.creating_tariff_prices) # Редактирование названия From 7c4471a5101715d596e2956ba9aae79114f38102 Mon Sep 17 00:00:00 2001 From: Egor Date: Mon, 12 Jan 2026 15:48:51 +0300 Subject: [PATCH 12/61] Update inline.py --- app/keyboards/inline.py | 46 +++++++++++++++++++++++------------------ 1 file changed, 26 insertions(+), 20 deletions(-) diff --git a/app/keyboards/inline.py b/app/keyboards/inline.py index 1979df1f..7559d9de 100644 --- a/app/keyboards/inline.py +++ b/app/keyboards/inline.py @@ -980,23 +980,38 @@ def get_subscription_keyboard( InlineKeyboardButton(text=texts.MENU_BUY_SUBSCRIPTION, callback_data="subscription_upgrade") ]) else: - # Ряд: [Продлить] [Автоплатеж] - keyboard.append([ - InlineKeyboardButton(text=texts.MENU_EXTEND_SUBSCRIPTION, callback_data="subscription_extend"), - InlineKeyboardButton( - text=texts.t("AUTOPAY_BUTTON", "💳 Автоплатеж"), - callback_data="subscription_autopay", - ) - ]) + # Проверяем, является ли тариф суточным + tariff = getattr(subscription, 'tariff', None) if subscription else None + is_daily_tariff = tariff and getattr(tariff, 'is_daily', False) - # Ряд: [Настройки] [Тариф] (если режим тарифов) + if is_daily_tariff: + # Для суточного тарифа показываем кнопку паузы/возобновления + is_paused = getattr(subscription, 'is_daily_paused', False) + if is_paused: + pause_text = texts.t("RESUME_DAILY_BUTTON", "▶️ Возобновить подписку") + else: + pause_text = texts.t("PAUSE_DAILY_BUTTON", "⏸️ Приостановить подписку") + keyboard.append([ + InlineKeyboardButton(text=pause_text, callback_data="toggle_daily_subscription_pause") + ]) + else: + # Для обычного тарифа: [Продлить] [Автоплатеж] + keyboard.append([ + InlineKeyboardButton(text=texts.MENU_EXTEND_SUBSCRIPTION, callback_data="subscription_extend"), + InlineKeyboardButton( + text=texts.t("AUTOPAY_BUTTON", "💳 Автоплатеж"), + callback_data="subscription_autopay", + ) + ]) + + # Ряд: [Настройки] [Тариф] (если режим тарифов и не суточный) settings_row = [ InlineKeyboardButton( text=texts.t("SUBSCRIPTION_SETTINGS_BUTTON", "⚙️ Настройки"), callback_data="subscription_settings", ) ] - if settings.is_tariffs_mode() and subscription: + if settings.is_tariffs_mode() and subscription and not is_daily_tariff: settings_row.append( InlineKeyboardButton( text=texts.t("CHANGE_TARIFF_BUTTON", "📦 Тариф"), @@ -2524,16 +2539,7 @@ def get_updated_subscription_settings_keyboard( # Если подписка на тарифе - отключаем страны, модем, трафик has_tariff = tariff is not None - # Кнопка паузы/возобновления суточной подписки - if tariff and getattr(tariff, 'is_daily', False) and subscription: - is_paused = getattr(subscription, 'is_daily_paused', False) - if is_paused: - button_text = texts.t("RESUME_DAILY_SUBSCRIPTION_BUTTON", "▶️ Возобновить суточный тариф") - else: - button_text = texts.t("PAUSE_DAILY_SUBSCRIPTION_BUTTON", "⏸️ Приостановить суточный тариф") - keyboard.append([ - InlineKeyboardButton(text=button_text, callback_data="toggle_daily_subscription_pause") - ]) + # Для суточных тарифов кнопка паузы теперь в главном меню подписки if show_countries_management and not has_tariff: keyboard.append([ From 6fc3dd0e357ea172baaa2ec431d0c4c437807959 Mon Sep 17 00:00:00 2001 From: Egor Date: Mon, 12 Jan 2026 15:49:25 +0300 Subject: [PATCH 13/61] Update purchase.py --- app/handlers/subscription/purchase.py | 67 +++++---------------------- 1 file changed, 12 insertions(+), 55 deletions(-) diff --git a/app/handlers/subscription/purchase.py b/app/handlers/subscription/purchase.py index 281ca051..085521cc 100644 --- a/app/handlers/subscription/purchase.py +++ b/app/handlers/subscription/purchase.py @@ -335,12 +335,15 @@ async def show_subscription_info( # Получаем название тарифа для режима тарифов tariff_line = "" + tariff = None if settings.is_tariffs_mode() and subscription.tariff_id: try: from app.database.crud.tariff import get_tariff_by_id tariff = await get_tariff_by_id(db, subscription.tariff_id) if tariff: tariff_line = f"\n📦 Тариф: {tariff.name}" + # Прикрепляем тариф к подписке для использования в клавиатуре + subscription.tariff = tariff except Exception as e: logger.warning(f"Ошибка получения тарифа: {e}") @@ -3075,77 +3078,31 @@ async def handle_toggle_daily_subscription_pause( ) return + # Прикрепляем тариф к подписке для CRUD функций + subscription.tariff = tariff + # Переключаем статус паузы - was_paused = subscription.is_daily_paused + was_paused = getattr(subscription, 'is_daily_paused', False) subscription = await toggle_daily_subscription_pause(db, subscription) if was_paused: # Была пауза, теперь возобновили message = texts.t( "DAILY_SUBSCRIPTION_RESUMED", - "▶️ Суточная подписка возобновлена.\n\nСписание будет произведено в ближайший цикл проверки." + "▶️ Подписка возобновлена!" ) else: # Была активна, теперь на паузе message = texts.t( "DAILY_SUBSCRIPTION_PAUSED", - "⏸️ Суточная подписка приостановлена.\n\nСписания не будут производиться до возобновления." + "⏸️ Подписка приостановлена!" ) await callback.answer(message, show_alert=True) - # Обновляем клавиатуру настроек - show_countries = await _should_show_countries_management(db_user) - - settings_template = texts.t( - "SUBSCRIPTION_SETTINGS_OVERVIEW", - ( - "⚙️ Настройки подписки\n\n" - "📊 Текущие параметры:\n" - "🌐 Стран: {countries_count}\n" - "📈 Трафик: {traffic_used} / {traffic_limit}\n" - "📱 Устройства: {devices_used} / {devices_limit}\n\n" - "Выберите что хотите изменить:" - ), - ) - - show_devices = settings.is_devices_selection_enabled() - if not show_devices: - settings_template = settings_template.replace( - "\n📱 Устройства: {devices_used} / {devices_limit}", - "", - ) - - if show_devices: - devices_used = await get_current_devices_count(db_user) - else: - devices_used = 0 - - modem_enabled = getattr(subscription, 'modem_enabled', False) or False - if modem_enabled and settings.is_modem_enabled(): - visible_device_limit = (subscription.device_limit or 1) - 1 - devices_limit_display = f"{visible_device_limit} + модем" - else: - devices_limit_display = str(subscription.device_limit) - - settings_text = settings_template.format( - countries_count=len(subscription.connected_squads), - traffic_used=texts.format_traffic(subscription.traffic_used_gb), - traffic_limit=texts.format_traffic(subscription.traffic_limit_gb), - devices_used=devices_used, - devices_limit=devices_limit_display, - ) - - await callback.message.edit_text( - settings_text, - reply_markup=get_updated_subscription_settings_keyboard( - db_user.language, - show_countries, - tariff=tariff, - subscription=subscription - ), - parse_mode="HTML" - ) + # Возвращаемся в меню подписки - вызываем show_subscription_info + await db.refresh(db_user) + await show_subscription_info(callback, db_user, db) # ============== ХЕНДЛЕРЫ ПЛАТНОГО ТРИАЛА ============== From 2b1a20e3737f96bca990cd938163a87aa7836e0f Mon Sep 17 00:00:00 2001 From: Egor Date: Mon, 12 Jan 2026 15:59:22 +0300 Subject: [PATCH 14/61] Update purchase.py --- app/handlers/subscription/purchase.py | 60 ++++++++++++++++++++++++++- 1 file changed, 58 insertions(+), 2 deletions(-) diff --git a/app/handlers/subscription/purchase.py b/app/handlers/subscription/purchase.py index 085521cc..79fb4ce6 100644 --- a/app/handlers/subscription/purchase.py +++ b/app/handlers/subscription/purchase.py @@ -333,8 +333,9 @@ async def show_subscription_info( else texts.t("SUBSCRIPTION_NO_SERVERS", "Нет серверов") ) - # Получаем название тарифа для режима тарифов + # Получаем информацию о тарифе для режима тарифов tariff_line = "" + tariff_info_block = "" tariff = None if settings.is_tariffs_mode() and subscription.tariff_id: try: @@ -344,6 +345,60 @@ async def show_subscription_info( tariff_line = f"\n📦 Тариф: {tariff.name}" # Прикрепляем тариф к подписке для использования в клавиатуре subscription.tariff = tariff + + # Формируем блок информации о тарифе + is_daily = getattr(tariff, 'is_daily', False) + tariff_type_str = "🔄 Суточный" if is_daily else "📅 Периодный" + + tariff_info_lines = [ + f"📦 {tariff.name}", + f"Тип: {tariff_type_str}", + f"Трафик: {tariff.traffic_limit_gb} ГБ" if tariff.traffic_limit_gb > 0 else "Трафик: ∞ Безлимит", + f"Устройства: {tariff.device_limit}", + ] + + if is_daily: + # Для суточного тарифа показываем цену и прогресс-бар + daily_price = getattr(tariff, 'daily_price_kopeks', 0) / 100 + tariff_info_lines.append(f"Цена: {daily_price:.2f} ₽/день") + + # Прогресс-бар до следующего списания + last_charge = getattr(subscription, 'last_daily_charge_at', None) + is_paused = getattr(subscription, 'is_daily_paused', False) + + if is_paused: + tariff_info_lines.append("") + tariff_info_lines.append("⏸️ Подписка приостановлена") + elif last_charge: + from datetime import timedelta + next_charge = last_charge + timedelta(hours=24) + now = datetime.utcnow() + + if next_charge > now: + time_until = next_charge - now + hours_left = time_until.seconds // 3600 + minutes_left = (time_until.seconds % 3600) // 60 + + # Процент оставшегося времени (24 часа = 100%) + total_seconds = 24 * 3600 + remaining_seconds = time_until.total_seconds() + percent = min(100, max(0, (remaining_seconds / total_seconds) * 100)) + + # Генерируем прогресс-бар + bar_length = 10 + filled = int(bar_length * percent / 100) + empty = bar_length - filled + progress_bar = "▓" * filled + "░" * empty + + tariff_info_lines.append("") + tariff_info_lines.append(f"⏳ До списания: {hours_left}ч {minutes_left}мин") + tariff_info_lines.append(f"[{progress_bar}] {percent:.0f}%") + else: + tariff_info_lines.append("") + tariff_info_lines.append("⏳ Первое списание скоро") + + tariff_info_block = "\n
" + "\n".join(tariff_info_lines) + "
" + except Exception as e: logger.warning(f"Ошибка получения тарифа: {e}") @@ -351,7 +406,7 @@ async def show_subscription_info( "SUBSCRIPTION_OVERVIEW_TEMPLATE", """👤 {full_name} 💰 Баланс: {balance} -📱 Подписка: {status_emoji} {status_display}{warning} +📱 Подписка: {status_emoji} {status_display}{warning}{tariff_info_block} 📱 Информация о подписке 🎭 Тип: {subscription_type}{tariff_line} @@ -383,6 +438,7 @@ async def show_subscription_info( status_emoji=status_emoji, status_display=status_display, warning=warning_text, + tariff_info_block=tariff_info_block, subscription_type=subscription_type, tariff_line=tariff_line, end_date=format_local_datetime(subscription.end_date, "%d.%m.%Y %H:%M"), From 4967c7ff7dd246abfaf7c9f3671287969bd03d07 Mon Sep 17 00:00:00 2001 From: Egor Date: Mon, 12 Jan 2026 16:05:31 +0300 Subject: [PATCH 15/61] Update daily_subscription_service.py --- app/services/daily_subscription_service.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/services/daily_subscription_service.py b/app/services/daily_subscription_service.py index 92e075da..c1265595 100644 --- a/app/services/daily_subscription_service.py +++ b/app/services/daily_subscription_service.py @@ -149,7 +149,7 @@ class DailySubscriptionService: type=TransactionType.SUBSCRIPTION_PAYMENT, amount_kopeks=daily_price, description=description, - payment_method=PaymentMethod.BALANCE, + payment_method=PaymentMethod.MANUAL, ) # Обновляем время последнего списания From 1c23538b715df8a90b929d5285bf4304a9ae5a52 Mon Sep 17 00:00:00 2001 From: Egor Date: Mon, 12 Jan 2026 16:14:14 +0300 Subject: [PATCH 16/61] Update tariff_purchase.py --- app/handlers/subscription/tariff_purchase.py | 520 +++++++++++++++++-- 1 file changed, 483 insertions(+), 37 deletions(-) diff --git a/app/handlers/subscription/tariff_purchase.py b/app/handlers/subscription/tariff_purchase.py index 322228c4..bb7a9a41 100644 --- a/app/handlers/subscription/tariff_purchase.py +++ b/app/handlers/subscription/tariff_purchase.py @@ -1,5 +1,6 @@ """Покупка подписки по тарифам.""" import logging +from datetime import timedelta from typing import List, Optional from aiogram import Dispatcher, types, F @@ -98,19 +99,27 @@ def format_tariffs_list_text( traffic = "∞" if traffic_gb == 0 else f"{traffic_gb}ГБ" # Цена - prices = tariff.period_prices or {} + is_daily = getattr(tariff, 'is_daily', False) price_text = "" discount_icon = "" - if prices: - min_period = min(prices.keys(), key=int) - min_price = prices[min_period] - discount_percent = 0 - if db_user: - discount_percent = _get_user_period_discount(db_user, int(min_period)) - if discount_percent > 0: - min_price = _apply_promo_discount(min_price, discount_percent) - discount_icon = "🔥" - price_text = f"от {_format_price_kopeks(min_price, compact=True)}{discount_icon}" + + if is_daily: + # Для суточных тарифов показываем цену за день + daily_price = getattr(tariff, 'daily_price_kopeks', 0) + price_text = f"🔄 {_format_price_kopeks(daily_price, compact=True)}/день" + else: + # Для периодных тарифов показываем минимальную цену + prices = tariff.period_prices or {} + if prices: + min_period = min(prices.keys(), key=int) + min_price = prices[min_period] + discount_percent = 0 + if db_user: + discount_percent = _get_user_period_discount(db_user, int(min_period)) + if discount_percent > 0: + min_price = _apply_promo_discount(min_price, discount_percent) + discount_icon = "🔥" + price_text = f"от {_format_price_kopeks(min_price, compact=True)}{discount_icon}" # Компактный формат: Название — 250ГБ/10📱 от 179₽🔥 lines.append(f"{tariff.name} — {traffic}/{tariff.device_limit}📱 {price_text}") @@ -257,11 +266,58 @@ def format_tariff_info_for_user( if discount_percent > 0: text += f"\n🎁 Ваша скидка: {discount_percent}%\n" - text += "\nВыберите период подписки:" + # Для суточных тарифов не показываем выбор периода + is_daily = getattr(tariff, 'is_daily', False) + if not is_daily: + text += "\nВыберите период подписки:" return text +def get_daily_tariff_confirm_keyboard( + tariff_id: int, + language: str, +) -> InlineKeyboardMarkup: + """Создает клавиатуру подтверждения покупки суточного тарифа.""" + texts = get_texts(language) + return InlineKeyboardMarkup(inline_keyboard=[ + [ + InlineKeyboardButton( + text="✅ Подтвердить покупку", + callback_data=f"daily_tariff_confirm:{tariff_id}" + ) + ], + [ + InlineKeyboardButton( + text=texts.BACK, + callback_data="tariff_list" + ) + ] + ]) + + +def get_daily_tariff_insufficient_balance_keyboard( + tariff_id: int, + language: str, +) -> InlineKeyboardMarkup: + """Создает клавиатуру при недостаточном балансе для суточного тарифа.""" + texts = get_texts(language) + return InlineKeyboardMarkup(inline_keyboard=[ + [ + InlineKeyboardButton( + text="💳 Пополнить баланс", + callback_data="balance_topup" + ) + ], + [ + InlineKeyboardButton( + text=texts.BACK, + callback_data="tariff_list" + ) + ] + ]) + + @error_handler async def show_tariffs_list( callback: types.CallbackQuery, @@ -324,11 +380,48 @@ async def select_tariff( await callback.answer("Тариф недоступен", show_alert=True) return - await callback.message.edit_text( - format_tariff_info_for_user(tariff, db_user.language), - reply_markup=get_tariff_periods_keyboard(tariff, db_user.language, db_user=db_user), - parse_mode="HTML" - ) + # Проверяем, суточный ли это тариф + is_daily = getattr(tariff, 'is_daily', False) + + if is_daily: + # Для суточного тарифа показываем подтверждение без выбора периода + daily_price = getattr(tariff, 'daily_price_kopeks', 0) + user_balance = db_user.balance_kopeks or 0 + traffic = _format_traffic(tariff.traffic_limit_gb) + + if user_balance >= daily_price: + await callback.message.edit_text( + f"✅ Подтверждение покупки\n\n" + f"📦 Тариф: {tariff.name}\n" + f"📊 Трафик: {traffic}\n" + f"📱 Устройств: {tariff.device_limit}\n" + f"🔄 Тип: Суточный\n\n" + f"💰 Цена: {_format_price_kopeks(daily_price)}/день\n\n" + f"💳 Ваш баланс: {_format_price_kopeks(user_balance)}\n\n" + f"ℹ️ Средства будут списываться автоматически раз в сутки.\n" + f"Вы можете приостановить подписку в любой момент.", + reply_markup=get_daily_tariff_confirm_keyboard(tariff_id, db_user.language), + parse_mode="HTML" + ) + else: + missing = daily_price - user_balance + await callback.message.edit_text( + f"❌ Недостаточно средств\n\n" + f"📦 Тариф: {tariff.name}\n" + f"🔄 Тип: Суточный\n" + f"💰 Цена: {_format_price_kopeks(daily_price)}/день\n\n" + f"💳 Ваш баланс: {_format_price_kopeks(user_balance)}\n" + f"⚠️ Не хватает: {_format_price_kopeks(missing)}", + reply_markup=get_daily_tariff_insufficient_balance_keyboard(tariff_id, db_user.language), + parse_mode="HTML" + ) + else: + # Для обычного тарифа показываем выбор периода + await callback.message.edit_text( + format_tariff_info_for_user(tariff, db_user.language), + reply_markup=get_tariff_periods_keyboard(tariff, db_user.language, db_user=db_user), + parse_mode="HTML" + ) await state.update_data(selected_tariff_id=tariff_id) await callback.answer() @@ -546,6 +639,160 @@ async def confirm_tariff_purchase( await callback.answer("Произошла ошибка при оформлении подписки", show_alert=True) +# ==================== Покупка суточного тарифа ==================== + +@error_handler +async def confirm_daily_tariff_purchase( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession, + state: FSMContext, +): + """Подтверждает покупку суточного тарифа.""" + from datetime import datetime + + tariff_id = int(callback.data.split(":")[1]) + tariff = await get_tariff_by_id(db, tariff_id) + + if not tariff or not tariff.is_active: + await callback.answer("Тариф недоступен", show_alert=True) + return + + is_daily = getattr(tariff, 'is_daily', False) + if not is_daily: + await callback.answer("Это не суточный тариф", show_alert=True) + return + + daily_price = getattr(tariff, 'daily_price_kopeks', 0) + if daily_price <= 0: + await callback.answer("Некорректная цена тарифа", show_alert=True) + return + + # Проверяем баланс + user_balance = db_user.balance_kopeks or 0 + if user_balance < daily_price: + await callback.answer("Недостаточно средств на балансе", show_alert=True) + return + + texts = get_texts(db_user.language) + + try: + # Списываем первый день сразу + success = await subtract_user_balance( + db, db_user, daily_price, + f"Покупка суточного тарифа {tariff.name} (первый день)" + ) + if not success: + await callback.answer("Ошибка списания баланса", show_alert=True) + return + + # Получаем список серверов из тарифа + squads = tariff.allowed_squads or [] + + # Проверяем есть ли уже подписка + existing_subscription = await get_subscription_by_user_id(db, db_user.id) + + if existing_subscription: + # Обновляем существующую подписку на суточный тариф + existing_subscription.tariff_id = tariff.id + existing_subscription.traffic_limit_gb = tariff.traffic_limit_gb + existing_subscription.device_limit = tariff.device_limit + existing_subscription.connected_squads = squads + existing_subscription.status = "active" + existing_subscription.is_daily_paused = False + existing_subscription.last_daily_charge_at = datetime.utcnow() + # Для суточного тарифа ставим далёкую дату окончания + existing_subscription.end_date = datetime.utcnow() + timedelta(days=365 * 10) + + await db.commit() + await db.refresh(existing_subscription) + subscription = existing_subscription + else: + # Создаем новую подписку + subscription = await create_paid_subscription( + db=db, + user_id=db_user.id, + duration_days=365 * 10, # Суточные подписки не имеют фиксированного срока + traffic_limit_gb=tariff.traffic_limit_gb, + device_limit=tariff.device_limit, + connected_squads=squads, + tariff_id=tariff.id, + ) + # Устанавливаем время последнего списания + subscription.last_daily_charge_at = datetime.utcnow() + subscription.is_daily_paused = False + await db.commit() + await db.refresh(subscription) + + # Обновляем пользователя в Remnawave + try: + subscription_service = SubscriptionService() + await subscription_service.create_remnawave_user( + db, + subscription, + reset_traffic=settings.RESET_TRAFFIC_ON_PAYMENT, + reset_reason="покупка суточного тарифа", + ) + except Exception as e: + logger.error(f"Ошибка обновления Remnawave: {e}") + + # Создаем транзакцию + await create_transaction( + db, + user_id=db_user.id, + type=TransactionType.SUBSCRIPTION_PAYMENT, + amount_kopeks=-daily_price, + description=f"Покупка суточного тарифа {tariff.name} (первый день)", + ) + + # Отправляем уведомление админу + try: + admin_notification_service = AdminNotificationService(callback.bot) + await admin_notification_service.send_subscription_purchase_notification( + db, + db_user, + subscription, + None, + 1, # 1 день + was_trial_conversion=False, + amount_kopeks=daily_price, + ) + except Exception as e: + logger.error(f"Ошибка отправки уведомления админу: {e}") + + # Очищаем корзину после успешной покупки + try: + await user_cart_service.delete_user_cart(db_user.id) + logger.info(f"Корзина очищена после покупки суточного тарифа для пользователя {db_user.telegram_id}") + except Exception as e: + logger.error(f"Ошибка очистки корзины: {e}") + + await state.clear() + + traffic = _format_traffic(tariff.traffic_limit_gb) + + await callback.message.edit_text( + f"🎉 Суточная подписка оформлена!\n\n" + f"📦 Тариф: {tariff.name}\n" + f"📊 Трафик: {traffic}\n" + f"📱 Устройств: {tariff.device_limit}\n" + f"🔄 Тип: Суточный\n" + f"💰 Списано: {_format_price_kopeks(daily_price)}\n\n" + f"ℹ️ Следующее списание через 24 часа.\n" + f"Перейдите в раздел «Подписка» для подключения.", + reply_markup=InlineKeyboardMarkup(inline_keyboard=[ + [InlineKeyboardButton(text="📱 Моя подписка", callback_data="menu_subscription")], + [InlineKeyboardButton(text=texts.BACK, callback_data="back_to_menu")] + ]), + parse_mode="HTML" + ) + await callback.answer("Подписка оформлена!", show_alert=True) + + except Exception as e: + logger.error(f"Ошибка при покупке суточного тарифа: {e}", exc_info=True) + await callback.answer("Произошла ошибка при оформлении подписки", show_alert=True) + + # ==================== Продление по тарифу ==================== def get_tariff_extend_keyboard( @@ -880,19 +1127,27 @@ def format_tariff_switch_list_text( traffic_gb = tariff.traffic_limit_gb traffic = "∞" if traffic_gb == 0 else f"{traffic_gb}ГБ" - prices = tariff.period_prices or {} + # Проверяем суточный ли тариф + is_daily = getattr(tariff, 'is_daily', False) price_text = "" discount_icon = "" - if prices: - min_period = min(prices.keys(), key=int) - min_price = prices[min_period] - discount_percent = 0 - if db_user: - discount_percent = _get_user_period_discount(db_user, int(min_period)) - if discount_percent > 0: - min_price = _apply_promo_discount(min_price, discount_percent) - discount_icon = "🔥" - price_text = f"от {_format_price_kopeks(min_price, compact=True)}{discount_icon}" + + if is_daily: + # Для суточных тарифов показываем цену за день + daily_price = getattr(tariff, 'daily_price_kopeks', 0) + price_text = f"🔄 {_format_price_kopeks(daily_price, compact=True)}/день" + else: + prices = tariff.period_prices or {} + if prices: + min_period = min(prices.keys(), key=int) + min_price = prices[min_period] + discount_percent = 0 + if db_user: + discount_percent = _get_user_period_discount(db_user, int(min_period)) + if discount_percent > 0: + min_price = _apply_promo_discount(min_price, discount_percent) + discount_icon = "🔥" + price_text = f"от {_format_price_kopeks(min_price, compact=True)}{discount_icon}" lines.append(f"{tariff.name} — {traffic}/{tariff.device_limit}📱 {price_text}") @@ -1105,23 +1360,77 @@ async def select_tariff_switch( traffic = _format_traffic(tariff.traffic_limit_gb) - info_text = f"""📦 {tariff.name} + # Проверяем, суточный ли это тариф + is_daily = getattr(tariff, 'is_daily', False) + + if is_daily: + # Для суточного тарифа показываем подтверждение без выбора периода + daily_price = getattr(tariff, 'daily_price_kopeks', 0) + user_balance = db_user.balance_kopeks or 0 + + if user_balance >= daily_price: + await callback.message.edit_text( + f"✅ Подтверждение смены тарифа\n\n" + f"📦 Новый тариф: {tariff.name}\n" + f"📊 Трафик: {traffic}\n" + f"📱 Устройств: {tariff.device_limit}\n" + f"🔄 Тип: Суточный\n\n" + f"💰 Цена: {_format_price_kopeks(daily_price)}/день\n\n" + f"💳 Ваш баланс: {_format_price_kopeks(user_balance)}\n\n" + f"ℹ️ Средства будут списываться автоматически раз в сутки.\n" + f"Вы можете приостановить подписку в любой момент.", + reply_markup=InlineKeyboardMarkup(inline_keyboard=[ + [InlineKeyboardButton( + text="✅ Подтвердить смену", + callback_data=f"daily_tariff_switch_confirm:{tariff_id}" + )], + [InlineKeyboardButton( + text=get_texts(db_user.language).BACK, + callback_data="tariff_switch" + )] + ]), + parse_mode="HTML" + ) + else: + missing = daily_price - user_balance + await callback.message.edit_text( + f"❌ Недостаточно средств\n\n" + f"📦 Тариф: {tariff.name}\n" + f"🔄 Тип: Суточный\n" + f"💰 Цена: {_format_price_kopeks(daily_price)}/день\n\n" + f"💳 Ваш баланс: {_format_price_kopeks(user_balance)}\n" + f"⚠️ Не хватает: {_format_price_kopeks(missing)}", + reply_markup=InlineKeyboardMarkup(inline_keyboard=[ + [InlineKeyboardButton( + text="💳 Пополнить баланс", + callback_data="balance_topup" + )], + [InlineKeyboardButton( + text=get_texts(db_user.language).BACK, + callback_data="tariff_switch" + )] + ]), + parse_mode="HTML" + ) + else: + # Для обычного тарифа показываем выбор периода + info_text = f"""📦 {tariff.name} Параметры нового тарифа: • Трафик: {traffic} • Устройств: {tariff.device_limit} """ - if tariff.description: - info_text += f"\n📝 {tariff.description}\n" + if tariff.description: + info_text += f"\n📝 {tariff.description}\n" - info_text += "\n⚠️ Оплачивается полная стоимость тарифа.\nВыберите период:" + info_text += "\n⚠️ Оплачивается полная стоимость тарифа.\nВыберите период:" - await callback.message.edit_text( - info_text, - reply_markup=get_tariff_switch_periods_keyboard(tariff, db_user.language, db_user=db_user), - parse_mode="HTML" - ) + await callback.message.edit_text( + info_text, + reply_markup=get_tariff_switch_periods_keyboard(tariff, db_user.language, db_user=db_user), + parse_mode="HTML" + ) await state.update_data(switch_tariff_id=tariff_id) await callback.answer() @@ -1356,6 +1665,137 @@ async def confirm_tariff_switch( await callback.answer("Произошла ошибка при переключении тарифа", show_alert=True) +# ==================== Смена на суточный тариф ==================== + +@error_handler +async def confirm_daily_tariff_switch( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession, + state: FSMContext, +): + """Подтверждает смену на суточный тариф.""" + from datetime import datetime + + tariff_id = int(callback.data.split(":")[1]) + tariff = await get_tariff_by_id(db, tariff_id) + + if not tariff or not tariff.is_active: + await callback.answer("Тариф недоступен", show_alert=True) + return + + is_daily = getattr(tariff, 'is_daily', False) + if not is_daily: + await callback.answer("Это не суточный тариф", show_alert=True) + return + + daily_price = getattr(tariff, 'daily_price_kopeks', 0) + if daily_price <= 0: + await callback.answer("Некорректная цена тарифа", show_alert=True) + return + + # Проверяем баланс + user_balance = db_user.balance_kopeks or 0 + if user_balance < daily_price: + await callback.answer("Недостаточно средств на балансе", show_alert=True) + return + + # Проверяем наличие подписки + subscription = await get_subscription_by_user_id(db, db_user.id) + if not subscription: + await callback.answer("У вас нет активной подписки", show_alert=True) + return + + texts = get_texts(db_user.language) + + try: + # Списываем первый день сразу + success = await subtract_user_balance( + db, db_user, daily_price, + f"Смена на суточный тариф {tariff.name} (первый день)" + ) + if not success: + await callback.answer("Ошибка списания баланса", show_alert=True) + return + + # Получаем список серверов из тарифа + squads = tariff.allowed_squads or [] + + # Обновляем подписку на суточный тариф + subscription.tariff_id = tariff.id + subscription.traffic_limit_gb = tariff.traffic_limit_gb + subscription.device_limit = tariff.device_limit + subscription.connected_squads = squads + subscription.status = "active" + subscription.is_daily_paused = False + subscription.last_daily_charge_at = datetime.utcnow() + # Для суточного тарифа ставим далёкую дату окончания + subscription.end_date = datetime.utcnow() + timedelta(days=365 * 10) + + await db.commit() + await db.refresh(subscription) + + # Обновляем пользователя в Remnawave + try: + subscription_service = SubscriptionService() + await subscription_service.create_remnawave_user( + db, + subscription, + reset_traffic=True, + reset_reason="смена на суточный тариф", + ) + except Exception as e: + logger.error(f"Ошибка обновления Remnawave: {e}") + + # Создаем транзакцию + await create_transaction( + db, + user_id=db_user.id, + type=TransactionType.SUBSCRIPTION_PAYMENT, + amount_kopeks=-daily_price, + description=f"Смена на суточный тариф {tariff.name} (первый день)", + ) + + # Отправляем уведомление админу + try: + admin_notification_service = AdminNotificationService(callback.bot) + await admin_notification_service.send_subscription_purchase_notification( + db, + db_user, + subscription, + None, + 1, # 1 день + was_trial_conversion=False, + amount_kopeks=daily_price, + ) + except Exception as e: + logger.error(f"Ошибка отправки уведомления админу: {e}") + + await state.clear() + + traffic = _format_traffic(tariff.traffic_limit_gb) + + await callback.message.edit_text( + f"🎉 Тариф успешно изменён!\n\n" + f"📦 Новый тариф: {tariff.name}\n" + f"📊 Трафик: {traffic}\n" + f"📱 Устройств: {tariff.device_limit}\n" + f"🔄 Тип: Суточный\n" + f"💰 Списано: {_format_price_kopeks(daily_price)}\n\n" + f"ℹ️ Следующее списание через 24 часа.", + reply_markup=InlineKeyboardMarkup(inline_keyboard=[ + [InlineKeyboardButton(text="📱 Моя подписка", callback_data="menu_subscription")], + [InlineKeyboardButton(text=texts.BACK, callback_data="back_to_menu")] + ]), + parse_mode="HTML" + ) + await callback.answer("Тариф изменён!", show_alert=True) + + except Exception as e: + logger.error(f"Ошибка при смене на суточный тариф: {e}", exc_info=True) + await callback.answer("Произошла ошибка при смене тарифа", show_alert=True) + + # ==================== Мгновенное переключение тарифов (без выбора периода) ==================== def _get_tariff_monthly_price(tariff: Tariff) -> int: @@ -1874,6 +2314,9 @@ def register_tariff_purchase_handlers(dp: Dispatcher): # Подтверждение покупки dp.callback_query.register(confirm_tariff_purchase, F.data.startswith("tariff_confirm:")) + # Подтверждение покупки суточного тарифа + dp.callback_query.register(confirm_daily_tariff_purchase, F.data.startswith("daily_tariff_confirm:")) + # Продление по тарифу dp.callback_query.register(select_tariff_extend_period, F.data.startswith("tariff_extend:")) dp.callback_query.register(confirm_tariff_extend, F.data.startswith("tariff_ext_confirm:")) @@ -1884,6 +2327,9 @@ def register_tariff_purchase_handlers(dp: Dispatcher): dp.callback_query.register(select_tariff_switch_period, F.data.startswith("tariff_sw_period:")) dp.callback_query.register(confirm_tariff_switch, F.data.startswith("tariff_sw_confirm:")) + # Смена на суточный тариф + dp.callback_query.register(confirm_daily_tariff_switch, F.data.startswith("daily_tariff_switch_confirm:")) + # Мгновенное переключение тарифов (без выбора периода) dp.callback_query.register(show_instant_switch_list, F.data == "instant_switch") dp.callback_query.register(preview_instant_switch, F.data.startswith("instant_sw_preview:")) From 78c44a1e00cb1db46308db1c2e2d0bf5aaf38562 Mon Sep 17 00:00:00 2001 From: Egor Date: Mon, 12 Jan 2026 16:19:17 +0300 Subject: [PATCH 17/61] Update tariff_purchase.py --- app/handlers/subscription/tariff_purchase.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/handlers/subscription/tariff_purchase.py b/app/handlers/subscription/tariff_purchase.py index bb7a9a41..8366d5f8 100644 --- a/app/handlers/subscription/tariff_purchase.py +++ b/app/handlers/subscription/tariff_purchase.py @@ -699,6 +699,7 @@ async def confirm_daily_tariff_purchase( existing_subscription.device_limit = tariff.device_limit existing_subscription.connected_squads = squads existing_subscription.status = "active" + existing_subscription.is_trial = False # Сбрасываем триальный статус existing_subscription.is_daily_paused = False existing_subscription.last_daily_charge_at = datetime.utcnow() # Для суточного тарифа ставим далёкую дату окончания @@ -1727,6 +1728,7 @@ async def confirm_daily_tariff_switch( subscription.device_limit = tariff.device_limit subscription.connected_squads = squads subscription.status = "active" + subscription.is_trial = False # Сбрасываем триальный статус subscription.is_daily_paused = False subscription.last_daily_charge_at = datetime.utcnow() # Для суточного тарифа ставим далёкую дату окончания From 8c5a385f147e18c3044f69b7b1d3bac4f1084ddf Mon Sep 17 00:00:00 2001 From: Egor Date: Mon, 12 Jan 2026 16:29:31 +0300 Subject: [PATCH 18/61] Update tariff_purchase.py --- app/handlers/subscription/tariff_purchase.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/app/handlers/subscription/tariff_purchase.py b/app/handlers/subscription/tariff_purchase.py index 8366d5f8..402c7ae8 100644 --- a/app/handlers/subscription/tariff_purchase.py +++ b/app/handlers/subscription/tariff_purchase.py @@ -702,18 +702,18 @@ async def confirm_daily_tariff_purchase( existing_subscription.is_trial = False # Сбрасываем триальный статус existing_subscription.is_daily_paused = False existing_subscription.last_daily_charge_at = datetime.utcnow() - # Для суточного тарифа ставим далёкую дату окончания - existing_subscription.end_date = datetime.utcnow() + timedelta(days=365 * 10) + # Для суточного тарифа ставим срок на 1 день + existing_subscription.end_date = datetime.utcnow() + timedelta(days=1) await db.commit() await db.refresh(existing_subscription) subscription = existing_subscription else: - # Создаем новую подписку + # Создаем новую подписку на 1 день subscription = await create_paid_subscription( db=db, user_id=db_user.id, - duration_days=365 * 10, # Суточные подписки не имеют фиксированного срока + duration_days=1, traffic_limit_gb=tariff.traffic_limit_gb, device_limit=tariff.device_limit, connected_squads=squads, @@ -1731,8 +1731,8 @@ async def confirm_daily_tariff_switch( subscription.is_trial = False # Сбрасываем триальный статус subscription.is_daily_paused = False subscription.last_daily_charge_at = datetime.utcnow() - # Для суточного тарифа ставим далёкую дату окончания - subscription.end_date = datetime.utcnow() + timedelta(days=365 * 10) + # Для суточного тарифа ставим срок на 1 день + subscription.end_date = datetime.utcnow() + timedelta(days=1) await db.commit() await db.refresh(subscription) From 62bec70db9b7cf556badbd16a3033a2c693fa5e6 Mon Sep 17 00:00:00 2001 From: Egor Date: Mon, 12 Jan 2026 16:29:59 +0300 Subject: [PATCH 19/61] Update daily_subscription_service.py --- app/services/daily_subscription_service.py | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/app/services/daily_subscription_service.py b/app/services/daily_subscription_service.py index c1265595..c2a290b7 100644 --- a/app/services/daily_subscription_service.py +++ b/app/services/daily_subscription_service.py @@ -152,14 +152,27 @@ class DailySubscriptionService: payment_method=PaymentMethod.MANUAL, ) - # Обновляем время последнего списания - await update_daily_charge_time(db, subscription) + # Обновляем время последнего списания и продлеваем подписку + subscription = await update_daily_charge_time(db, subscription) logger.info( f"✅ Суточное списание: подписка {subscription.id}, " f"сумма {daily_price} коп., пользователь {user.telegram_id}" ) + # Синхронизируем с Remnawave (обновляем срок подписки) + try: + from app.services.subscription_service import SubscriptionService + subscription_service = SubscriptionService() + await subscription_service.create_remnawave_user( + db, + subscription, + reset_traffic=False, + reset_reason=None, + ) + except Exception as e: + logger.warning(f"Не удалось обновить Remnawave: {e}") + # Уведомляем пользователя if self._bot: await self._notify_daily_charge(user, subscription, daily_price) From 2090da3603a77ee39c1e0c74714f480c520122a6 Mon Sep 17 00:00:00 2001 From: Egor Date: Mon, 12 Jan 2026 16:30:24 +0300 Subject: [PATCH 20/61] Update inline.py --- app/keyboards/inline.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/app/keyboards/inline.py b/app/keyboards/inline.py index 7559d9de..1934f4fc 100644 --- a/app/keyboards/inline.py +++ b/app/keyboards/inline.py @@ -1004,18 +1004,20 @@ def get_subscription_keyboard( ) ]) - # Ряд: [Настройки] [Тариф] (если режим тарифов и не суточный) + # Ряд: [Настройки] [Тариф] (если режим тарифов) settings_row = [ InlineKeyboardButton( text=texts.t("SUBSCRIPTION_SETTINGS_BUTTON", "⚙️ Настройки"), callback_data="subscription_settings", ) ] - if settings.is_tariffs_mode() and subscription and not is_daily_tariff: + if settings.is_tariffs_mode() and subscription: + # Для суточных тарифов переходим на список тарифов, для обычных - мгновенное переключение + tariff_callback = "tariff_switch" if is_daily_tariff else "instant_switch" settings_row.append( InlineKeyboardButton( text=texts.t("CHANGE_TARIFF_BUTTON", "📦 Тариф"), - callback_data="instant_switch" + callback_data=tariff_callback ) ) keyboard.append(settings_row) From 640a80953f851b23503629dd5121fa570212fd2d Mon Sep 17 00:00:00 2001 From: Egor Date: Mon, 12 Jan 2026 16:31:00 +0300 Subject: [PATCH 21/61] Update subscription.py --- app/database/crud/subscription.py | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/app/database/crud/subscription.py b/app/database/crud/subscription.py index 8786ffff..793a998d 100644 --- a/app/database/crud/subscription.py +++ b/app/database/crud/subscription.py @@ -1861,8 +1861,16 @@ async def update_daily_charge_time( subscription: Subscription, charge_time: datetime = None, ) -> Subscription: - """Обновляет время последнего суточного списания.""" - subscription.last_daily_charge_at = charge_time or datetime.utcnow() + """Обновляет время последнего суточного списания и продлевает подписку на 1 день.""" + now = charge_time or datetime.utcnow() + subscription.last_daily_charge_at = now + + # Продлеваем подписку на 1 день от текущего момента + new_end_date = now + timedelta(days=1) + if subscription.end_date is None or subscription.end_date < new_end_date: + subscription.end_date = new_end_date + logger.info(f"📅 Продлена подписка {subscription.id} до {new_end_date}") + await db.commit() await db.refresh(subscription) From 7aac2fadf742c140c698a5787a9809f5e7971a64 Mon Sep 17 00:00:00 2001 From: Egor Date: Mon, 12 Jan 2026 16:33:47 +0300 Subject: [PATCH 22/61] Update tariff_purchase.py --- app/handlers/subscription/tariff_purchase.py | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/app/handlers/subscription/tariff_purchase.py b/app/handlers/subscription/tariff_purchase.py index 402c7ae8..a1bcd449 100644 --- a/app/handlers/subscription/tariff_purchase.py +++ b/app/handlers/subscription/tariff_purchase.py @@ -1369,6 +1369,16 @@ async def select_tariff_switch( daily_price = getattr(tariff, 'daily_price_kopeks', 0) user_balance = db_user.balance_kopeks or 0 + # Проверяем текущую подписку на оставшиеся дни + current_subscription = await get_subscription_by_user_id(db, db_user.id) + days_warning = "" + if current_subscription and current_subscription.end_date: + from datetime import datetime + remaining = current_subscription.end_date - datetime.utcnow() + remaining_days = max(0, remaining.days) + if remaining_days > 1: + days_warning = f"\n\n⚠️ Внимание! У вас осталось {remaining_days} дн. подписки.\nПри смене на суточный тариф они будут утеряны!" + if user_balance >= daily_price: await callback.message.edit_text( f"✅ Подтверждение смены тарифа\n\n" @@ -1377,7 +1387,8 @@ async def select_tariff_switch( f"📱 Устройств: {tariff.device_limit}\n" f"🔄 Тип: Суточный\n\n" f"💰 Цена: {_format_price_kopeks(daily_price)}/день\n\n" - f"💳 Ваш баланс: {_format_price_kopeks(user_balance)}\n\n" + f"💳 Ваш баланс: {_format_price_kopeks(user_balance)}" + f"{days_warning}\n\n" f"ℹ️ Средства будут списываться автоматически раз в сутки.\n" f"Вы можете приостановить подписку в любой момент.", reply_markup=InlineKeyboardMarkup(inline_keyboard=[ @@ -1400,7 +1411,8 @@ async def select_tariff_switch( f"🔄 Тип: Суточный\n" f"💰 Цена: {_format_price_kopeks(daily_price)}/день\n\n" f"💳 Ваш баланс: {_format_price_kopeks(user_balance)}\n" - f"⚠️ Не хватает: {_format_price_kopeks(missing)}", + f"⚠️ Не хватает: {_format_price_kopeks(missing)}" + f"{days_warning}", reply_markup=InlineKeyboardMarkup(inline_keyboard=[ [InlineKeyboardButton( text="💳 Пополнить баланс", From 92a6231b6f66e0d808b7f58899ceebdf7737f86f Mon Sep 17 00:00:00 2001 From: Egor Date: Mon, 12 Jan 2026 16:40:42 +0300 Subject: [PATCH 23/61] Update tariff_purchase.py --- app/handlers/subscription/tariff_purchase.py | 83 +++++++++++++++++++- 1 file changed, 82 insertions(+), 1 deletion(-) diff --git a/app/handlers/subscription/tariff_purchase.py b/app/handlers/subscription/tariff_purchase.py index a1bcd449..9d469177 100644 --- a/app/handlers/subscription/tariff_purchase.py +++ b/app/handlers/subscription/tariff_purchase.py @@ -2131,6 +2131,61 @@ async def preview_instant_switch( texts = get_texts(db_user.language) + # Проверяем, суточный ли новый тариф + is_new_daily = getattr(new_tariff, 'is_daily', False) + daily_warning = "" + if is_new_daily and remaining_days > 1: + daily_warning = texts.t( + "DAILY_SWITCH_WARNING", + f"\n\n⚠️ Внимание! У вас осталось {remaining_days} дн. подписки.\nПри смене на суточный тариф они будут утеряны!" + ).format(days=remaining_days) + + # Для суточного тарифа особая логика показа + if is_new_daily: + daily_price = getattr(new_tariff, 'daily_price_kopeks', 0) + user_balance = db_user.balance_kopeks or 0 + + if user_balance >= daily_price: + await callback.message.edit_text( + f"🔄 Переключение на суточный тариф\n\n" + f"📌 Текущий: {current_tariff.name}\n" + f" • Трафик: {current_traffic}\n" + f" • Устройств: {current_tariff.device_limit}\n\n" + f"📦 Новый: {new_tariff.name}\n" + f" • Трафик: {traffic}\n" + f" • Устройств: {new_tariff.device_limit}\n" + f" • Тип: 🔄 Суточный\n\n" + f"💰 Цена: {_format_price_kopeks(daily_price)}/день\n\n" + f"💳 Ваш баланс: {_format_price_kopeks(user_balance)}" + f"{daily_warning}\n\n" + f"ℹ️ Средства будут списываться автоматически раз в сутки.", + reply_markup=get_instant_switch_confirm_keyboard(tariff_id, db_user.language), + parse_mode="HTML" + ) + else: + missing = daily_price - user_balance + await callback.message.edit_text( + f"❌ Недостаточно средств\n\n" + f"📦 Тариф: {new_tariff.name}\n" + f"🔄 Тип: Суточный\n" + f"💰 Цена: {_format_price_kopeks(daily_price)}/день\n\n" + f"💳 Ваш баланс: {_format_price_kopeks(user_balance)}\n" + f"⚠️ Не хватает: {_format_price_kopeks(missing)}" + f"{daily_warning}", + reply_markup=get_instant_switch_insufficient_balance_keyboard(tariff_id, db_user.language), + parse_mode="HTML" + ) + + await state.update_data( + switch_tariff_id=tariff_id, + upgrade_cost=0, + is_upgrade=False, + current_tariff_id=current_tariff_id, + remaining_days=remaining_days, + ) + await callback.answer() + return + if is_upgrade: # Upgrade - нужна доплата if user_balance >= upgrade_cost: @@ -2237,13 +2292,39 @@ async def confirm_instant_switch( # Получаем список серверов из нового тарифа squads = new_tariff.allowed_squads or [] + # Проверяем, суточный ли новый тариф + is_new_daily = getattr(new_tariff, 'is_daily', False) + # Обновляем подписку с новыми параметрами тарифа - # НЕ меняем end_date - только параметры тарифа subscription.tariff_id = new_tariff.id subscription.traffic_limit_gb = new_tariff.traffic_limit_gb subscription.device_limit = new_tariff.device_limit subscription.connected_squads = squads + if is_new_daily: + # Для суточного тарифа - сбрасываем на 1 день и настраиваем суточные параметры + daily_price = getattr(new_tariff, 'daily_price_kopeks', 0) + + # Списываем первый день если ещё не списано (upgrade_cost был 0) + if upgrade_cost == 0 and daily_price > 0: + if user_balance >= daily_price: + await subtract_user_balance( + db, db_user, daily_price, + f"Переключение на суточный тариф {new_tariff.name} (первый день)" + ) + await create_transaction( + db, + user_id=db_user.id, + type=TransactionType.SUBSCRIPTION_PAYMENT, + amount_kopeks=-daily_price, + description=f"Переключение на суточный тариф {new_tariff.name} (первый день)", + ) + + subscription.end_date = datetime.utcnow() + timedelta(days=1) + subscription.is_trial = False + subscription.is_daily_paused = False + subscription.last_daily_charge_at = datetime.utcnow() + await db.commit() await db.refresh(subscription) From 891b3887992b21f314f13b34dc47e625901f83dc Mon Sep 17 00:00:00 2001 From: Egor Date: Mon, 12 Jan 2026 16:41:36 +0300 Subject: [PATCH 24/61] Add files via upload --- app/localization/locales/en.json | 6 +++++- app/localization/locales/ru.json | 6 +++++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/app/localization/locales/en.json b/app/localization/locales/en.json index bd0721ec..045969cb 100644 --- a/app/localization/locales/en.json +++ b/app/localization/locales/en.json @@ -1660,5 +1660,9 @@ "ADMIN_USER_RESTRICTIONS": "⚠️ Restrict", "USER_RESTRICTION_TOPUP_BLOCKED": "🚫 Top-up restricted\n\n{reason}\n\nIf you believe this is an error, you can appeal the decision.", "USER_RESTRICTION_SUBSCRIPTION_BLOCKED": "🚫 Subscription purchase/renewal restricted\n\n{reason}\n\nIf you believe this is an error, you can appeal the decision.", - "USER_RESTRICTION_APPEAL_BUTTON": "🆘 Appeal" + "USER_RESTRICTION_APPEAL_BUTTON": "🆘 Appeal", + + "PAUSE_DAILY_BUTTON": "⏸️ Pause subscription", + "RESUME_DAILY_BUTTON": "▶️ Resume subscription", + "DAILY_SWITCH_WARNING": "⚠️ Warning! You have {days} days left.\nThey will be lost when switching to daily tariff!" } diff --git a/app/localization/locales/ru.json b/app/localization/locales/ru.json index ea9167fa..4214b4a3 100644 --- a/app/localization/locales/ru.json +++ b/app/localization/locales/ru.json @@ -1677,5 +1677,9 @@ "ADMIN_USER_RESTRICTIONS": "⚠️ Ограничить", "USER_RESTRICTION_TOPUP_BLOCKED": "🚫 Пополнение ограничено\n\n{reason}\n\nЕсли вы считаете это ошибкой, вы можете обжаловать решение.", "USER_RESTRICTION_SUBSCRIPTION_BLOCKED": "🚫 Покупка/продление подписки ограничено\n\n{reason}\n\nЕсли вы считаете это ошибкой, вы можете обжаловать решение.", - "USER_RESTRICTION_APPEAL_BUTTON": "🆘 Обжаловать" + "USER_RESTRICTION_APPEAL_BUTTON": "🆘 Обжаловать", + + "PAUSE_DAILY_BUTTON": "⏸️ Приостановить подписку", + "RESUME_DAILY_BUTTON": "▶️ Возобновить подписку", + "DAILY_SWITCH_WARNING": "⚠️ Внимание! У вас осталось {days} дн. подписки.\nПри смене на суточный тариф они будут утеряны!" } From 030acf07a42ad6388ccc92099493b1c67ccd3e7f Mon Sep 17 00:00:00 2001 From: Egor Date: Mon, 12 Jan 2026 16:47:52 +0300 Subject: [PATCH 25/61] Update tariff_purchase.py --- app/handlers/subscription/tariff_purchase.py | 50 +++++++++++++------- 1 file changed, 34 insertions(+), 16 deletions(-) diff --git a/app/handlers/subscription/tariff_purchase.py b/app/handlers/subscription/tariff_purchase.py index 9d469177..e54335d9 100644 --- a/app/handlers/subscription/tariff_purchase.py +++ b/app/handlers/subscription/tariff_purchase.py @@ -2369,24 +2369,42 @@ async def confirm_instant_switch( traffic = _format_traffic(new_tariff.traffic_limit_gb) - if is_upgrade: - cost_text = f"💰 Списано: {_format_price_kopeks(upgrade_cost)}" + # Для суточного тарифа другое сообщение об успехе + if is_new_daily: + daily_price = getattr(new_tariff, 'daily_price_kopeks', 0) + await callback.message.edit_text( + f"🎉 Тариф успешно изменён!\n\n" + f"📦 Новый тариф: {new_tariff.name}\n" + f"📊 Трафик: {traffic}\n" + f"📱 Устройств: {new_tariff.device_limit}\n" + f"🔄 Тип: Суточный\n" + f"💰 Списано: {_format_price_kopeks(daily_price)}\n\n" + f"ℹ️ Следующее списание через 24 часа.", + reply_markup=InlineKeyboardMarkup(inline_keyboard=[ + [InlineKeyboardButton(text="📱 Моя подписка", callback_data="menu_subscription")], + [InlineKeyboardButton(text=texts.BACK, callback_data="back_to_menu")] + ]), + parse_mode="HTML" + ) else: - cost_text = "💰 Бесплатно" + if is_upgrade: + cost_text = f"💰 Списано: {_format_price_kopeks(upgrade_cost)}" + else: + cost_text = "💰 Бесплатно" - await callback.message.edit_text( - f"🎉 Тариф успешно изменён!\n\n" - f"📦 Новый тариф: {new_tariff.name}\n" - f"📊 Трафик: {traffic}\n" - f"📱 Устройств: {new_tariff.device_limit}\n" - f"⏰ Осталось дней: {remaining_days}\n" - f"{cost_text}", - reply_markup=InlineKeyboardMarkup(inline_keyboard=[ - [InlineKeyboardButton(text="📱 Моя подписка", callback_data="menu_subscription")], - [InlineKeyboardButton(text=texts.BACK, callback_data="back_to_menu")] - ]), - parse_mode="HTML" - ) + await callback.message.edit_text( + f"🎉 Тариф успешно изменён!\n\n" + f"📦 Новый тариф: {new_tariff.name}\n" + f"📊 Трафик: {traffic}\n" + f"📱 Устройств: {new_tariff.device_limit}\n" + f"⏰ Осталось дней: {remaining_days}\n" + f"{cost_text}", + reply_markup=InlineKeyboardMarkup(inline_keyboard=[ + [InlineKeyboardButton(text="📱 Моя подписка", callback_data="menu_subscription")], + [InlineKeyboardButton(text=texts.BACK, callback_data="back_to_menu")] + ]), + parse_mode="HTML" + ) await callback.answer("Тариф изменён!", show_alert=True) except Exception as e: From 71ff89ca55c257402684dbe7e03fee57b97f7ee3 Mon Sep 17 00:00:00 2001 From: Egor Date: Mon, 12 Jan 2026 16:56:00 +0300 Subject: [PATCH 26/61] Update monitoring_service.py --- app/services/monitoring_service.py | 43 +++++++++++++++++++++++------- 1 file changed, 34 insertions(+), 9 deletions(-) diff --git a/app/services/monitoring_service.py b/app/services/monitoring_service.py index 44b6b5b9..adac5867 100644 --- a/app/services/monitoring_service.py +++ b/app/services/monitoring_service.py @@ -46,6 +46,7 @@ from app.database.models import ( MonitoringLog, SubscriptionStatus, Subscription, + Tariff, User, Ticket, TicketStatus, @@ -694,7 +695,10 @@ class MonitoringService: result = await db.execute( select(Subscription) - .options(selectinload(Subscription.user)) + .options( + selectinload(Subscription.user), + selectinload(Subscription.tariff), + ) .where( and_( Subscription.is_trial == False, @@ -703,7 +707,14 @@ class MonitoringService: ) ) - subscriptions = result.scalars().all() + all_subscriptions = result.scalars().all() + + # Исключаем суточные тарифы - для них отдельная логика + subscriptions = [ + sub for sub in all_subscriptions + if not (sub.tariff and getattr(sub.tariff, 'is_daily', False)) + ] + sent_day1 = 0 sent_wave2 = 0 sent_wave3 = 0 @@ -811,27 +822,41 @@ class MonitoringService: async def _get_expiring_paid_subscriptions(self, db: AsyncSession, days_before: int) -> List[Subscription]: current_time = datetime.utcnow() threshold_date = current_time + timedelta(days=days_before) - + result = await db.execute( select(Subscription) - .options(selectinload(Subscription.user)) + .options( + selectinload(Subscription.user), + selectinload(Subscription.tariff), + ) .where( and_( Subscription.status == SubscriptionStatus.ACTIVE.value, - Subscription.is_trial == False, + Subscription.is_trial == False, Subscription.end_date > current_time, Subscription.end_date <= threshold_date ) ) ) - + logger.debug(f"🔍 Поиск платных подписок, истекающих в ближайшие {days_before} дней") logger.debug(f"📅 Текущее время: {current_time}") logger.debug(f"📅 Пороговая дата: {threshold_date}") - - subscriptions = result.scalars().all() + + all_subscriptions = result.scalars().all() + + # Исключаем суточные тарифы - для них отдельная логика списания + subscriptions = [ + sub for sub in all_subscriptions + if not (sub.tariff and getattr(sub.tariff, 'is_daily', False)) + ] + + excluded_count = len(all_subscriptions) - len(subscriptions) + if excluded_count > 0: + logger.debug(f"🔄 Исключено {excluded_count} суточных подписок из уведомлений") + logger.info(f"📊 Найдено {len(subscriptions)} платных подписок для уведомлений") - + return subscriptions @staticmethod From 0759657185f12828e0ed06f9e2e165d38648d749 Mon Sep 17 00:00:00 2001 From: Egor Date: Mon, 12 Jan 2026 17:00:05 +0300 Subject: [PATCH 27/61] Update purchase.py --- app/handlers/subscription/purchase.py | 40 +++++++++++++++++++++++---- 1 file changed, 34 insertions(+), 6 deletions(-) diff --git a/app/handlers/subscription/purchase.py b/app/handlers/subscription/purchase.py index 79fb4ce6..e4d707ba 100644 --- a/app/handlers/subscription/purchase.py +++ b/app/handlers/subscription/purchase.py @@ -2,6 +2,8 @@ import base64 import json import logging from datetime import datetime, timedelta + +logger = logging.getLogger(__name__) from typing import Dict, List, Any, Tuple, Optional from urllib.parse import quote from aiogram import Dispatcher, types, F @@ -134,7 +136,7 @@ from app.handlers.simple_subscription import ( _get_simple_subscription_payment_keyboard, ) -from .common import _apply_promo_offer_discount, _get_promo_offer_discount_percent, logger, update_traffic_prices +from .common import _apply_promo_offer_discount, _get_promo_offer_discount_percent, update_traffic_prices from .autopay import ( handle_autopay_menu, handle_subscription_cancel, @@ -337,10 +339,14 @@ async def show_subscription_info( tariff_line = "" tariff_info_block = "" tariff = None + print(f"[DEBUG TARIFF] Режим тарифов: {settings.is_tariffs_mode()}, tariff_id: {subscription.tariff_id}") + logger.info(f"🔍 Режим тарифов: {settings.is_tariffs_mode()}, tariff_id: {subscription.tariff_id}") if settings.is_tariffs_mode() and subscription.tariff_id: try: from app.database.crud.tariff import get_tariff_by_id tariff = await get_tariff_by_id(db, subscription.tariff_id) + print(f"[DEBUG TARIFF] Загружен тариф: {tariff.name if tariff else None}") + logger.info(f"🔍 Загружен тариф: {tariff.name if tariff else None}, is_daily: {getattr(tariff, 'is_daily', None) if tariff else None}") if tariff: tariff_line = f"\n📦 Тариф: {tariff.name}" # Прикрепляем тариф к подписке для использования в клавиатуре @@ -398,13 +404,35 @@ async def show_subscription_info( tariff_info_lines.append("⏳ Первое списание скоро") tariff_info_block = "\n
" + "\n".join(tariff_info_lines) + "
" + print(f"[DEBUG TARIFF] Сформирован блок: {len(tariff_info_block)} символов") + logger.info(f"🔍 Сформирован блок тарифа: {len(tariff_info_block)} символов") except Exception as e: - logger.warning(f"Ошибка получения тарифа: {e}") + print(f"[DEBUG TARIFF] ОШИБКА: {e}") + logger.warning(f"Ошибка получения тарифа: {e}", exc_info=True) - message_template = texts.t( - "SUBSCRIPTION_OVERVIEW_TEMPLATE", - """👤 {full_name} + print(f"[DEBUG TARIFF] tariff_line='{tariff_line}', tariff_info_block_len={len(tariff_info_block)}") + # Определяем, суточный ли тариф для выбора шаблона + is_daily_tariff = tariff and getattr(tariff, 'is_daily', False) + + if is_daily_tariff: + # Для суточных тарифов другой шаблон без "Действует до" и "Осталось" + message_template = texts.t( + "SUBSCRIPTION_DAILY_OVERVIEW_TEMPLATE", + """👤 {full_name} +💰 Баланс: {balance} +📱 Подписка: {status_emoji} {status_display}{warning}{tariff_info_block} + +📱 Информация о подписке +🎭 Тип: {subscription_type}{tariff_line} +📈 Трафик: {traffic} +🌍 Серверы: {servers} +📱 Устройства: {devices_used} / {device_limit}""", + ) + else: + message_template = texts.t( + "SUBSCRIPTION_OVERVIEW_TEMPLATE", + """👤 {full_name} 💰 Баланс: {balance} 📱 Подписка: {status_emoji} {status_display}{warning}{tariff_info_block} @@ -415,7 +443,7 @@ async def show_subscription_info( 📈 Трафик: {traffic} 🌍 Серверы: {servers} 📱 Устройства: {devices_used} / {device_limit}""", - ) + ) if not show_devices: message_template = message_template.replace( From 3839f6f7095fc66d080cf22103144e173980209f Mon Sep 17 00:00:00 2001 From: Egor Date: Mon, 12 Jan 2026 17:20:13 +0300 Subject: [PATCH 28/61] Add files via upload --- app/handlers/subscription/purchase.py | 8 ------ app/handlers/subscription/tariff_purchase.py | 30 ++++++++++++++++++++ 2 files changed, 30 insertions(+), 8 deletions(-) diff --git a/app/handlers/subscription/purchase.py b/app/handlers/subscription/purchase.py index e4d707ba..892baefc 100644 --- a/app/handlers/subscription/purchase.py +++ b/app/handlers/subscription/purchase.py @@ -339,14 +339,10 @@ async def show_subscription_info( tariff_line = "" tariff_info_block = "" tariff = None - print(f"[DEBUG TARIFF] Режим тарифов: {settings.is_tariffs_mode()}, tariff_id: {subscription.tariff_id}") - logger.info(f"🔍 Режим тарифов: {settings.is_tariffs_mode()}, tariff_id: {subscription.tariff_id}") if settings.is_tariffs_mode() and subscription.tariff_id: try: from app.database.crud.tariff import get_tariff_by_id tariff = await get_tariff_by_id(db, subscription.tariff_id) - print(f"[DEBUG TARIFF] Загружен тариф: {tariff.name if tariff else None}") - logger.info(f"🔍 Загружен тариф: {tariff.name if tariff else None}, is_daily: {getattr(tariff, 'is_daily', None) if tariff else None}") if tariff: tariff_line = f"\n📦 Тариф: {tariff.name}" # Прикрепляем тариф к подписке для использования в клавиатуре @@ -404,14 +400,10 @@ async def show_subscription_info( tariff_info_lines.append("⏳ Первое списание скоро") tariff_info_block = "\n
" + "\n".join(tariff_info_lines) + "
" - print(f"[DEBUG TARIFF] Сформирован блок: {len(tariff_info_block)} символов") - logger.info(f"🔍 Сформирован блок тарифа: {len(tariff_info_block)} символов") except Exception as e: - print(f"[DEBUG TARIFF] ОШИБКА: {e}") logger.warning(f"Ошибка получения тарифа: {e}", exc_info=True) - print(f"[DEBUG TARIFF] tariff_line='{tariff_line}', tariff_info_block_len={len(tariff_info_block)}") # Определяем, суточный ли тариф для выбора шаблона is_daily_tariff = tariff and getattr(tariff, 'is_daily', False) diff --git a/app/handlers/subscription/tariff_purchase.py b/app/handlers/subscription/tariff_purchase.py index e54335d9..266821ab 100644 --- a/app/handlers/subscription/tariff_purchase.py +++ b/app/handlers/subscription/tariff_purchase.py @@ -545,6 +545,12 @@ async def confirm_tariff_purchase( # Получаем список серверов из тарифа squads = tariff.allowed_squads or [] + # Если allowed_squads пустой - значит "все серверы", получаем их + if not squads: + from app.database.crud.server_squad import get_all_server_squads + all_servers, _ = await get_all_server_squads(db, available_only=True) + squads = [s.squad_uuid for s in all_servers if s.squad_uuid] + # Проверяем есть ли уже подписка existing_subscription = await get_subscription_by_user_id(db, db_user.id) @@ -689,6 +695,12 @@ async def confirm_daily_tariff_purchase( # Получаем список серверов из тарифа squads = tariff.allowed_squads or [] + # Если allowed_squads пустой - значит "все серверы", получаем их + if not squads: + from app.database.crud.server_squad import get_all_server_squads + all_servers, _ = await get_all_server_squads(db, available_only=True) + squads = [s.squad_uuid for s in all_servers if s.squad_uuid] + # Проверяем есть ли уже подписка existing_subscription = await get_subscription_by_user_id(db, db_user.id) @@ -1592,6 +1604,12 @@ async def confirm_tariff_switch( # Получаем список серверов из тарифа squads = tariff.allowed_squads or [] + # Если allowed_squads пустой - значит "все серверы", получаем их + if not squads: + from app.database.crud.server_squad import get_all_server_squads + all_servers, _ = await get_all_server_squads(db, available_only=True) + squads = [s.squad_uuid for s in all_servers if s.squad_uuid] + # При смене тарифа пользователь получает ровно тот период, за который заплатил # Старые дни не сохраняются - это смена тарифа, а не продление days_for_new_tariff = period @@ -1734,6 +1752,12 @@ async def confirm_daily_tariff_switch( # Получаем список серверов из тарифа squads = tariff.allowed_squads or [] + # Если allowed_squads пустой - значит "все серверы", получаем их + if not squads: + from app.database.crud.server_squad import get_all_server_squads + all_servers, _ = await get_all_server_squads(db, available_only=True) + squads = [s.squad_uuid for s in all_servers if s.squad_uuid] + # Обновляем подписку на суточный тариф subscription.tariff_id = tariff.id subscription.traffic_limit_gb = tariff.traffic_limit_gb @@ -2292,6 +2316,12 @@ async def confirm_instant_switch( # Получаем список серверов из нового тарифа squads = new_tariff.allowed_squads or [] + # Если allowed_squads пустой - значит "все серверы", получаем их + if not squads: + from app.database.crud.server_squad import get_all_server_squads + all_servers, _ = await get_all_server_squads(db, available_only=True) + squads = [s.squad_uuid for s in all_servers if s.squad_uuid] + # Проверяем, суточный ли новый тариф is_new_daily = getattr(new_tariff, 'is_daily', False) From 0903f42da91664866b083c8db28dafcd010ae07a Mon Sep 17 00:00:00 2001 From: Egor Date: Mon, 12 Jan 2026 17:20:55 +0300 Subject: [PATCH 29/61] Add files via upload --- app/localization/locales/en.json | 3 ++- app/localization/locales/ru.json | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/app/localization/locales/en.json b/app/localization/locales/en.json index 045969cb..fcfb7d65 100644 --- a/app/localization/locales/en.json +++ b/app/localization/locales/en.json @@ -1424,7 +1424,8 @@ "SUBSCRIPTION_NOT_FOUND": "❌ Subscription not found", "SUBSCRIPTION_NO_ACTIVE_LINK": "⚠ You don't have an active subscription or the link is still being generated", "SUBSCRIPTION_NO_SERVERS": "No servers", - "SUBSCRIPTION_OVERVIEW_TEMPLATE": "👤 {full_name}\n💰 Balance: {balance}\n📱 Subscription: {status_emoji} {status_display}{warning}\n\n📱 Subscription details\n🎭 Type: {subscription_type}\n📅 Valid until: {end_date}\n⏰ Time left: {time_left}\n📈 Traffic: {traffic}\n🌍 Servers: {servers}\n📱 Devices: {devices_used} / {device_limit}", + "SUBSCRIPTION_OVERVIEW_TEMPLATE": "👤 {full_name}\n💰 Balance: {balance}\n📱 Subscription: {status_emoji} {status_display}{warning}{tariff_info_block}\n\n📱 Subscription details\n🎭 Type: {subscription_type}{tariff_line}\n📅 Valid until: {end_date}\n⏰ Time left: {time_left}\n📈 Traffic: {traffic}\n🌍 Servers: {servers}\n📱 Devices: {devices_used} / {device_limit}", + "SUBSCRIPTION_DAILY_OVERVIEW_TEMPLATE": "👤 {full_name}\n💰 Balance: {balance}\n📱 Subscription: {status_emoji} {status_display}{warning}{tariff_info_block}\n\n📱 Subscription details\n🎭 Type: {subscription_type}{tariff_line}\n📈 Traffic: {traffic}\n🌍 Servers: {servers}\n📱 Devices: {devices_used} / {device_limit}", "SUBSCRIPTION_PROMO_DISCOUNT_HINT": "⚡ Extra {percent}% discount is active and will apply automatically. It stacks with other discounts.", "SUBSCRIPTION_PROMO_DISCOUNT_NOTE": "⚡ Extra discount {percent}%: -{amount}", "SUBSCRIPTION_PROMO_DISCOUNT_TIMER": "⏳ Discount active for {time_left}\n{bar}", diff --git a/app/localization/locales/ru.json b/app/localization/locales/ru.json index 4214b4a3..16ed01da 100644 --- a/app/localization/locales/ru.json +++ b/app/localization/locales/ru.json @@ -1441,7 +1441,8 @@ "SUBSCRIPTION_NOT_FOUND": "❌ Подписка не найдена", "SUBSCRIPTION_NO_ACTIVE_LINK": "⚠ У вас нет активной подписки или ссылка еще генерируется", "SUBSCRIPTION_NO_SERVERS": "Нет серверов", - "SUBSCRIPTION_OVERVIEW_TEMPLATE": "👤 {full_name}\n💰 Баланс: {balance}\n📱 Подписка: {status_emoji} {status_display}{warning}\n\n📱 Информация о подписке\n🎭 Тип: {subscription_type}\n📅 Действует до: {end_date}\n⏰ Осталось: {time_left}\n📈 Трафик: {traffic}\n🌍 Серверы: {servers}\n📱 Устройства: {devices_used} / {device_limit}", + "SUBSCRIPTION_OVERVIEW_TEMPLATE": "👤 {full_name}\n💰 Баланс: {balance}\n📱 Подписка: {status_emoji} {status_display}{warning}{tariff_info_block}\n\n📱 Информация о подписке\n🎭 Тип: {subscription_type}{tariff_line}\n📅 Действует до: {end_date}\n⏰ Осталось: {time_left}\n📈 Трафик: {traffic}\n🌍 Серверы: {servers}\n📱 Устройства: {devices_used} / {device_limit}", + "SUBSCRIPTION_DAILY_OVERVIEW_TEMPLATE": "👤 {full_name}\n💰 Баланс: {balance}\n📱 Подписка: {status_emoji} {status_display}{warning}{tariff_info_block}\n\n📱 Информация о подписке\n🎭 Тип: {subscription_type}{tariff_line}\n📈 Трафик: {traffic}\n🌍 Серверы: {servers}\n📱 Устройства: {devices_used} / {device_limit}", "SUBSCRIPTION_PROMO_DISCOUNT_HINT": "⚡ Активирована доп. скидка {percent}%. \n\nСуммируется с другими скидками!", "SUBSCRIPTION_PROMO_DISCOUNT_NOTE": "⚡ Доп. скидка {percent}%: -{amount}", "SUBSCRIPTION_PROMO_DISCOUNT_TIMER": "⏳ Скидка действует ещё: {time_left}\n{bar}", From 09bb52f8e5ac6369f93d172acdefb8b9994da4a9 Mon Sep 17 00:00:00 2001 From: Egor Date: Mon, 12 Jan 2026 17:25:47 +0300 Subject: [PATCH 30/61] Add files via upload --- app/localization/locales/en.json | 4 ++-- app/localization/locales/ru.json | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/app/localization/locales/en.json b/app/localization/locales/en.json index fcfb7d65..c6db1338 100644 --- a/app/localization/locales/en.json +++ b/app/localization/locales/en.json @@ -1424,8 +1424,8 @@ "SUBSCRIPTION_NOT_FOUND": "❌ Subscription not found", "SUBSCRIPTION_NO_ACTIVE_LINK": "⚠ You don't have an active subscription or the link is still being generated", "SUBSCRIPTION_NO_SERVERS": "No servers", - "SUBSCRIPTION_OVERVIEW_TEMPLATE": "👤 {full_name}\n💰 Balance: {balance}\n📱 Subscription: {status_emoji} {status_display}{warning}{tariff_info_block}\n\n📱 Subscription details\n🎭 Type: {subscription_type}{tariff_line}\n📅 Valid until: {end_date}\n⏰ Time left: {time_left}\n📈 Traffic: {traffic}\n🌍 Servers: {servers}\n📱 Devices: {devices_used} / {device_limit}", - "SUBSCRIPTION_DAILY_OVERVIEW_TEMPLATE": "👤 {full_name}\n💰 Balance: {balance}\n📱 Subscription: {status_emoji} {status_display}{warning}{tariff_info_block}\n\n📱 Subscription details\n🎭 Type: {subscription_type}{tariff_line}\n📈 Traffic: {traffic}\n🌍 Servers: {servers}\n📱 Devices: {devices_used} / {device_limit}", + "SUBSCRIPTION_OVERVIEW_TEMPLATE": "👤 {full_name}\n💰 Balance: {balance}\n📱 Subscription: {status_emoji} {status_display}{warning}{tariff_info_block}\n\n📱 Subscription details\n🎭 Type: {subscription_type}\n📅 Valid until: {end_date}\n⏰ Time left: {time_left}\n📈 Traffic: {traffic}\n🌍 Servers: {servers}\n📱 Devices: {devices_used} / {device_limit}", + "SUBSCRIPTION_DAILY_OVERVIEW_TEMPLATE": "👤 {full_name}\n💰 Balance: {balance}\n📱 Subscription: {status_emoji} {status_display}{warning}{tariff_info_block}\n\n📱 Subscription details\n🎭 Type: {subscription_type}\n📈 Traffic: {traffic}\n🌍 Servers: {servers}\n📱 Devices: {devices_used} / {device_limit}", "SUBSCRIPTION_PROMO_DISCOUNT_HINT": "⚡ Extra {percent}% discount is active and will apply automatically. It stacks with other discounts.", "SUBSCRIPTION_PROMO_DISCOUNT_NOTE": "⚡ Extra discount {percent}%: -{amount}", "SUBSCRIPTION_PROMO_DISCOUNT_TIMER": "⏳ Discount active for {time_left}\n{bar}", diff --git a/app/localization/locales/ru.json b/app/localization/locales/ru.json index 16ed01da..f995f979 100644 --- a/app/localization/locales/ru.json +++ b/app/localization/locales/ru.json @@ -1441,8 +1441,8 @@ "SUBSCRIPTION_NOT_FOUND": "❌ Подписка не найдена", "SUBSCRIPTION_NO_ACTIVE_LINK": "⚠ У вас нет активной подписки или ссылка еще генерируется", "SUBSCRIPTION_NO_SERVERS": "Нет серверов", - "SUBSCRIPTION_OVERVIEW_TEMPLATE": "👤 {full_name}\n💰 Баланс: {balance}\n📱 Подписка: {status_emoji} {status_display}{warning}{tariff_info_block}\n\n📱 Информация о подписке\n🎭 Тип: {subscription_type}{tariff_line}\n📅 Действует до: {end_date}\n⏰ Осталось: {time_left}\n📈 Трафик: {traffic}\n🌍 Серверы: {servers}\n📱 Устройства: {devices_used} / {device_limit}", - "SUBSCRIPTION_DAILY_OVERVIEW_TEMPLATE": "👤 {full_name}\n💰 Баланс: {balance}\n📱 Подписка: {status_emoji} {status_display}{warning}{tariff_info_block}\n\n📱 Информация о подписке\n🎭 Тип: {subscription_type}{tariff_line}\n📈 Трафик: {traffic}\n🌍 Серверы: {servers}\n📱 Устройства: {devices_used} / {device_limit}", + "SUBSCRIPTION_OVERVIEW_TEMPLATE": "👤 {full_name}\n💰 Баланс: {balance}\n📱 Подписка: {status_emoji} {status_display}{warning}{tariff_info_block}\n\n📱 Информация о подписке\n🎭 Тип: {subscription_type}\n📅 Действует до: {end_date}\n⏰ Осталось: {time_left}\n📈 Трафик: {traffic}\n🌍 Серверы: {servers}\n📱 Устройства: {devices_used} / {device_limit}", + "SUBSCRIPTION_DAILY_OVERVIEW_TEMPLATE": "👤 {full_name}\n💰 Баланс: {balance}\n📱 Подписка: {status_emoji} {status_display}{warning}{tariff_info_block}\n\n📱 Информация о подписке\n🎭 Тип: {subscription_type}\n📈 Трафик: {traffic}\n🌍 Серверы: {servers}\n📱 Устройства: {devices_used} / {device_limit}", "SUBSCRIPTION_PROMO_DISCOUNT_HINT": "⚡ Активирована доп. скидка {percent}%. \n\nСуммируется с другими скидками!", "SUBSCRIPTION_PROMO_DISCOUNT_NOTE": "⚡ Доп. скидка {percent}%: -{amount}", "SUBSCRIPTION_PROMO_DISCOUNT_TIMER": "⏳ Скидка действует ещё: {time_left}\n{bar}", From 0f8a367b24d6280c3191f997adcd025525ac42fd Mon Sep 17 00:00:00 2001 From: Egor Date: Mon, 12 Jan 2026 17:26:16 +0300 Subject: [PATCH 31/61] Add files via upload --- app/handlers/subscription/purchase.py | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/app/handlers/subscription/purchase.py b/app/handlers/subscription/purchase.py index 892baefc..ac2c9122 100644 --- a/app/handlers/subscription/purchase.py +++ b/app/handlers/subscription/purchase.py @@ -336,7 +336,6 @@ async def show_subscription_info( ) # Получаем информацию о тарифе для режима тарифов - tariff_line = "" tariff_info_block = "" tariff = None if settings.is_tariffs_mode() and subscription.tariff_id: @@ -344,7 +343,6 @@ async def show_subscription_info( from app.database.crud.tariff import get_tariff_by_id tariff = await get_tariff_by_id(db, subscription.tariff_id) if tariff: - tariff_line = f"\n📦 Тариф: {tariff.name}" # Прикрепляем тариф к подписке для использования в клавиатуре subscription.tariff = tariff @@ -416,7 +414,7 @@ async def show_subscription_info( 📱 Подписка: {status_emoji} {status_display}{warning}{tariff_info_block} 📱 Информация о подписке -🎭 Тип: {subscription_type}{tariff_line} +🎭 Тип: {subscription_type} 📈 Трафик: {traffic} 🌍 Серверы: {servers} 📱 Устройства: {devices_used} / {device_limit}""", @@ -429,7 +427,7 @@ async def show_subscription_info( 📱 Подписка: {status_emoji} {status_display}{warning}{tariff_info_block} 📱 Информация о подписке -🎭 Тип: {subscription_type}{tariff_line} +🎭 Тип: {subscription_type} 📅 Действует до: {end_date} ⏰ Осталось: {time_left} 📈 Трафик: {traffic} @@ -460,7 +458,6 @@ async def show_subscription_info( warning=warning_text, tariff_info_block=tariff_info_block, subscription_type=subscription_type, - tariff_line=tariff_line, end_date=format_local_datetime(subscription.end_date, "%d.%m.%Y %H:%M"), time_left=time_left_text, traffic=traffic_used_display, From a9e56504a77dcfe9b2f61fb281b2fea5b9453250 Mon Sep 17 00:00:00 2001 From: Egor Date: Mon, 12 Jan 2026 17:33:11 +0300 Subject: [PATCH 32/61] Update menu.py --- app/handlers/menu.py | 35 +++++++++++++++++++++++++++++++++-- 1 file changed, 33 insertions(+), 2 deletions(-) diff --git a/app/handlers/menu.py b/app/handlers/menu.py index 092b40cc..d11d4970 100644 --- a/app/handlers/menu.py +++ b/app/handlers/menu.py @@ -1092,7 +1092,7 @@ async def handle_back_to_menu( ) await callback.answer() -def _get_subscription_status(user: User, texts) -> str: +def _get_subscription_status(user: User, texts, is_daily_tariff: bool = False) -> str: subscription = getattr(user, "subscription", None) if not subscription: return texts.t("SUB_STATUS_NONE", "❌ Отсутствует") @@ -1144,6 +1144,10 @@ def _get_subscription_status(user: User, texts) -> str: ) if actual_status == "active": + # Для суточных тарифов не показываем предупреждение об истечении + if is_daily_tariff: + return texts.t("SUB_STATUS_DAILY_ACTIVE", "💎 Активна") + if days_left > 7 and end_date_text: return texts.t( "SUB_STATUS_ACTIVE_LONG", @@ -1185,12 +1189,39 @@ def _insert_random_message(base_text: str, random_message: str, action_prompt: s async def get_main_menu_text(user, texts, db: AsyncSession): + from app.config import settings + + # Загружаем информацию о тарифе если включен режим тарифов + tariff = None + is_daily_tariff = False + tariff_info_block = "" + + subscription = getattr(user, "subscription", None) + if settings.is_tariffs_mode() and subscription and subscription.tariff_id: + try: + from app.database.crud.tariff import get_tariff_by_id + tariff = await get_tariff_by_id(db, subscription.tariff_id) + if tariff: + is_daily_tariff = getattr(tariff, 'is_daily', False) + # Формируем краткий блок информации о тарифе для главного меню + tariff_info_block = f"\n📦 Тариф: {tariff.name}" + except Exception as e: + logger.debug(f"Не удалось загрузить тариф для главного меню: {e}") base_text = texts.MAIN_MENU.format( user_name=user.full_name, - subscription_status=_get_subscription_status(user, texts) + subscription_status=_get_subscription_status(user, texts, is_daily_tariff) ) + # Добавляем информацию о тарифе перед "Выберите действие" + if tariff_info_block: + action_prompt_text = texts.t("MAIN_MENU_ACTION_PROMPT", "Выберите действие:") + if action_prompt_text in base_text: + base_text = base_text.replace( + action_prompt_text, + f"{tariff_info_block}\n\n{action_prompt_text}" + ) + action_prompt = texts.t("MAIN_MENU_ACTION_PROMPT", "Выберите действие:") info_sections: list[str] = [] From d9514ac00e1e3984186329f06b9b114a3399f6cd Mon Sep 17 00:00:00 2001 From: Egor Date: Mon, 12 Jan 2026 17:33:52 +0300 Subject: [PATCH 33/61] Add files via upload --- app/localization/locales/en.json | 1 + app/localization/locales/ru.json | 1 + app/localization/locales/ua.json | 4 +++- app/localization/locales/zh.json | 8 ++++++-- 4 files changed, 11 insertions(+), 3 deletions(-) diff --git a/app/localization/locales/en.json b/app/localization/locales/en.json index c6db1338..1d267f80 100644 --- a/app/localization/locales/en.json +++ b/app/localization/locales/en.json @@ -1456,6 +1456,7 @@ "SUB_STATUS_ACTIVE_LONG": "💎 Active\n📅 until {end_date} ({days} days)", "SUB_STATUS_ACTIVE_TODAY": "💎 Active\n⚠️ expires today!", "SUB_STATUS_ACTIVE_TOMORROW": "💎 Active\n⚠️ expires tomorrow!", + "SUB_STATUS_DAILY_ACTIVE": "💎 Active", "SUB_STATUS_EXPIRED": "🔴 Expired\n📅 {end_date}", "SUB_STATUS_DISABLED": "⚫ Disabled", "SUB_STATUS_PENDING": "⏳ Pending activation", diff --git a/app/localization/locales/ru.json b/app/localization/locales/ru.json index f995f979..c8ddc9ac 100644 --- a/app/localization/locales/ru.json +++ b/app/localization/locales/ru.json @@ -1473,6 +1473,7 @@ "SUB_STATUS_ACTIVE_LONG": "💎 Активна\n📅 до {end_date} ({days} дн.)", "SUB_STATUS_ACTIVE_TODAY": "💎 Активна\n⚠️ истекает сегодня!", "SUB_STATUS_ACTIVE_TOMORROW": "💎 Активна\n⚠️ истекает завтра!", + "SUB_STATUS_DAILY_ACTIVE": "💎 Активна", "SUB_STATUS_EXPIRED": "🔴 Истекла\n📅 {end_date}", "SUB_STATUS_DISABLED": "⚫ Отключена", "SUB_STATUS_PENDING": "⏳ Ожидает активации", diff --git a/app/localization/locales/ua.json b/app/localization/locales/ua.json index baa7ef9a..eb8db8d6 100644 --- a/app/localization/locales/ua.json +++ b/app/localization/locales/ua.json @@ -1355,7 +1355,8 @@ "SUBSCRIPTION_NOT_FOUND": "❌ Підписку не знайдено", "SUBSCRIPTION_NO_ACTIVE_LINK": "⚠ У вас немає активної підписки або посилання ще генерується", "SUBSCRIPTION_NO_SERVERS": "Немає серверів", - "SUBSCRIPTION_OVERVIEW_TEMPLATE": "👤 {full_name}\n💰 Баланс: {balance}\n📱 Підписка: {status_emoji} {status_display}{warning}\n\n📱 Інформація про підписку\n🎭 Тип: {subscription_type}\n📅 Діє до: {end_date}\n⏰ Залишилося: {time_left}\n📈 Трафік: {traffic}\n🌍 Сервери: {servers}\n📱 Пристрої: {devices_used} / {device_limit}", + "SUBSCRIPTION_OVERVIEW_TEMPLATE": "👤 {full_name}\n💰 Баланс: {balance}\n📱 Підписка: {status_emoji} {status_display}{warning}{tariff_info_block}\n\n📱 Інформація про підписку\n🎭 Тип: {subscription_type}\n📅 Діє до: {end_date}\n⏰ Залишилося: {time_left}\n📈 Трафік: {traffic}\n🌍 Сервери: {servers}\n📱 Пристрої: {devices_used} / {device_limit}", + "SUBSCRIPTION_DAILY_OVERVIEW_TEMPLATE": "👤 {full_name}\n💰 Баланс: {balance}\n📱 Підписка: {status_emoji} {status_display}{warning}{tariff_info_block}\n\n📱 Інформація про підписку\n🎭 Тип: {subscription_type}\n📈 Трафік: {traffic}\n🌍 Сервери: {servers}\n📱 Пристрої: {devices_used} / {device_limit}", "SUBSCRIPTION_PROMO_DISCOUNT_HINT": "⚡ Активовано дод. знижку {percent}%. \n\nСумується з іншими знижками!", "SUBSCRIPTION_PROMO_DISCOUNT_NOTE": "⚡ Дод. знижка {percent}%: -{amount}", "SUBSCRIPTION_PROMO_DISCOUNT_TIMER": "⏳ Знижка діє ще: {time_left}\n{bar}", @@ -1386,6 +1387,7 @@ "SUB_STATUS_ACTIVE_LONG": "💎 Активна\n📅 до {end_date} ({days} дн.)", "SUB_STATUS_ACTIVE_TODAY": "💎 Активна\n⚠️ закінчується сьогодні!", "SUB_STATUS_ACTIVE_TOMORROW": "💎 Активна\n⚠️ закінчується завтра!", + "SUB_STATUS_DAILY_ACTIVE": "💎 Активна", "SUB_STATUS_EXPIRED": "🔴 Закінчилася\n📅 {end_date}", "SUB_STATUS_DISABLED": "⚫ Вимкнена", "SUB_STATUS_PENDING": "⏳ Очікує активації", diff --git a/app/localization/locales/zh.json b/app/localization/locales/zh.json index 8b7f7c27..9b792ce7 100644 --- a/app/localization/locales/zh.json +++ b/app/localization/locales/zh.json @@ -1353,7 +1353,8 @@ "SUBSCRIPTION_NOT_FOUND":"❌未找到订阅", "SUBSCRIPTION_NO_ACTIVE_LINK":"⚠您没有活跃的订阅或链接仍在生成中", "SUBSCRIPTION_NO_SERVERS":"没有服务器", -"SUBSCRIPTION_OVERVIEW_TEMPLATE":"👤{full_name}\n💰余额:{balance}\n📱订阅:{status_emoji}{status_display}{warning}\n\n📱订阅信息\n🎭类型:{subscription_type}\n📅有效期至:{end_date}\n⏰剩余时间:{time_left}\n📈流量:{traffic}\n🌍服务器:{servers}\n📱设备:{devices_used}/{device_limit}", +"SUBSCRIPTION_OVERVIEW_TEMPLATE":"👤{full_name}\n💰余额:{balance}\n📱订阅:{status_emoji}{status_display}{warning}{tariff_info_block}\n\n📱订阅信息\n🎭类型:{subscription_type}\n📅有效期至:{end_date}\n⏰剩余时间:{time_left}\n📈流量:{traffic}\n🌍服务器:{servers}\n📱设备:{devices_used}/{device_limit}", +"SUBSCRIPTION_DAILY_OVERVIEW_TEMPLATE":"👤{full_name}\n💰余额:{balance}\n📱订阅:{status_emoji}{status_display}{warning}{tariff_info_block}\n\n📱订阅信息\n🎭类型:{subscription_type}\n📈流量:{traffic}\n🌍服务器:{servers}\n📱设备:{devices_used}/{device_limit}", "SUBSCRIPTION_PROMO_DISCOUNT_HINT":"⚡已激活额外{percent}%折扣。\n\n可与其他折扣叠加!", "SUBSCRIPTION_PROMO_DISCOUNT_NOTE":"⚡额外{percent}%折扣:-{amount}", "SUBSCRIPTION_PROMO_DISCOUNT_TIMER":"⏳折扣剩余时间:{time_left}\n{bar}", @@ -1384,6 +1385,7 @@ "SUB_STATUS_ACTIVE_LONG":"💎活跃\n📅至{end_date}({days}天)", "SUB_STATUS_ACTIVE_TODAY":"💎活跃\n⚠️今天过期!", "SUB_STATUS_ACTIVE_TOMORROW":"💎活跃\n⚠️明天过期!", +"SUB_STATUS_DAILY_ACTIVE":"💎活跃", "SUB_STATUS_EXPIRED":"🔴已过期\n📅{end_date}", "SUB_STATUS_DISABLED":"⚫已禁用", "SUB_STATUS_PENDING":"⏳等待激活", @@ -1681,7 +1683,8 @@ "SUBSCRIPTION_NOT_FOUND":"❌未找到订阅", "SUBSCRIPTION_NO_ACTIVE_LINK":"⚠您没有活跃的订阅或链接仍在生成中", "SUBSCRIPTION_NO_SERVERS":"没有服务器", -"SUBSCRIPTION_OVERVIEW_TEMPLATE":"👤{full_name}\n💰余额:{balance}\n📱订阅:{status_emoji}{status_display}{warning}\n\n📱订阅信息\n🎭类型:{subscription_type}\n📅有效期至:{end_date}\n⏰剩余时间:{time_left}\n📈流量:{traffic}\n🌍服务器:{servers}\n📱设备:{devices_used}/{device_limit}", +"SUBSCRIPTION_OVERVIEW_TEMPLATE":"👤{full_name}\n💰余额:{balance}\n📱订阅:{status_emoji}{status_display}{warning}{tariff_info_block}\n\n📱订阅信息\n🎭类型:{subscription_type}\n📅有效期至:{end_date}\n⏰剩余时间:{time_left}\n📈流量:{traffic}\n🌍服务器:{servers}\n📱设备:{devices_used}/{device_limit}", +"SUBSCRIPTION_DAILY_OVERVIEW_TEMPLATE":"👤{full_name}\n💰余额:{balance}\n📱订阅:{status_emoji}{status_display}{warning}{tariff_info_block}\n\n📱订阅信息\n🎭类型:{subscription_type}\n📈流量:{traffic}\n🌍服务器:{servers}\n📱设备:{devices_used}/{device_limit}", "SUBSCRIPTION_PROMO_DISCOUNT_HINT":"⚡已激活额外{percent}%折扣。\n\n可与其他折扣叠加!", "SUBSCRIPTION_PROMO_DISCOUNT_NOTE":"⚡额外{percent}%折扣:-{amount}", "SUBSCRIPTION_PROMO_DISCOUNT_TIMER":"⏳折扣剩余时间:{time_left}\n{bar}", @@ -1712,6 +1715,7 @@ "SUB_STATUS_ACTIVE_LONG":"💎活跃\n📅至{end_date}({days}天)", "SUB_STATUS_ACTIVE_TODAY":"💎活跃\n⚠️今天过期!", "SUB_STATUS_ACTIVE_TOMORROW":"💎活跃\n⚠️明天过期!", +"SUB_STATUS_DAILY_ACTIVE":"💎活跃", "SUB_STATUS_EXPIRED":"🔴已过期\n📅{end_date}", "SUB_STATUS_DISABLED":"⚫已禁用", "SUB_STATUS_PENDING":"⏳等待激活", From ae94ed1dcfd10dbf356c9012090456b2971befc7 Mon Sep 17 00:00:00 2001 From: Egor Date: Mon, 12 Jan 2026 17:36:31 +0300 Subject: [PATCH 34/61] Update subscription_checker.py --- app/middlewares/subscription_checker.py | 60 +++++++++++-------------- 1 file changed, 27 insertions(+), 33 deletions(-) diff --git a/app/middlewares/subscription_checker.py b/app/middlewares/subscription_checker.py index 9d9c523f..18ae8e1c 100644 --- a/app/middlewares/subscription_checker.py +++ b/app/middlewares/subscription_checker.py @@ -2,52 +2,46 @@ import logging from typing import Callable, Dict, Any, Awaitable from datetime import datetime from aiogram import BaseMiddleware -from aiogram.types import TelegramObject, Update, Message, CallbackQuery +from aiogram.types import TelegramObject -from app.database.database import get_db -from app.database.crud.user import get_user_by_telegram_id from app.database.models import SubscriptionStatus logger = logging.getLogger(__name__) class SubscriptionStatusMiddleware(BaseMiddleware): - + """ + Проверяет статус подписки пользователя. + ВАЖНО: Использует db и db_user из data, которые уже загружены в AuthMiddleware. + Не создаёт дополнительных сессий БД. + """ + async def __call__( self, handler: Callable[[TelegramObject, Dict[str, Any]], Awaitable[Any]], event: TelegramObject, data: Dict[str, Any] ) -> Any: - - telegram_id = None - if isinstance(event, (Message, CallbackQuery)): - telegram_id = event.from_user.id - elif isinstance(event, Update): - if event.message: - telegram_id = event.message.from_user.id - elif event.callback_query: - telegram_id = event.callback_query.from_user.id - - if telegram_id: + # Используем db и user из AuthMiddleware - не создаём новую сессию! + db = data.get('db') + user = data.get('db_user') + + if db and user and user.subscription: try: - async for db in get_db(): - user = await get_user_by_telegram_id(db, telegram_id) - if user and user.subscription: - current_time = datetime.utcnow() - subscription = user.subscription - - if (subscription.status == SubscriptionStatus.ACTIVE.value and - subscription.end_date <= current_time): - - subscription.status = SubscriptionStatus.EXPIRED.value - subscription.updated_at = current_time - await db.commit() - - logger.info(f"⏰ Middleware: Статус подписки пользователя {user.id} изменен на 'expired' (время истекло)") - break - + current_time = datetime.utcnow() + subscription = user.subscription + + if (subscription.status == SubscriptionStatus.ACTIVE.value and + subscription.end_date and + subscription.end_date <= current_time): + + subscription.status = SubscriptionStatus.EXPIRED.value + subscription.updated_at = current_time + await db.commit() + + logger.info(f"⏰ Middleware: Статус подписки пользователя {user.id} изменен на 'expired' (время истекло)") + except Exception as e: - logger.error(f"Ошибка проверки статуса подписки для пользователя {telegram_id}: {e}") - + logger.error(f"Ошибка проверки статуса подписки: {e}") + return await handler(event, data) From 6e8c9bda303decced5cc911591e2ab5239ab3c06 Mon Sep 17 00:00:00 2001 From: Egor Date: Mon, 12 Jan 2026 17:39:58 +0300 Subject: [PATCH 35/61] Update channel_checker.py --- app/middlewares/channel_checker.py | 56 ++++++++++-------------------- 1 file changed, 18 insertions(+), 38 deletions(-) diff --git a/app/middlewares/channel_checker.py b/app/middlewares/channel_checker.py index cac399fb..67fa2db0 100644 --- a/app/middlewares/channel_checker.py +++ b/app/middlewares/channel_checker.py @@ -1,17 +1,19 @@ import logging from typing import Callable, Dict, Any, Awaitable, Optional +from datetime import datetime from aiogram import BaseMiddleware, Bot, types from aiogram.exceptions import TelegramForbiddenError, TelegramBadRequest from aiogram.fsm.context import FSMContext from aiogram.types import TelegramObject, Update, Message, CallbackQuery from aiogram.enums import ChatMemberStatus +from sqlalchemy.ext.asyncio import AsyncSession from app.config import settings from app.database.database import get_db from app.database.crud.campaign import get_campaign_by_start_parameter from app.database.crud.subscription import deactivate_subscription, reactivate_subscription from app.database.crud.user import get_user_by_telegram_id -from app.database.models import SubscriptionStatus +from app.database.models import SubscriptionStatus, User from app.keyboards.inline import get_channel_sub_keyboard from app.localization.loader import DEFAULT_LANGUAGE from app.localization.texts import get_texts @@ -23,6 +25,11 @@ logger = logging.getLogger(__name__) class ChannelCheckerMiddleware(BaseMiddleware): + """ + Middleware для проверки подписки на канал. + ОПТИМИЗИРОВАНО: создаёт максимум одну сессию БД на запрос. + """ + def __init__(self): self.BAD_MEMBER_STATUS = ( ChatMemberStatus.LEFT, @@ -55,10 +62,7 @@ class ChannelCheckerMiddleware(BaseMiddleware): logger.debug("❌ telegram_id не найден, пропускаем") return await handler(event, data) - - # Админам разрешаем пропускать проверку подписки, чтобы не блокировать - # работу панели управления даже при отсутствии подписки. Важно делать - # это до обращения к состоянию, чтобы не выполнять лишние операции. + # Админам разрешаем пропускать проверку подписки if settings.is_admin(telegram_id): logger.debug( "✅ Пользователь %s является администратором — пропускаем проверку подписки", @@ -72,7 +76,6 @@ class ChannelCheckerMiddleware(BaseMiddleware): if state: current_state = await state.get_state() - is_reg_process = is_registration_process(event, current_state) if is_reg_process: @@ -191,10 +194,10 @@ class ChannelCheckerMiddleware(BaseMiddleware): payload = parts[1] - data = await state.get_data() or {} - if data.get("pending_start_payload") != payload: - data["pending_start_payload"] = payload - await state.set_data(data) + state_data = await state.get_data() or {} + if state_data.get("pending_start_payload") != payload: + state_data["pending_start_payload"] = payload + await state.set_data(state_data) logger.debug("💾 Сохранен start payload %s для последующей обработки", payload) if bot and message.from_user: @@ -213,7 +216,7 @@ class ChannelCheckerMiddleware(BaseMiddleware): payload: str, ) -> None: try: - data = await state.get_data() or {} + state_data = await state.get_data() or {} except Exception as error: logger.error( "❌ Не удалось получить данные состояния для уведомления по кампании %s: %s", @@ -222,7 +225,7 @@ class ChannelCheckerMiddleware(BaseMiddleware): ) return - if data.get("campaign_notification_sent"): + if state_data.get("campaign_notification_sent"): return async for db in get_db(): @@ -246,7 +249,6 @@ class ChannelCheckerMiddleware(BaseMiddleware): ) if sent: await state.update_data(campaign_notification_sent=True) - break except Exception as error: logger.error( "❌ Ошибка отправки уведомления о переходе по кампании %s: %s", @@ -259,40 +261,24 @@ class ChannelCheckerMiddleware(BaseMiddleware): async def _deactivate_subscription_on_unsubscribe( self, telegram_id: int, bot: Bot, channel_link: Optional[str] ) -> None: + """Деактивация подписки при отписке от канала.""" if not settings.CHANNEL_DISABLE_TRIAL_ON_UNSUBSCRIBE and not settings.CHANNEL_REQUIRED_FOR_ALL: - logger.debug( - "ℹ️ Пропускаем деактивацию подписки пользователя %s: отключение при отписке выключено", - telegram_id, - ) return async for db in get_db(): try: user = await get_user_by_telegram_id(db, telegram_id) if not user or not user.subscription: - logger.debug( - "⚠️ Пользователь %s отсутствует или не имеет подписки — пропускаем деактивацию", - telegram_id, - ) break subscription = user.subscription if subscription.status != SubscriptionStatus.ACTIVE.value: - logger.debug( - "ℹ️ Подписка пользователя %s не активна (status=%s) — пропускаем деактивацию", - telegram_id, - subscription.status, - ) break if settings.CHANNEL_REQUIRED_FOR_ALL: pass elif not subscription.is_trial: - logger.debug( - "ℹ️ Подписка пользователя %s платная, CHANNEL_REQUIRED_FOR_ALL=False — пропускаем деактивацию", - telegram_id, - ) break await deactivate_subscription(db, subscription) @@ -324,7 +310,6 @@ class ChannelCheckerMiddleware(BaseMiddleware): ) channel_kb = get_channel_sub_keyboard(channel_link, language=user.language) await bot.send_message(telegram_id, notification_text, reply_markup=channel_kb) - logger.info(f"📨 Уведомление о деактивации отправлено пользователю {telegram_id}") except Exception as notify_error: logger.error( "❌ Не удалось отправить уведомление о деактивации пользователю %s: %s", @@ -341,10 +326,7 @@ class ChannelCheckerMiddleware(BaseMiddleware): break async def _reactivate_subscription_on_subscribe(self, telegram_id: int, bot: Bot) -> None: - """Реактивация подписки после повторной подписки на канал. - - Вызывается только если подписка в статусе DISABLED. - """ + """Реактивация подписки после повторной подписки на канал.""" if not settings.CHANNEL_DISABLE_TRIAL_ON_UNSUBSCRIBE and not settings.CHANNEL_REQUIRED_FOR_ALL: return @@ -356,13 +338,11 @@ class ChannelCheckerMiddleware(BaseMiddleware): subscription = user.subscription - # Реактивируем только DISABLED подписки (деактивированные из-за отписки) - # Тихо выходим если подписка не требует реактивации — без логов + # Реактивируем только DISABLED подписки if subscription.status != SubscriptionStatus.DISABLED.value: break # Проверяем что подписка ещё не истекла - from datetime import datetime if subscription.end_date and subscription.end_date <= datetime.utcnow(): break From c350195bfc9072c93abcc5f41192d3634c800cba Mon Sep 17 00:00:00 2001 From: Egor Date: Mon, 12 Jan 2026 17:58:25 +0300 Subject: [PATCH 36/61] Update miniapp.py --- app/webapi/routes/miniapp.py | 129 +++++++++++++++++++++++++++++++++++ 1 file changed, 129 insertions(+) diff --git a/app/webapi/routes/miniapp.py b/app/webapi/routes/miniapp.py index c469335f..b19d8acd 100644 --- a/app/webapi/routes/miniapp.py +++ b/app/webapi/routes/miniapp.py @@ -197,6 +197,8 @@ from ..schemas.miniapp import ( MiniAppConnectedServer, MiniAppTrafficTopupRequest, MiniAppTrafficTopupResponse, + MiniAppDailySubscriptionToggleRequest, + MiniAppDailySubscriptionToggleResponse, ) @@ -3414,6 +3416,26 @@ async def get_subscription_details( devices_count, devices = await _load_devices_info(user) + # Загружаем данные суточного тарифа + is_daily_tariff = False + is_daily_paused = False + daily_tariff_name = None + daily_price_kopeks = None + daily_price_label = None + daily_next_charge_at = None + + if subscription and getattr(subscription, "tariff_id", None): + tariff = await get_tariff_by_id(db, subscription.tariff_id) + if tariff and getattr(tariff, 'is_daily', False): + is_daily_tariff = True + is_daily_paused = getattr(subscription, 'is_daily_paused', False) + daily_tariff_name = tariff.name + daily_price_kopeks = getattr(tariff, 'daily_price_kopeks', 0) + daily_price_label = settings.format_price(daily_price_kopeks) + "/день" if daily_price_kopeks > 0 else None + # Следующее списание - через 24 часа от последнего обновления подписки или от start_date + if subscription.end_date and not is_daily_paused: + daily_next_charge_at = subscription.end_date + response_user = MiniAppSubscriptionUser( telegram_id=user.telegram_id, username=user.username, @@ -3443,6 +3465,12 @@ async def get_subscription_details( promo_offer_discount_percent=active_discount_percent, promo_offer_discount_expires_at=active_discount_expires_at, promo_offer_discount_source=promo_offer_source, + is_daily_tariff=is_daily_tariff, + is_daily_paused=is_daily_paused, + daily_tariff_name=daily_tariff_name, + daily_price_kopeks=daily_price_kopeks, + daily_price_label=daily_price_label, + daily_next_charge_at=daily_next_charge_at, ) referral_info = await _build_referral_info(db, user) @@ -6340,6 +6368,11 @@ async def _build_tariff_model( is_upgrade = upgrade is_switch_free = cost == 0 + # Суточный тариф + is_daily = getattr(tariff, 'is_daily', False) + daily_price_kopeks = getattr(tariff, 'daily_price_kopeks', 0) if is_daily else 0 + daily_price_label = settings.format_price(daily_price_kopeks) + "/день" if is_daily and daily_price_kopeks > 0 else None + return MiniAppTariff( id=tariff.id, name=tariff.name, @@ -6358,6 +6391,9 @@ async def _build_tariff_model( switch_cost_label=switch_cost_label, is_upgrade=is_upgrade, is_switch_free=is_switch_free, + is_daily=is_daily, + daily_price_kopeks=daily_price_kopeks, + daily_price_label=daily_price_label, ) @@ -6378,6 +6414,11 @@ async def _build_current_tariff_model(db: AsyncSession, tariff, promo_group=None except (TypeError, ValueError): pass + # Суточный тариф + is_daily = getattr(tariff, 'is_daily', False) + daily_price_kopeks = getattr(tariff, 'daily_price_kopeks', 0) if is_daily else 0 + daily_price_label = settings.format_price(daily_price_kopeks) + "/день" if is_daily and daily_price_kopeks > 0 else None + return MiniAppCurrentTariff( id=tariff.id, name=tariff.name, @@ -6389,6 +6430,9 @@ async def _build_current_tariff_model(db: AsyncSession, tariff, promo_group=None device_limit=tariff.device_limit, servers_count=servers_count, monthly_price_kopeks=monthly_price, + is_daily=is_daily, + daily_price_kopeks=daily_price_kopeks, + daily_price_label=daily_price_label, ) @@ -7093,3 +7137,88 @@ async def purchase_traffic_topup_endpoint( new_balance_kopeks=user.balance_kopeks, charged_kopeks=final_price, ) + + +@router.post("/subscription/daily/toggle-pause") +async def toggle_daily_subscription_pause_endpoint( + payload: MiniAppDailySubscriptionToggleRequest, + db: AsyncSession = Depends(get_db_session), +): + """Переключает паузу/активацию суточной подписки.""" + from app.webapi.schemas.miniapp import MiniAppDailySubscriptionToggleResponse + from app.services.subscription_service import SubscriptionService + + user = await _authorize_miniapp_user(payload.init_data, db) + subscription = user.subscription + + if not subscription: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail={"code": "no_subscription", "message": "No subscription found"}, + ) + + # Проверяем наличие тарифа + tariff_id = getattr(subscription, 'tariff_id', None) + if not tariff_id: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail={"code": "no_tariff", "message": "Subscription has no tariff"}, + ) + + tariff = await get_tariff_by_id(db, tariff_id) + if not tariff or not getattr(tariff, 'is_daily', False): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail={"code": "not_daily_tariff", "message": "Subscription is not on a daily tariff"}, + ) + + # Переключаем состояние паузы + is_currently_paused = getattr(subscription, 'is_daily_paused', False) + new_paused_state = not is_currently_paused + subscription.is_daily_paused = new_paused_state + + # Если снимаем с паузы и подписка активна, нужно проверить баланс для активации + if not new_paused_state: + daily_price = getattr(tariff, 'daily_price_kopeks', 0) + if daily_price > 0 and user.balance_kopeks < daily_price: + raise HTTPException( + status_code=status.HTTP_402_PAYMENT_REQUIRED, + detail={ + "code": "insufficient_balance", + "message": "Insufficient balance to resume daily subscription", + "required": daily_price, + "balance": user.balance_kopeks, + }, + ) + + await db.commit() + await db.refresh(subscription) + await db.refresh(user) + + # Синхронизация с RemnaWave + try: + service = SubscriptionService() + if new_paused_state: + # При паузе отключаем пользователя в RemnaWave + if user.remnawave_uuid: + await service.disable_remnawave_user(user.remnawave_uuid) + else: + # При возобновлении включаем пользователя в RemnaWave + if user.remnawave_uuid: + await service.enable_remnawave_user(user.remnawave_uuid) + except Exception as e: + logger.error(f"Ошибка синхронизации с RemnaWave при паузе/возобновлении: {e}") + + lang = getattr(user, "language", settings.DEFAULT_LANGUAGE) + if new_paused_state: + message = "Суточная подписка приостановлена" if lang == "ru" else "Daily subscription paused" + else: + message = "Суточная подписка возобновлена" if lang == "ru" else "Daily subscription resumed" + + return MiniAppDailySubscriptionToggleResponse( + success=True, + message=message, + is_paused=new_paused_state, + balance_kopeks=user.balance_kopeks, + balance_label=settings.format_price(user.balance_kopeks), + ) From 0ac2a7a62b198ef11149e943e0ada3e68114cfb8 Mon Sep 17 00:00:00 2001 From: Egor Date: Mon, 12 Jan 2026 17:58:50 +0300 Subject: [PATCH 37/61] Update miniapp.py --- app/webapi/schemas/miniapp.py | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/app/webapi/schemas/miniapp.py b/app/webapi/schemas/miniapp.py index 70e7fdf8..152beaf4 100644 --- a/app/webapi/schemas/miniapp.py +++ b/app/webapi/schemas/miniapp.py @@ -47,6 +47,13 @@ class MiniAppSubscriptionUser(BaseModel): promo_offer_discount_percent: int = 0 promo_offer_discount_expires_at: Optional[datetime] = None promo_offer_discount_source: Optional[str] = None + # Суточные тарифы + is_daily_tariff: bool = False + is_daily_paused: bool = False + daily_tariff_name: Optional[str] = None + daily_price_kopeks: Optional[int] = None + daily_price_label: Optional[str] = None + daily_next_charge_at: Optional[datetime] = None # Время следующего списания class MiniAppPromoGroup(BaseModel): @@ -529,6 +536,10 @@ class MiniAppTariff(BaseModel): switch_cost_label: Optional[str] = None # Форматированная стоимость is_upgrade: Optional[bool] = None # True = повышение, False = понижение is_switch_free: Optional[bool] = None # True = бесплатное переключение + # Суточные тарифы + is_daily: bool = False + daily_price_kopeks: int = 0 + daily_price_label: Optional[str] = None class MiniAppTrafficTopupPackage(BaseModel): @@ -561,6 +572,10 @@ class MiniAppCurrentTariff(BaseModel): # Лимит докупки трафика (0 = без лимита) max_topup_traffic_gb: int = 0 available_topup_gb: Optional[int] = None # Сколько еще можно докупить (None = без лимита) + # Суточные тарифы + is_daily: bool = False + daily_price_kopeks: int = 0 + daily_price_label: Optional[str] = None class MiniAppTrafficTopupRequest(BaseModel): @@ -650,6 +665,20 @@ class MiniAppTariffSwitchResponse(BaseModel): balance_label: str = "" +class MiniAppDailySubscriptionToggleRequest(BaseModel): + """Запрос на паузу/возобновление суточной подписки.""" + init_data: str = Field(...) + + +class MiniAppDailySubscriptionToggleResponse(BaseModel): + """Ответ на паузу/возобновление суточной подписки.""" + success: bool = True + message: Optional[str] = None + is_paused: bool = False + balance_kopeks: int = 0 + balance_label: str = "" + + class MiniAppSubscriptionResponse(BaseModel): success: bool = True subscription_id: Optional[int] = None From 97711ac735fbc0632aa08a6dae9e9faabe577ba3 Mon Sep 17 00:00:00 2001 From: Egor Date: Mon, 12 Jan 2026 18:01:34 +0300 Subject: [PATCH 38/61] Update index.html --- miniapp/index.html | 459 +++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 445 insertions(+), 14 deletions(-) diff --git a/miniapp/index.html b/miniapp/index.html index 9e97474f..8fb9d4d8 100644 --- a/miniapp/index.html +++ b/miniapp/index.html @@ -2707,6 +2707,168 @@ color: #41464b; } + .status-paused { + background: linear-gradient(135deg, #e2e3e5, #eeeff1); + color: #41464b; + } + + /* Daily Subscription Status Bar */ + .daily-subscription-status { + margin-top: 16px; + padding: 16px; + background: linear-gradient(135deg, rgba(var(--primary-rgb), 0.08), rgba(var(--primary-rgb), 0.04)); + border-radius: var(--radius-lg); + border: 1px solid rgba(var(--primary-rgb), 0.15); + } + + .daily-subscription-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 12px; + } + + .daily-subscription-title { + font-size: 14px; + font-weight: 700; + color: var(--text-primary); + display: flex; + align-items: center; + gap: 8px; + } + + .daily-subscription-title .daily-icon { + font-size: 18px; + } + + .daily-subscription-price { + font-size: 14px; + font-weight: 600; + color: var(--primary); + } + + .daily-subscription-progress { + margin-bottom: 12px; + } + + .daily-progress-bar { + height: 8px; + background: rgba(var(--primary-rgb), 0.15); + border-radius: 4px; + overflow: hidden; + position: relative; + } + + .daily-progress-fill { + height: 100%; + background: linear-gradient(90deg, var(--primary), rgba(var(--primary-rgb), 0.7)); + border-radius: 4px; + transition: width 0.5s ease; + position: relative; + } + + .daily-progress-fill::after { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: linear-gradient(90deg, transparent, rgba(255,255,255,0.3), transparent); + animation: shimmer 2s infinite; + } + + .daily-subscription-info { + display: flex; + justify-content: space-between; + align-items: center; + font-size: 13px; + } + + .daily-time-remaining { + color: var(--text-secondary); + } + + .daily-time-remaining strong { + color: var(--text-primary); + font-weight: 600; + } + + .daily-next-charge { + color: var(--text-secondary); + } + + .daily-subscription-actions { + margin-top: 12px; + display: flex; + gap: 8px; + } + + .daily-pause-btn { + flex: 1; + padding: 10px 16px; + border-radius: var(--radius); + border: 2px solid var(--border-color); + background: var(--bg-secondary); + color: var(--text-primary); + font-size: 14px; + font-weight: 600; + cursor: pointer; + transition: all 0.3s ease; + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + } + + .daily-pause-btn:hover { + border-color: var(--primary); + background: rgba(var(--primary-rgb), 0.05); + } + + .daily-pause-btn.paused { + background: linear-gradient(135deg, var(--primary), rgba(var(--primary-rgb), 0.8)); + color: white; + border-color: transparent; + } + + .daily-pause-btn.paused:hover { + opacity: 0.9; + } + + .daily-pause-btn:disabled { + opacity: 0.6; + cursor: not-allowed; + } + + .daily-subscription-paused-notice { + margin-top: 12px; + padding: 12px; + background: rgba(var(--warning-rgb), 0.1); + border-radius: var(--radius); + border: 1px solid rgba(var(--warning-rgb), 0.2); + font-size: 13px; + color: var(--text-primary); + display: flex; + align-items: flex-start; + gap: 10px; + } + + .daily-subscription-paused-notice .notice-icon { + font-size: 18px; + line-height: 1; + } + + :root[data-theme="dark"] .daily-subscription-status { + background: linear-gradient(135deg, rgba(var(--primary-rgb), 0.12), rgba(var(--primary-rgb), 0.06)); + border-color: rgba(var(--primary-rgb), 0.25); + } + + :root[data-theme="dark"] .status-paused { + background: linear-gradient(135deg, rgba(100, 116, 139, 0.3), rgba(100, 116, 139, 0.2)); + color: #94a3b8; + } + .status-missing { background: linear-gradient(135deg, #e0e7ff, #eef2ff); color: #1e3a8a; @@ -5569,6 +5731,39 @@ + + +
@@ -6433,6 +6628,16 @@ 'info.promo_group': 'Promo group', 'info.device_limit': 'Device limit', 'info.autopay': 'Auto-pay', + // Daily subscription + 'daily.tariff_name': 'Daily tariff', + 'daily.time_remaining': 'Time left:', + 'daily.next_charge': 'Next charge at', + 'daily.pause': 'Pause', + 'daily.resume': 'Resume', + 'daily.paused_notice': 'Subscription paused. Daily billing disabled. VPN is not working.', + 'daily.status.active': 'Active', + 'daily.status.paused': 'Paused', + 'daily.hours_remaining': 'Hours left', 'button.connect.default': 'Connect to VPN', 'button.connect.happ': 'Connect', 'button.copy': 'Copy subscription link', @@ -6896,6 +7101,16 @@ 'info.promo_group': 'Уровень', 'info.device_limit': 'Лимит устройств', 'info.autopay': 'Автоплатеж', + // Суточная подписка + 'daily.tariff_name': 'Суточный тариф', + 'daily.time_remaining': 'Осталось:', + 'daily.next_charge': 'Следующее списание', + 'daily.pause': 'Приостановить', + 'daily.resume': 'Возобновить', + 'daily.paused_notice': 'Подписка приостановлена. Ежедневное списание отключено. VPN не работает.', + 'daily.status.active': 'Активна', + 'daily.status.paused': 'Приостановлена', + 'daily.hours_remaining': 'Часов осталось', 'button.connect.default': 'Подключиться к VPN', 'button.connect.happ': 'Подключиться', 'button.copy': 'Скопировать ссылку подписки', @@ -9278,6 +9493,9 @@ : autopayLabel; } + // Отображение суточной подписки + renderDailySubscriptionStatus(); + renderSubscriptionMissingCard(); renderSubscriptionPurchaseCard(); renderSubscriptionRenewalCard(); @@ -9295,6 +9513,195 @@ updateActionButtons(); } + // ============================================================ + // Суточная подписка - отображение и управление + // ============================================================ + let dailyTimerInterval = null; + + function renderDailySubscriptionStatus() { + const container = document.getElementById('dailySubscriptionStatus'); + if (!container) return; + + const user = userData?.user; + if (!user) { + container.classList.add('hidden'); + return; + } + + const isDailyTariff = user.is_daily_tariff ?? user.isDailyTariff ?? false; + const isDailyPaused = user.is_daily_paused ?? user.isDailyPaused ?? false; + const dailyTariffName = user.daily_tariff_name ?? user.dailyTariffName ?? ''; + const dailyPriceLabel = user.daily_price_label ?? user.dailyPriceLabel ?? ''; + const dailyNextChargeAt = user.daily_next_charge_at ?? user.dailyNextChargeAt ?? null; + + if (!isDailyTariff) { + container.classList.add('hidden'); + if (dailyTimerInterval) { + clearInterval(dailyTimerInterval); + dailyTimerInterval = null; + } + return; + } + + container.classList.remove('hidden'); + + // Заполняем данные + const tariffNameEl = document.getElementById('dailyTariffName'); + if (tariffNameEl && dailyTariffName) { + tariffNameEl.textContent = dailyTariffName; + } + + const priceEl = document.getElementById('dailyPrice'); + if (priceEl && dailyPriceLabel) { + priceEl.textContent = dailyPriceLabel; + } + + // Настройка кнопки паузы + const pauseBtn = document.getElementById('dailyPauseBtn'); + const pauseBtnIcon = document.getElementById('dailyPauseBtnIcon'); + const pauseBtnText = document.getElementById('dailyPauseBtnText'); + const pausedNotice = document.getElementById('dailyPausedNotice'); + const progressSection = document.getElementById('dailyProgressSection'); + + if (isDailyPaused) { + pauseBtn?.classList.add('paused'); + if (pauseBtnIcon) pauseBtnIcon.textContent = '▶️'; + if (pauseBtnText) pauseBtnText.textContent = t('daily.resume'); + pausedNotice?.classList.remove('hidden'); + progressSection?.classList.add('hidden'); + + // Обновляем статус badge + const statusBadge = document.getElementById('statusBadge'); + if (statusBadge) { + statusBadge.textContent = t('daily.status.paused'); + statusBadge.className = 'status-badge status-paused'; + } + } else { + pauseBtn?.classList.remove('paused'); + if (pauseBtnIcon) pauseBtnIcon.textContent = '⏸️'; + if (pauseBtnText) pauseBtnText.textContent = t('daily.pause'); + pausedNotice?.classList.add('hidden'); + progressSection?.classList.remove('hidden'); + } + + // Обработчик кнопки паузы + if (pauseBtn) { + pauseBtn.onclick = () => toggleDailyPause(); + } + + // Запуск таймера обратного отсчета + startDailyCountdownTimer(dailyNextChargeAt, isDailyPaused); + } + + function startDailyCountdownTimer(nextChargeAt, isPaused) { + if (dailyTimerInterval) { + clearInterval(dailyTimerInterval); + dailyTimerInterval = null; + } + + const timeRemainingEl = document.getElementById('dailyTimeRemaining'); + const progressFill = document.getElementById('dailyProgressFill'); + const nextChargeEl = document.getElementById('dailyNextCharge'); + + if (!nextChargeAt || isPaused) { + if (timeRemainingEl) timeRemainingEl.textContent = '--:--:--'; + if (progressFill) progressFill.style.width = '0%'; + if (nextChargeEl) nextChargeEl.textContent = ''; + return; + } + + const nextChargeDate = new Date(nextChargeAt); + const totalDuration = 24 * 60 * 60 * 1000; // 24 часа в миллисекундах + + function updateTimer() { + const now = new Date(); + const remaining = nextChargeDate - now; + + if (remaining <= 0) { + if (timeRemainingEl) timeRemainingEl.textContent = '00:00:00'; + if (progressFill) progressFill.style.width = '0%'; + clearInterval(dailyTimerInterval); + return; + } + + // Форматируем оставшееся время + const hours = Math.floor(remaining / (1000 * 60 * 60)); + const minutes = Math.floor((remaining % (1000 * 60 * 60)) / (1000 * 60)); + const seconds = Math.floor((remaining % (1000 * 60)) / 1000); + const timeStr = `${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`; + + if (timeRemainingEl) timeRemainingEl.textContent = timeStr; + + // Обновляем прогресс бар (от 100% до 0%) + const progressPercent = Math.max(0, Math.min(100, (remaining / totalDuration) * 100)); + if (progressFill) progressFill.style.width = `${progressPercent}%`; + + // Время следующего списания + if (nextChargeEl) { + const chargeTime = nextChargeDate.toLocaleTimeString(currentLanguage === 'ru' ? 'ru-RU' : 'en-US', { + hour: '2-digit', + minute: '2-digit' + }); + nextChargeEl.textContent = `${t('daily.next_charge')} ${chargeTime}`; + } + } + + updateTimer(); + dailyTimerInterval = setInterval(updateTimer, 1000); + } + + async function toggleDailyPause() { + const pauseBtn = document.getElementById('dailyPauseBtn'); + if (!pauseBtn || pauseBtn.disabled) return; + + pauseBtn.disabled = true; + + try { + const initData = window.Telegram?.WebApp?.initData || ''; + const response = await fetch(`${apiBaseUrl}/miniapp/subscription/daily/toggle-pause`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ init_data: initData }) + }); + + const result = await response.json(); + + if (!response.ok) { + const errorCode = result?.detail?.code || 'unknown'; + if (errorCode === 'insufficient_balance') { + showToast(t('daily.error.insufficient_balance') || 'Недостаточно средств для возобновления подписки', 'error'); + } else { + showToast(result?.detail?.message || 'Error', 'error'); + } + return; + } + + // Обновляем локальные данные + if (userData?.user) { + userData.user.is_daily_paused = result.is_paused; + userData.user.isDailyPaused = result.is_paused; + } + + // Обновляем баланс + if (result.balance_kopeks !== undefined) { + userData.balance_kopeks = result.balance_kopeks; + userData.balanceKopeks = result.balance_kopeks; + renderBalanceSection(); + } + + // Перерисовываем статус + renderDailySubscriptionStatus(); + + showToast(result.message || 'OK', 'success'); + + } catch (error) { + console.error('Error toggling daily pause:', error); + showToast('Connection error', 'error'); + } finally { + pauseBtn.disabled = false; + } + } + function renderSubscriptionMissingCard() { const card = document.getElementById('subscriptionMissingCard'); if (!card) { @@ -20359,6 +20766,8 @@ // Разные стили для режима смены и покупки if (isInstantSwitchMode) { // Режим мгновенной смены тарифа - используем точные данные с сервера + const isDaily = tariff.is_daily ?? tariff.isDaily ?? false; + const dailyPriceLabel = tariff.daily_price_label ?? tariff.dailyPriceLabel ?? null; const isUpgrade = tariff.is_upgrade ?? tariff.isUpgrade ?? false; const isSwitchFree = tariff.is_switch_free ?? tariff.isSwitchFree ?? !isUpgrade; const upgradeCost = tariff.switch_cost_kopeks ?? tariff.switchCostKopeks ?? 0; @@ -20394,12 +20803,13 @@ const upgradeLabel = preferredLanguage === 'en' ? 'upgrade' : 'доплата'; const freeLabel = preferredLanguage === 'en' ? '✓ Free' : '✓ Бесплатно'; + const dailyLabel = preferredLanguage === 'en' ? 'daily' : 'суточный'; div.className = 'instant-switch-tariff-item'; div.innerHTML = `
- ${isPremium ? '👑 ' : '⚡ '}${escapeHtml(tariff.name)} + ${isDaily ? '🔄 ' : (isPremium ? '👑 ' : '⚡ ')}${escapeHtml(tariff.name)}
${description ? `
${escapeHtml(description)}
` : ''}
@@ -20409,22 +20819,40 @@ ${serverTags ? `
${serverTags}
` : ''}
- - ${isUpgrade - ? `${upgradeLabel}+${upgradeCostLabel}` - : freeLabel} - + ${isDaily ? ` + + ${dailyLabel} + ${dailyPriceLabel || ''} + + ` : ` + + ${isUpgrade + ? `${upgradeLabel}+${upgradeCostLabel}` + : freeLabel} + + `}
`; div.addEventListener('click', () => showInstantSwitchConfirm(tariff, isUpgrade, upgradeCost)); } else { // Обычный режим покупки + const isDaily = tariff.is_daily ?? tariff.isDaily ?? false; + const dailyPriceKopeks = tariff.daily_price_kopeks ?? tariff.dailyPriceKopeks ?? 0; + const dailyPriceLabel = tariff.daily_price_label ?? tariff.dailyPriceLabel ?? null; + let minPrice = null; let minPriceOriginal = null; let maxDiscountPercent = 0; + let priceDisplayLabel = null; + let priceDisplayOriginalLabel = null; + let pricePrefix = preferredLanguage === 'en' ? 'from' : 'от'; - if (periods.length > 0) { + if (isDaily && dailyPriceKopeks > 0) { + // Суточный тариф - показываем цену за день + priceDisplayLabel = dailyPriceLabel || formatPriceFromKopeks(dailyPriceKopeks, tariffsData?.currency || 'RUB') + '/день'; + pricePrefix = ''; + } else if (periods.length > 0) { periods.forEach(p => { const price = p.price_kopeks || p.priceKopeks || 0; const originalPrice = p.original_price_kopeks || p.originalPriceKopeks || price; @@ -20438,14 +20866,16 @@ maxDiscountPercent = discountPct; } }); + priceDisplayLabel = minPrice !== null + ? formatPriceFromKopeks(minPrice, tariffsData?.currency || 'RUB') + : null; + priceDisplayOriginalLabel = minPriceOriginal !== null + ? formatPriceFromKopeks(minPriceOriginal, tariffsData?.currency || 'RUB') + : null; } - const minPriceLabel = minPrice !== null - ? formatPriceFromKopeks(minPrice, tariffsData?.currency || 'RUB') - : null; - const minPriceOriginalLabel = minPriceOriginal !== null - ? formatPriceFromKopeks(minPriceOriginal, tariffsData?.currency || 'RUB') - : null; + const minPriceLabel = priceDisplayLabel; + const minPriceOriginalLabel = priceDisplayOriginalLabel; div.className = 'subscription-settings-toggle' + (selectedTariffId === tariff.id ? ' active' : ''); @@ -20489,10 +20919,11 @@
${minPriceLabel ? `
-
от
+ ${pricePrefix ? `
${pricePrefix}
` : ''} ${minPriceOriginalLabel ? `
${minPriceOriginalLabel}
` : ''}
${minPriceLabel}
${maxDiscountPercent > 0 ? `
-${maxDiscountPercent}%
` : ''} + ${isDaily ? `
🔄 суточный
` : ''}
` : ''}
From bcef64bafac29dfef4bef178cdc32f55e38e7ec9 Mon Sep 17 00:00:00 2001 From: Egor Date: Mon, 12 Jan 2026 18:11:21 +0300 Subject: [PATCH 39/61] Update miniapp.py --- app/webapi/routes/miniapp.py | 69 +++++++++++++++++++++++++++++------- 1 file changed, 56 insertions(+), 13 deletions(-) diff --git a/app/webapi/routes/miniapp.py b/app/webapi/routes/miniapp.py index b19d8acd..9c45c773 100644 --- a/app/webapi/routes/miniapp.py +++ b/app/webapi/routes/miniapp.py @@ -6548,21 +6548,35 @@ async def purchase_tariff_endpoint( }, ) - # Получаем цену за выбранный период - base_price_kopeks = tariff.get_price_for_period(payload.period_days) - if base_price_kopeks is None: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail={ - "code": "invalid_period", - "message": "Invalid period for this tariff", - }, - ) + # Получаем цену + is_daily_tariff = getattr(tariff, 'is_daily', False) + if is_daily_tariff: + # Для суточного тарифа берём daily_price_kopeks (первый день) + base_price_kopeks = getattr(tariff, 'daily_price_kopeks', 0) + if base_price_kopeks <= 0: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail={ + "code": "invalid_daily_price", + "message": "Daily tariff has no price configured", + }, + ) + else: + # Для обычного тарифа получаем цену за выбранный период + base_price_kopeks = tariff.get_price_for_period(payload.period_days) + if base_price_kopeks is None: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail={ + "code": "invalid_period", + "message": "Invalid period for this tariff", + }, + ) - # Применяем скидку промогруппы + # Применяем скидку промогруппы (только для обычных тарифов, не для суточных) price_kopeks = base_price_kopeks discount_percent = 0 - if promo_group: + if not is_daily_tariff and promo_group: raw_discounts = getattr(promo_group, 'period_discounts', None) or {} for k, v in raw_discounts.items(): try: @@ -6589,7 +6603,9 @@ async def purchase_tariff_endpoint( subscription = getattr(user, "subscription", None) # Списываем баланс - if discount_percent > 0: + if is_daily_tariff: + description = f"Активация суточного тарифа '{tariff.name}' (первый день)" + elif discount_percent > 0: description = f"Покупка тарифа '{tariff.name}' на {payload.period_days} дней (скидка {discount_percent}%)" else: description = f"Покупка тарифа '{tariff.name}' на {payload.period_days} дней" @@ -6636,6 +6652,16 @@ async def purchase_tariff_endpoint( tariff_id=tariff.id, ) + # Инициализация daily полей при покупке суточного тарифа + is_daily_tariff = getattr(tariff, 'is_daily', False) + if is_daily_tariff: + subscription.is_daily_paused = False + subscription.last_daily_charge_at = datetime.utcnow() + # Для суточного тарифа end_date = сейчас + 1 день (первый день уже оплачен) + subscription.end_date = datetime.utcnow() + timedelta(days=1) + await db.commit() + await db.refresh(subscription) + # Синхронизируем с RemnaWave service = SubscriptionService() await service.update_remnawave_user(db, subscription) @@ -6923,6 +6949,23 @@ async def switch_tariff_endpoint( # Сбрасываем докупленный трафик при смене тарифа subscription.purchased_traffic_gb = 0 + # Обработка daily полей при смене тарифа + new_is_daily = getattr(new_tariff, 'is_daily', False) + old_is_daily = getattr(current_tariff, 'is_daily', False) + + if new_is_daily: + # Переход на суточный тариф + subscription.is_daily_paused = False + subscription.last_daily_charge_at = datetime.utcnow() + # Для суточного тарифа end_date = сейчас + 1 день + subscription.end_date = datetime.utcnow() + timedelta(days=1) + logger.info(f"🔄 Смена на суточный тариф: установлены daily поля, end_date={subscription.end_date}") + elif old_is_daily and not new_is_daily: + # Переход с суточного на обычный тариф - очищаем daily поля + subscription.is_daily_paused = False + subscription.last_daily_charge_at = None + logger.info(f"🔄 Смена с суточного на обычный тариф: очищены daily поля") + await db.commit() await db.refresh(subscription) await db.refresh(user) From 26e242cac72440ea0e15450bddd45416dd728281 Mon Sep 17 00:00:00 2001 From: Egor Date: Mon, 12 Jan 2026 18:12:14 +0300 Subject: [PATCH 40/61] Update subscription.py --- app/database/crud/subscription.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/app/database/crud/subscription.py b/app/database/crud/subscription.py index 793a998d..da1a56be 100644 --- a/app/database/crud/subscription.py +++ b/app/database/crud/subscription.py @@ -426,6 +426,24 @@ async def extend_subscription( subscription.connected_squads = connected_squads logger.info(f"🌍 Обновлены сквады: {old_squads} → {connected_squads}") + # Обработка daily полей при смене тарифа + if is_tariff_change and tariff_id is not None: + # Получаем информацию о новом тарифе для проверки is_daily + from app.database.crud.tariff import get_tariff_by_id + new_tariff = await get_tariff_by_id(db, tariff_id) + old_was_daily = getattr(subscription, 'is_daily_paused', False) or getattr(subscription, 'last_daily_charge_at', None) is not None + + if new_tariff and getattr(new_tariff, 'is_daily', False): + # Переход на суточный тариф - сбрасываем флаги + subscription.is_daily_paused = False + subscription.last_daily_charge_at = None # Будет установлено при первом списании + logger.info(f"🔄 Переход на суточный тариф: сброшены daily флаги") + elif old_was_daily: + # Переход с суточного на обычный тариф - очищаем daily поля + subscription.is_daily_paused = False + subscription.last_daily_charge_at = None + logger.info(f"🔄 Переход с суточного тарифа: очищены daily флаги") + # В режиме fixed_with_topup при продлении сбрасываем трафик до фиксированного лимита # Только если не передан traffic_limit_gb И у подписки нет тарифа (классический режим) # Если у подписки есть tariff_id - трафик определяется тарифом, не сбрасываем From df17f2be4a1b15b092eed090b12922805bb32a0a Mon Sep 17 00:00:00 2001 From: Egor Date: Mon, 12 Jan 2026 18:13:02 +0300 Subject: [PATCH 41/61] Update index.html --- miniapp/index.html | 65 ++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 57 insertions(+), 8 deletions(-) diff --git a/miniapp/index.html b/miniapp/index.html index 8fb9d4d8..4f03e7aa 100644 --- a/miniapp/index.html +++ b/miniapp/index.html @@ -21114,6 +21114,34 @@ return; } + // Для суточных тарифов показываем специальную информацию + const isDaily = selectedTariffData.is_daily ?? selectedTariffData.isDaily ?? false; + if (isDaily) { + const dailyPriceLabel = selectedTariffData.daily_price_label ?? selectedTariffData.dailyPriceLabel ?? ''; + section.classList.remove('hidden'); + list.innerHTML = ` +
+
+ 🔄 +
+
Суточная подписка
+
Ежедневное списание с баланса
+
+
+
+ Стоимость в день: + ${dailyPriceLabel} +
+
+ 💡 Списание происходит автоматически каждые 24 часа. Можно приостановить в любой момент. +
+
+ `; + summary?.classList.add('hidden'); + updateTariffButton(); + return; + } + const periods = selectedTariffData.periods || []; if (periods.length === 0) { section.classList.add('hidden'); @@ -21233,12 +21261,24 @@ const btn = document.getElementById('tariffsSelectBtn'); if (!btn) return; - if (selectedTariffData && selectedTariffPeriod) { + // Для суточных тарифов не требуем выбора периода + const isDaily = selectedTariffData?.is_daily ?? selectedTariffData?.isDaily ?? false; + + if (selectedTariffData && (selectedTariffPeriod || isDaily)) { btn.disabled = false; - const priceKopeks = selectedTariffPeriod.price_kopeks || selectedTariffPeriod.priceKopeks || - selectedTariffPeriod.final_price || selectedTariffPeriod.finalPrice || 0; - const priceLabel = formatPriceFromKopeks(priceKopeks, tariffsData?.currency || 'RUB'); - btn.textContent = `Купить за ${priceLabel}`; + let priceKopeks, priceLabel; + + if (isDaily) { + // Суточный тариф - используем daily_price + priceKopeks = selectedTariffData.daily_price_kopeks ?? selectedTariffData.dailyPriceKopeks ?? 0; + priceLabel = selectedTariffData.daily_price_label ?? selectedTariffData.dailyPriceLabel ?? formatPriceFromKopeks(priceKopeks, tariffsData?.currency || 'RUB'); + btn.textContent = `Активировать за ${priceLabel}`; + } else { + priceKopeks = selectedTariffPeriod.price_kopeks || selectedTariffPeriod.priceKopeks || + selectedTariffPeriod.final_price || selectedTariffPeriod.finalPrice || 0; + priceLabel = formatPriceFromKopeks(priceKopeks, tariffsData?.currency || 'RUB'); + btn.textContent = `Купить за ${priceLabel}`; + } } else { btn.disabled = true; btn.textContent = t('tariffs.select'); @@ -21246,7 +21286,10 @@ } async function purchaseTariff() { - if (!selectedTariffId || !selectedTariffPeriod) { + const isDaily = selectedTariffData?.is_daily ?? selectedTariffData?.isDaily ?? false; + + // Для суточных тарифов не требуем выбора периода + if (!selectedTariffId || (!selectedTariffPeriod && !isDaily)) { showPopup('Выберите тариф', 'Ошибка'); return; } @@ -21259,7 +21302,10 @@ try { const initData = tg.initData || ''; - const periodDays = selectedTariffPeriod.days || selectedTariffPeriod.period_days || selectedTariffPeriod.periodDays; + // Для суточных тарифов используем period_days=1 + const periodDays = isDaily + ? 1 + : (selectedTariffPeriod.days || selectedTariffPeriod.period_days || selectedTariffPeriod.periodDays); const response = await fetch('/miniapp/subscription/tariff/purchase', { method: 'POST', @@ -21277,7 +21323,10 @@ throw new Error(result?.detail?.message || result?.message || 'Ошибка покупки тарифа'); } - showPopup(result.message || 'Тариф успешно активирован!', 'Успех'); + const successMsg = isDaily + ? (result.message || 'Суточный тариф активирован!') + : (result.message || 'Тариф успешно активирован!'); + showPopup(successMsg, 'Успех'); await refreshSubscriptionData(); } catch (err) { console.error('Tariff purchase failed:', err); From db54b01f04e99664c7596c33b461e288326d072f Mon Sep 17 00:00:00 2001 From: Egor Date: Mon, 12 Jan 2026 18:16:59 +0300 Subject: [PATCH 42/61] Update subscription.py --- app/database/crud/subscription.py | 45 +++++++++++++++++-------------- 1 file changed, 25 insertions(+), 20 deletions(-) diff --git a/app/database/crud/subscription.py b/app/database/crud/subscription.py index da1a56be..c6992347 100644 --- a/app/database/crud/subscription.py +++ b/app/database/crud/subscription.py @@ -328,23 +328,10 @@ async def extend_subscription( if is_tariff_change: logger.info(f"🔄 Обнаружена СМЕНА тарифа: {subscription.tariff_id} → {tariff_id}") - # НОВОЕ: Вычисляем бонусные дни от триала ДО изменения end_date - # Бонусные дни НЕ начисляются при смене тарифа + # Бонусные дни от триала - добавляются ТОЛЬКО когда подписка истекла + # и мы начинаем отсчёт с текущей даты. НЕ начисляются при смене тарифа. + # Если подписка ещё активна - просто добавляем дни к существующей дате окончания. bonus_days = 0 - if not is_tariff_change and subscription.is_trial and settings.TRIAL_ADD_REMAINING_DAYS_TO_PAID: - # Вычисляем остаток триала - if subscription.end_date and subscription.end_date > current_time: - remaining = subscription.end_date - current_time - if remaining.total_seconds() > 0: - bonus_days = max(0, remaining.days) - logger.info( - "🎁 Обнаружен остаток триала: %s дней для подписки %s", - bonus_days, - subscription.id, - ) - - # Применяем продление с учетом бонусных дней - total_days = days + bonus_days if days < 0: subscription.end_date = subscription.end_date + timedelta(days=days) @@ -354,16 +341,34 @@ async def extend_subscription( subscription.end_date, ) elif is_tariff_change: - # При СМЕНЕ тарифа срок начинается с текущей даты + # При СМЕНЕ тарифа срок начинается с текущей даты + бонус от триала + if subscription.is_trial and settings.TRIAL_ADD_REMAINING_DAYS_TO_PAID: + if subscription.end_date and subscription.end_date > current_time: + remaining = subscription.end_date - current_time + if remaining.total_seconds() > 0: + bonus_days = max(0, remaining.days) + logger.info( + "🎁 Обнаружен остаток триала: %s дней для подписки %s", + bonus_days, + subscription.id, + ) + total_days = days + bonus_days subscription.end_date = current_time + timedelta(days=total_days) subscription.start_date = current_time logger.info(f"📅 СМЕНА тарифа: срок начинается с текущей даты + {total_days} дней") elif subscription.end_date > current_time: - subscription.end_date = subscription.end_date + timedelta(days=total_days) - logger.info(f"📅 Подписка активна, добавляем {total_days} дней ({days} + {bonus_days} бонус) к текущей дате окончания") + # Подписка активна - просто добавляем дни к текущей дате окончания + # БЕЗ бонусных дней (они уже учтены в end_date) + subscription.end_date = subscription.end_date + timedelta(days=days) + logger.info(f"📅 Подписка активна, добавляем {days} дней к текущей дате окончания") else: + # Подписка истекла - начинаем с текущей даты + бонус от триала + if subscription.is_trial and settings.TRIAL_ADD_REMAINING_DAYS_TO_PAID: + # Триал истёк, но бонус всё равно не добавляем (триал уже истёк) + pass + total_days = days + bonus_days subscription.end_date = current_time + timedelta(days=total_days) - logger.info(f"📅 Подписка истекла, устанавливаем новую дату окончания на {total_days} дней ({days} + {bonus_days} бонус)") + logger.info(f"📅 Подписка истекла, устанавливаем новую дату окончания на {total_days} дней") # УДАЛЕНО: Автоматическая конвертация триала по длительности # Теперь триал конвертируется ТОЛЬКО после успешного коммита продления From 25f2a9507d4c00f1cade99b7df245853cbd744ab Mon Sep 17 00:00:00 2001 From: Egor Date: Mon, 12 Jan 2026 18:26:00 +0300 Subject: [PATCH 43/61] Update index.html --- miniapp/index.html | 42 ++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 40 insertions(+), 2 deletions(-) diff --git a/miniapp/index.html b/miniapp/index.html index 4f03e7aa..0471801e 100644 --- a/miniapp/index.html +++ b/miniapp/index.html @@ -2749,6 +2749,16 @@ .daily-subscription-progress { margin-bottom: 12px; + transition: opacity 0.3s ease; + } + + .daily-subscription-progress.paused { + opacity: 0.5; + } + + .daily-subscription-progress.paused .daily-progress-fill { + animation: none; + background: var(--hint-color); } .daily-progress-bar { @@ -6635,6 +6645,7 @@ 'daily.pause': 'Pause', 'daily.resume': 'Resume', 'daily.paused_notice': 'Subscription paused. Daily billing disabled. VPN is not working.', + 'daily.paused_no_charge': 'Paused — no charge', 'daily.status.active': 'Active', 'daily.status.paused': 'Paused', 'daily.hours_remaining': 'Hours left', @@ -7108,6 +7119,7 @@ 'daily.pause': 'Приостановить', 'daily.resume': 'Возобновить', 'daily.paused_notice': 'Подписка приостановлена. Ежедневное списание отключено. VPN не работает.', + 'daily.paused_no_charge': 'Пауза — без списания', 'daily.status.active': 'Активна', 'daily.status.paused': 'Приостановлена', 'daily.hours_remaining': 'Часов осталось', @@ -9568,7 +9580,9 @@ if (pauseBtnIcon) pauseBtnIcon.textContent = '▶️'; if (pauseBtnText) pauseBtnText.textContent = t('daily.resume'); pausedNotice?.classList.remove('hidden'); - progressSection?.classList.add('hidden'); + // Оставляем прогресс секцию видимой, но затемняем + progressSection?.classList.remove('hidden'); + progressSection?.classList.add('paused'); // Обновляем статус badge const statusBadge = document.getElementById('statusBadge'); @@ -9582,6 +9596,7 @@ if (pauseBtnText) pauseBtnText.textContent = t('daily.pause'); pausedNotice?.classList.add('hidden'); progressSection?.classList.remove('hidden'); + progressSection?.classList.remove('paused'); } // Обработчик кнопки паузы @@ -9603,13 +9618,36 @@ const progressFill = document.getElementById('dailyProgressFill'); const nextChargeEl = document.getElementById('dailyNextCharge'); - if (!nextChargeAt || isPaused) { + if (!nextChargeAt) { if (timeRemainingEl) timeRemainingEl.textContent = '--:--:--'; if (progressFill) progressFill.style.width = '0%'; if (nextChargeEl) nextChargeEl.textContent = ''; return; } + // При паузе показываем оставшееся время, но без обновления (статичное) + if (isPaused) { + const nextChargeDate = new Date(nextChargeAt); + const now = new Date(); + const remaining = nextChargeDate - now; + + if (remaining > 0) { + const hours = Math.floor(remaining / (1000 * 60 * 60)); + const minutes = Math.floor((remaining % (1000 * 60 * 60)) / (1000 * 60)); + const seconds = Math.floor((remaining % (1000 * 60)) / 1000); + const timeStr = `${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`; + if (timeRemainingEl) timeRemainingEl.textContent = timeStr; + const totalDuration = 24 * 60 * 60 * 1000; + const progressPercent = Math.max(0, Math.min(100, (remaining / totalDuration) * 100)); + if (progressFill) progressFill.style.width = `${progressPercent}%`; + } else { + if (timeRemainingEl) timeRemainingEl.textContent = '00:00:00'; + if (progressFill) progressFill.style.width = '0%'; + } + if (nextChargeEl) nextChargeEl.textContent = t('daily.paused_no_charge'); + return; + } + const nextChargeDate = new Date(nextChargeAt); const totalDuration = 24 * 60 * 60 * 1000; // 24 часа в миллисекундах From e987b25e6b08cfff14dcde17c8c21b677e436c47 Mon Sep 17 00:00:00 2001 From: Egor Date: Mon, 12 Jan 2026 18:26:54 +0300 Subject: [PATCH 44/61] Add files via upload --- app/localization/locales/en.json | 4 +++- app/localization/locales/ru.json | 4 +++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/app/localization/locales/en.json b/app/localization/locales/en.json index 1d267f80..6d394742 100644 --- a/app/localization/locales/en.json +++ b/app/localization/locales/en.json @@ -1666,5 +1666,7 @@ "PAUSE_DAILY_BUTTON": "⏸️ Pause subscription", "RESUME_DAILY_BUTTON": "▶️ Resume subscription", - "DAILY_SWITCH_WARNING": "⚠️ Warning! You have {days} days left.\nThey will be lost when switching to daily tariff!" + "DAILY_SWITCH_WARNING": "⚠️ Warning! You have {days} days left.\nThey will be lost when switching to daily tariff!", + "DAILY_SUBSCRIPTION_PAUSED": "⏸️ Subscription paused", + "DAILY_SUBSCRIPTION_RESUMED": "▶️ Subscription resumed!" } diff --git a/app/localization/locales/ru.json b/app/localization/locales/ru.json index c8ddc9ac..e74c16c7 100644 --- a/app/localization/locales/ru.json +++ b/app/localization/locales/ru.json @@ -1683,5 +1683,7 @@ "PAUSE_DAILY_BUTTON": "⏸️ Приостановить подписку", "RESUME_DAILY_BUTTON": "▶️ Возобновить подписку", - "DAILY_SWITCH_WARNING": "⚠️ Внимание! У вас осталось {days} дн. подписки.\nПри смене на суточный тариф они будут утеряны!" + "DAILY_SWITCH_WARNING": "⚠️ Внимание! У вас осталось {days} дн. подписки.\nПри смене на суточный тариф они будут утеряны!", + "DAILY_SUBSCRIPTION_PAUSED": "⏸️ Подписка приостановлена", + "DAILY_SUBSCRIPTION_RESUMED": "▶️ Подписка возобновлена!" } From eb40d8f48adf237a68d21a7dd2652574b27aa986 Mon Sep 17 00:00:00 2001 From: Egor Date: Mon, 12 Jan 2026 18:43:54 +0300 Subject: [PATCH 45/61] Update miniapp.py --- app/webapi/routes/miniapp.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/webapi/routes/miniapp.py b/app/webapi/routes/miniapp.py index 9c45c773..8b378b0c 100644 --- a/app/webapi/routes/miniapp.py +++ b/app/webapi/routes/miniapp.py @@ -6551,6 +6551,8 @@ async def purchase_tariff_endpoint( # Получаем цену is_daily_tariff = getattr(tariff, 'is_daily', False) if is_daily_tariff: + # Для суточного тарифа принудительно 1 день (защита от манипуляций с period_days) + payload.period_days = 1 # Для суточного тарифа берём daily_price_kopeks (первый день) base_price_kopeks = getattr(tariff, 'daily_price_kopeks', 0) if base_price_kopeks <= 0: From 67f60ba41ace2e451911dde740adb4ccee0fef7f Mon Sep 17 00:00:00 2001 From: Egor Date: Mon, 12 Jan 2026 18:44:34 +0300 Subject: [PATCH 46/61] Update subscription.py --- app/database/crud/subscription.py | 25 +++++++++++++++++-------- 1 file changed, 17 insertions(+), 8 deletions(-) diff --git a/app/database/crud/subscription.py b/app/database/crud/subscription.py index c6992347..f881df9f 100644 --- a/app/database/crud/subscription.py +++ b/app/database/crud/subscription.py @@ -1486,9 +1486,9 @@ async def check_and_update_subscription_status( db: AsyncSession, subscription: Subscription ) -> Subscription: - + current_time = datetime.utcnow() - + logger.info( "🔍 Проверка статуса подписки %s, текущий статус: %s, дата окончания: %s, текущее время: %s", subscription.id, @@ -1496,20 +1496,29 @@ async def check_and_update_subscription_status( format_local_datetime(subscription.end_date), format_local_datetime(current_time), ) - - if (subscription.status == SubscriptionStatus.ACTIVE.value and + + # Для суточных тарифов с паузой не меняем статус на expired + # (время "заморожено" пока пользователь на паузе) + is_daily_paused = getattr(subscription, 'is_daily_paused', False) + if is_daily_paused: + logger.info( + f"⏸️ Суточная подписка {subscription.id} на паузе, пропускаем проверку истечения" + ) + return subscription + + if (subscription.status == SubscriptionStatus.ACTIVE.value and subscription.end_date <= current_time): - + subscription.status = SubscriptionStatus.EXPIRED.value subscription.updated_at = current_time - + await db.commit() await db.refresh(subscription) - + logger.info(f"⏰ Статус подписки пользователя {subscription.user_id} изменен на 'expired'") elif subscription.status == SubscriptionStatus.PENDING.value: logger.info(f"ℹ️ Проверка PENDING подписки {subscription.id}, статус остается без изменений") - + return subscription async def create_subscription_no_commit( From 40fad0d53750303822a63f471fd975b7cb33c90a Mon Sep 17 00:00:00 2001 From: Egor Date: Mon, 12 Jan 2026 18:56:24 +0300 Subject: [PATCH 47/61] Update subscription.py --- app/database/crud/subscription.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/app/database/crud/subscription.py b/app/database/crud/subscription.py index f881df9f..df18478b 100644 --- a/app/database/crud/subscription.py +++ b/app/database/crud/subscription.py @@ -1878,6 +1878,17 @@ async def resume_daily_subscription( return subscription subscription.is_daily_paused = False + + # Восстанавливаем статус ACTIVE если подписка была DISABLED (недостаток средств) + if subscription.status == SubscriptionStatus.DISABLED.value: + subscription.status = SubscriptionStatus.ACTIVE.value + # Обновляем время последнего списания для корректного расчёта следующего + subscription.last_daily_charge_at = datetime.utcnow() + subscription.end_date = datetime.utcnow() + timedelta(days=1) + logger.info( + f"✅ Суточная подписка {subscription.id} восстановлена из DISABLED в ACTIVE" + ) + await db.commit() await db.refresh(subscription) From fab17702c5fc4328b4672a679a28d65ea227d0d9 Mon Sep 17 00:00:00 2001 From: Egor Date: Mon, 12 Jan 2026 18:57:13 +0300 Subject: [PATCH 48/61] Update miniapp.py --- app/webapi/routes/miniapp.py | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/app/webapi/routes/miniapp.py b/app/webapi/routes/miniapp.py index 8b378b0c..8353a9b0 100644 --- a/app/webapi/routes/miniapp.py +++ b/app/webapi/routes/miniapp.py @@ -7222,7 +7222,7 @@ async def toggle_daily_subscription_pause_endpoint( new_paused_state = not is_currently_paused subscription.is_daily_paused = new_paused_state - # Если снимаем с паузы и подписка активна, нужно проверить баланс для активации + # Если снимаем с паузы, нужно проверить баланс для активации if not new_paused_state: daily_price = getattr(tariff, 'daily_price_kopeks', 0) if daily_price > 0 and user.balance_kopeks < daily_price: @@ -7236,6 +7236,17 @@ async def toggle_daily_subscription_pause_endpoint( }, ) + # Восстанавливаем статус ACTIVE если подписка была DISABLED (недостаток средств) + from app.database.models import SubscriptionStatus + if subscription.status == SubscriptionStatus.DISABLED.value: + subscription.status = SubscriptionStatus.ACTIVE.value + # Обновляем время последнего списания для корректного расчёта следующего + subscription.last_daily_charge_at = datetime.utcnow() + subscription.end_date = datetime.utcnow() + timedelta(days=1) + logger.info( + f"✅ Суточная подписка {subscription.id} восстановлена из DISABLED в ACTIVE" + ) + await db.commit() await db.refresh(subscription) await db.refresh(user) From 26f3d4e0528ffe11be7354788fb927b34e985c19 Mon Sep 17 00:00:00 2001 From: Egor Date: Mon, 12 Jan 2026 18:57:50 +0300 Subject: [PATCH 49/61] Update purchase.py --- app/handlers/subscription/purchase.py | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/app/handlers/subscription/purchase.py b/app/handlers/subscription/purchase.py index ac2c9122..488ee8c8 100644 --- a/app/handlers/subscription/purchase.py +++ b/app/handlers/subscription/purchase.py @@ -369,6 +369,17 @@ async def show_subscription_info( if is_paused: tariff_info_lines.append("") tariff_info_lines.append("⏸️ Подписка приостановлена") + # Показываем оставшееся время даже при паузе + if last_charge: + from datetime import timedelta + next_charge = last_charge + timedelta(hours=24) + now = datetime.utcnow() + if next_charge > now: + time_until = next_charge - now + hours_left = time_until.seconds // 3600 + minutes_left = (time_until.seconds % 3600) // 60 + tariff_info_lines.append(f"⏳ Осталось: {hours_left}ч {minutes_left}мин") + tariff_info_lines.append("💤 Списание приостановлено") elif last_charge: from datetime import timedelta next_charge = last_charge + timedelta(hours=24) @@ -3156,6 +3167,20 @@ async def handle_toggle_daily_subscription_pause( # Переключаем статус паузы was_paused = getattr(subscription, 'is_daily_paused', False) + + # При возобновлении проверяем баланс + if was_paused: + daily_price = getattr(tariff, 'daily_price_kopeks', 0) + if daily_price > 0 and db_user.balance_kopeks < daily_price: + await callback.answer( + texts.t( + "INSUFFICIENT_BALANCE_FOR_RESUME", + f"❌ Недостаточно средств для возобновления. Требуется: {settings.format_price(daily_price)}" + ), + show_alert=True + ) + return + subscription = await toggle_daily_subscription_pause(db, subscription) if was_paused: From 7692e85ef0138003bf9d6a9f708782ed157a6dfe Mon Sep 17 00:00:00 2001 From: Egor Date: Mon, 12 Jan 2026 19:17:16 +0300 Subject: [PATCH 50/61] Update index.html --- miniapp/index.html | 29 ++++++++++------------------- 1 file changed, 10 insertions(+), 19 deletions(-) diff --git a/miniapp/index.html b/miniapp/index.html index 0471801e..20176bbc 100644 --- a/miniapp/index.html +++ b/miniapp/index.html @@ -9627,7 +9627,9 @@ // При паузе показываем оставшееся время, но без обновления (статичное) if (isPaused) { - const nextChargeDate = new Date(nextChargeAt); + // Сервер возвращает UTC время, добавляем 'Z' если отсутствует + const utcDateStr = String(nextChargeAt).endsWith('Z') ? nextChargeAt : nextChargeAt + 'Z'; + const nextChargeDate = new Date(utcDateStr); const now = new Date(); const remaining = nextChargeDate - now; @@ -9648,7 +9650,9 @@ return; } - const nextChargeDate = new Date(nextChargeAt); + // Сервер возвращает UTC время, добавляем 'Z' если отсутствует + const utcDateStr = String(nextChargeAt).endsWith('Z') ? nextChargeAt : nextChargeAt + 'Z'; + const nextChargeDate = new Date(utcDateStr); const totalDuration = 24 * 60 * 60 * 1000; // 24 часа в миллисекундах function updateTimer() { @@ -9676,7 +9680,7 @@ // Время следующего списания if (nextChargeEl) { - const chargeTime = nextChargeDate.toLocaleTimeString(currentLanguage === 'ru' ? 'ru-RU' : 'en-US', { + const chargeTime = nextChargeDate.toLocaleTimeString(preferredLanguage === 'ru' ? 'ru-RU' : 'en-US', { hour: '2-digit', minute: '2-digit' }); @@ -9714,24 +9718,11 @@ return; } - // Обновляем локальные данные - if (userData?.user) { - userData.user.is_daily_paused = result.is_paused; - userData.user.isDailyPaused = result.is_paused; - } - - // Обновляем баланс - if (result.balance_kopeks !== undefined) { - userData.balance_kopeks = result.balance_kopeks; - userData.balanceKopeks = result.balance_kopeks; - renderBalanceSection(); - } - - // Перерисовываем статус - renderDailySubscriptionStatus(); - showToast(result.message || 'OK', 'success'); + // Полное обновление данных подписки для корректного отображения + await refreshSubscriptionData({ silent: true }); + } catch (error) { console.error('Error toggling daily pause:', error); showToast('Connection error', 'error'); From c42444429e47aee5edb88603f3e728cf17498259 Mon Sep 17 00:00:00 2001 From: Egor Date: Mon, 12 Jan 2026 19:22:18 +0300 Subject: [PATCH 51/61] Update index.html --- miniapp/index.html | 34 +++++++++++++++++++++------------- 1 file changed, 21 insertions(+), 13 deletions(-) diff --git a/miniapp/index.html b/miniapp/index.html index 20176bbc..fc312d87 100644 --- a/miniapp/index.html +++ b/miniapp/index.html @@ -9699,8 +9699,8 @@ pauseBtn.disabled = true; try { - const initData = window.Telegram?.WebApp?.initData || ''; - const response = await fetch(`${apiBaseUrl}/miniapp/subscription/daily/toggle-pause`, { + const initData = tg.initData || ''; + const response = await fetch('/miniapp/subscription/daily/toggle-pause', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ init_data: initData }) @@ -9710,24 +9710,23 @@ if (!response.ok) { const errorCode = result?.detail?.code || 'unknown'; - if (errorCode === 'insufficient_balance') { - showToast(t('daily.error.insufficient_balance') || 'Недостаточно средств для возобновления подписки', 'error'); - } else { - showToast(result?.detail?.message || 'Error', 'error'); - } + const errorMsg = errorCode === 'insufficient_balance' + ? (t('daily.error.insufficient_balance') || 'Недостаточно средств для возобновления подписки') + : (result?.detail?.message || 'Error'); + showPopup(errorMsg); return; } - showToast(result.message || 'OK', 'success'); - // Полное обновление данных подписки для корректного отображения await refreshSubscriptionData({ silent: true }); } catch (error) { console.error('Error toggling daily pause:', error); - showToast('Connection error', 'error'); + showPopup(error.message || 'Connection error'); } finally { - pauseBtn.disabled = false; + if (pauseBtn) { + pauseBtn.disabled = false; + } } } @@ -15152,6 +15151,13 @@ return typeRaw === 'paid'; } + function isDailyTariffSubscription() { + if (!userData?.user) { + return false; + } + return Boolean(userData.user.is_daily_tariff ?? userData.user.isDailyTariff ?? false); + } + function isTrialSubscription() { if (!userData?.user) { return false; @@ -16364,8 +16370,10 @@ return; } - // Для триала НЕ показываем карточку продления — юзер должен купить подписку - const shouldShow = hasActiveSubscription() && !isTrialSubscription(); + // Для триала и суточных тарифов НЕ показываем карточку продления + // Триал — юзер должен купить подписку + // Суточный тариф — продление автоматическое + const shouldShow = hasActiveSubscription() && !isTrialSubscription() && !isDailyTariffSubscription(); card.classList.toggle('hidden', !shouldShow); if (!shouldShow) { return; From f59614f14f49761c5cffe107c1e4731d0775d52a Mon Sep 17 00:00:00 2001 From: Egor Date: Mon, 12 Jan 2026 19:24:53 +0300 Subject: [PATCH 52/61] Update index.html --- miniapp/index.html | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/miniapp/index.html b/miniapp/index.html index fc312d87..3e163253 100644 --- a/miniapp/index.html +++ b/miniapp/index.html @@ -5770,7 +5770,7 @@
@@ -6644,7 +6644,7 @@ 'daily.next_charge': 'Next charge at', 'daily.pause': 'Pause', 'daily.resume': 'Resume', - 'daily.paused_notice': 'Subscription paused. Daily billing disabled. VPN is not working.', + 'daily.paused_notice': 'Subscription paused. Auto-renewal disabled. VPN works until the end of paid time.', 'daily.paused_no_charge': 'Paused — no charge', 'daily.status.active': 'Active', 'daily.status.paused': 'Paused', @@ -7118,7 +7118,7 @@ 'daily.next_charge': 'Следующее списание', 'daily.pause': 'Приостановить', 'daily.resume': 'Возобновить', - 'daily.paused_notice': 'Подписка приостановлена. Ежедневное списание отключено. VPN не работает.', + 'daily.paused_notice': 'Подписка приостановлена. Автопродление отключено. VPN работает до конца оплаченного времени.', 'daily.paused_no_charge': 'Пауза — без списания', 'daily.status.active': 'Активна', 'daily.status.paused': 'Приостановлена', From e4d54677fabb3e2117ae892beb19d3b081601053 Mon Sep 17 00:00:00 2001 From: Egor Date: Mon, 12 Jan 2026 19:25:25 +0300 Subject: [PATCH 53/61] Update miniapp.py --- app/webapi/routes/miniapp.py | 22 ++++++++++------------ 1 file changed, 10 insertions(+), 12 deletions(-) diff --git a/app/webapi/routes/miniapp.py b/app/webapi/routes/miniapp.py index 8353a9b0..bdaa9ede 100644 --- a/app/webapi/routes/miniapp.py +++ b/app/webapi/routes/miniapp.py @@ -3432,8 +3432,8 @@ async def get_subscription_details( daily_tariff_name = tariff.name daily_price_kopeks = getattr(tariff, 'daily_price_kopeks', 0) daily_price_label = settings.format_price(daily_price_kopeks) + "/день" if daily_price_kopeks > 0 else None - # Следующее списание - через 24 часа от последнего обновления подписки или от start_date - if subscription.end_date and not is_daily_paused: + # Оставшееся время подписки (показываем даже при паузе) + if subscription.end_date: daily_next_charge_at = subscription.end_date response_user = MiniAppSubscriptionUser( @@ -7252,18 +7252,16 @@ async def toggle_daily_subscription_pause_endpoint( await db.refresh(user) # Синхронизация с RemnaWave - try: - service = SubscriptionService() - if new_paused_state: - # При паузе отключаем пользователя в RemnaWave - if user.remnawave_uuid: - await service.disable_remnawave_user(user.remnawave_uuid) - else: - # При возобновлении включаем пользователя в RemnaWave + # При паузе VPN продолжает работать до конца оплаченного времени, + # поэтому НЕ отключаем пользователя в RemnaWave + # При возобновлении включаем если был отключен (например, из-за истечения срока) + if not new_paused_state: + try: + service = SubscriptionService() if user.remnawave_uuid: await service.enable_remnawave_user(user.remnawave_uuid) - except Exception as e: - logger.error(f"Ошибка синхронизации с RemnaWave при паузе/возобновлении: {e}") + except Exception as e: + logger.error(f"Ошибка синхронизации с RemnaWave при возобновлении: {e}") lang = getattr(user, "language", settings.DEFAULT_LANGUAGE) if new_paused_state: From 40f1312d0b77f36d621fa6c795a9f0e61ef68308 Mon Sep 17 00:00:00 2001 From: Egor Date: Mon, 12 Jan 2026 19:29:03 +0300 Subject: [PATCH 54/61] Update backup_service.py --- app/services/backup_service.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/app/services/backup_service.py b/app/services/backup_service.py index 14ff3b2e..8d638442 100644 --- a/app/services/backup_service.py +++ b/app/services/backup_service.py @@ -27,7 +27,7 @@ from app.database.models import ( ServerSquad, SubscriptionServer, UserMessage, YooKassaPayment, CryptoBotPayment, WelcomeText, Base, PromoGroup, AdvertisingCampaign, AdvertisingCampaignRegistration, SupportAuditLog, Ticket, TicketMessage, - MulenPayPayment, Pal24Payment, DiscountOffer, WebApiToken, + MulenPayPayment, Pal24Payment, DiscountOffer, WebApiToken, Tariff, server_squad_promo_groups ) @@ -75,6 +75,7 @@ class BackupService: Squad, ServerSquad, PromoGroup, + Tariff, # Tariff должен быть ДО Subscription из-за FK User, PromoCode, WelcomeText, @@ -825,7 +826,7 @@ class BackupService: model.__tablename__: model for model in models_for_restore } - pre_restore_tables = {"promo_groups"} + pre_restore_tables = {"promo_groups", "tariffs"} for table_name in pre_restore_tables: model = models_by_table.get(table_name) if not model: From cec597cfd92deb9cc4d53b83918efe76d57b9315 Mon Sep 17 00:00:00 2001 From: Egor Date: Mon, 12 Jan 2026 19:34:04 +0300 Subject: [PATCH 55/61] Update miniapp.py --- app/webapi/routes/miniapp.py | 24 +++++++++++++++++++++--- 1 file changed, 21 insertions(+), 3 deletions(-) diff --git a/app/webapi/routes/miniapp.py b/app/webapi/routes/miniapp.py index bdaa9ede..819bc347 100644 --- a/app/webapi/routes/miniapp.py +++ b/app/webapi/routes/miniapp.py @@ -6630,6 +6630,15 @@ async def purchase_tariff_endpoint( description=description, ) + # Получаем список серверов из тарифа + squads = tariff.allowed_squads or [] + + # Если allowed_squads пустой - значит "все серверы", получаем их + if not squads: + from app.database.crud.server_squad import get_all_server_squads + all_servers, _ = await get_all_server_squads(db, available_only=True) + squads = [s.squad_uuid for s in all_servers if s.squad_uuid] + if subscription: # Смена/продление тарифа subscription = await extend_subscription( @@ -6639,7 +6648,7 @@ async def purchase_tariff_endpoint( tariff_id=tariff.id, traffic_limit_gb=tariff.traffic_limit_gb, device_limit=tariff.device_limit, - connected_squads=tariff.allowed_squads or [], + connected_squads=squads, ) else: # Создание новой подписки @@ -6650,7 +6659,7 @@ async def purchase_tariff_endpoint( duration_days=payload.period_days, traffic_limit_gb=tariff.traffic_limit_gb, device_limit=tariff.device_limit, - connected_squads=tariff.allowed_squads or [], + connected_squads=squads, tariff_id=tariff.id, ) @@ -6943,11 +6952,20 @@ async def switch_tariff_endpoint( description=description, ) + # Получаем список серверов из тарифа + squads = new_tariff.allowed_squads or [] + + # Если allowed_squads пустой - значит "все серверы", получаем их + if not squads: + from app.database.crud.server_squad import get_all_server_squads + all_servers, _ = await get_all_server_squads(db, available_only=True) + squads = [s.squad_uuid for s in all_servers if s.squad_uuid] + # Обновляем подписку - меняем тариф без изменения даты subscription.tariff_id = new_tariff.id subscription.traffic_limit_gb = new_tariff.traffic_limit_gb subscription.device_limit = new_tariff.device_limit - subscription.connected_squads = new_tariff.allowed_squads or [] + subscription.connected_squads = squads # Сбрасываем докупленный трафик при смене тарифа subscription.purchased_traffic_gb = 0 From fe9feaf65d33d0b9e204db3102c7398585c63cf5 Mon Sep 17 00:00:00 2001 From: Egor Date: Mon, 12 Jan 2026 19:43:38 +0300 Subject: [PATCH 56/61] Update miniapp.py --- app/webapi/routes/miniapp.py | 86 +++++++++++++++++++++++++++++------- 1 file changed, 70 insertions(+), 16 deletions(-) diff --git a/app/webapi/routes/miniapp.py b/app/webapi/routes/miniapp.py index 819bc347..064ebda0 100644 --- a/app/webapi/routes/miniapp.py +++ b/app/webapi/routes/miniapp.py @@ -6359,14 +6359,30 @@ async def _build_tariff_model( is_upgrade = None is_switch_free = None - if current_tariff and current_tariff.id != tariff.id and remaining_days > 0: - cost, upgrade = _calculate_tariff_switch_cost( - current_tariff, tariff, remaining_days, promo_group, user - ) - switch_cost_kopeks = cost - switch_cost_label = settings.format_price(cost) if cost > 0 else None - is_upgrade = upgrade - is_switch_free = cost == 0 + if current_tariff and current_tariff.id != tariff.id: + current_is_daily = getattr(current_tariff, 'is_daily', False) + new_is_daily = getattr(tariff, 'is_daily', False) + + if current_is_daily and not new_is_daily: + # Переключение С суточного НА периодный - полная оплата нового тарифа + # Берём минимальную цену из периодов нового тарифа + min_period_price = None + if periods: + min_period_price = min(p.price_kopeks for p in periods) + if min_period_price and min_period_price > 0: + switch_cost_kopeks = min_period_price + switch_cost_label = settings.format_price(min_period_price) + is_upgrade = True # Показываем как платный переход + is_switch_free = False + elif remaining_days > 0: + # Обычный расчёт для периодных тарифов + cost, upgrade = _calculate_tariff_switch_cost( + current_tariff, tariff, remaining_days, promo_group, user + ) + switch_cost_kopeks = cost + switch_cost_label = settings.format_price(cost) if cost > 0 else None + is_upgrade = upgrade + is_switch_free = cost == 0 # Суточный тариф is_daily = getattr(tariff, 'is_daily', False) @@ -6833,9 +6849,21 @@ async def preview_tariff_switch_endpoint( remaining_days = max(0, delta.days) # Рассчитываем стоимость переключения - upgrade_cost, is_upgrade = _calculate_tariff_switch_cost( - current_tariff, new_tariff, remaining_days, promo_group, user - ) + current_is_daily = getattr(current_tariff, 'is_daily', False) if current_tariff else False + new_is_daily = getattr(new_tariff, 'is_daily', False) + + if current_is_daily and not new_is_daily: + # Переключение С суточного НА периодный - полная оплата нового тарифа + # Берём минимальную цену из периодов нового тарифа + min_period_price = 0 + if new_tariff.period_prices: + min_period_price = min(new_tariff.period_prices.values()) + upgrade_cost = min_period_price + is_upgrade = min_period_price > 0 + else: + upgrade_cost, is_upgrade = _calculate_tariff_switch_cost( + current_tariff, new_tariff, remaining_days, promo_group, user + ) balance = user.balance_kopeks or 0 has_enough = balance >= upgrade_cost @@ -6918,9 +6946,27 @@ async def switch_tariff_endpoint( remaining_days = max(0, delta.days) # Рассчитываем стоимость - upgrade_cost, is_upgrade = _calculate_tariff_switch_cost( - current_tariff, new_tariff, remaining_days, promo_group, user - ) + current_is_daily = getattr(current_tariff, 'is_daily', False) if current_tariff else False + new_is_daily = getattr(new_tariff, 'is_daily', False) + switching_from_daily = current_is_daily and not new_is_daily + + if switching_from_daily: + # Переключение С суточного НА периодный - полная оплата нового тарифа (минимальный период) + min_period_days = 30 # По умолчанию месяц + min_period_price = 0 + if new_tariff.period_prices: + # Находим минимальный период и его цену + min_period_days = min(int(k) for k in new_tariff.period_prices.keys()) + min_period_price = new_tariff.period_prices.get(str(min_period_days), 0) + upgrade_cost = min_period_price + is_upgrade = min_period_price > 0 + # remaining_days для нового тарифа будет равен min_period_days после покупки + new_period_days = min_period_days + else: + upgrade_cost, is_upgrade = _calculate_tariff_switch_cost( + current_tariff, new_tariff, remaining_days, promo_group, user + ) + new_period_days = 0 # Не меняем дату окончания # Списываем доплату если апгрейд if upgrade_cost > 0: @@ -6935,7 +6981,10 @@ async def switch_tariff_endpoint( }, ) - description = f"Переход на тариф '{new_tariff.name}' (доплата за {remaining_days} дней)" + if switching_from_daily: + description = f"Переход с суточного на тариф '{new_tariff.name}' ({new_period_days} дней)" + else: + description = f"Переход на тариф '{new_tariff.name}' (доплата за {remaining_days} дней)" success = await subtract_user_balance(db, user, upgrade_cost, description) if not success: raise HTTPException( @@ -6984,7 +7033,12 @@ async def switch_tariff_endpoint( # Переход с суточного на обычный тариф - очищаем daily поля subscription.is_daily_paused = False subscription.last_daily_charge_at = None - logger.info(f"🔄 Смена с суточного на обычный тариф: очищены daily поля") + # Устанавливаем дату окончания для периодного тарифа + if new_period_days > 0: + subscription.end_date = datetime.utcnow() + timedelta(days=new_period_days) + logger.info(f"🔄 Смена с суточного на периодный тариф: end_date={subscription.end_date} ({new_period_days} дней)") + else: + logger.info(f"🔄 Смена с суточного на обычный тариф: очищены daily поля") await db.commit() await db.refresh(subscription) From a2380b937ca7a5b77b5b4f43d8c5a117c5f65556 Mon Sep 17 00:00:00 2001 From: Egor Date: Mon, 12 Jan 2026 19:44:07 +0300 Subject: [PATCH 57/61] Update index.html --- miniapp/index.html | 160 ++++++++++++++++++++++++++++++++++----------- 1 file changed, 122 insertions(+), 38 deletions(-) diff --git a/miniapp/index.html b/miniapp/index.html index 3e163253..000284bc 100644 --- a/miniapp/index.html +++ b/miniapp/index.html @@ -2852,21 +2852,28 @@ } .daily-subscription-paused-notice { - margin-top: 12px; - padding: 12px; - background: rgba(var(--warning-rgb), 0.1); - border-radius: var(--radius); - border: 1px solid rgba(var(--warning-rgb), 0.2); + margin: 16px; + padding: 14px 16px; + background: linear-gradient(135deg, rgba(var(--warning-rgb), 0.12), rgba(var(--warning-rgb), 0.06)); + border-radius: 12px; + border: 1px solid rgba(var(--warning-rgb), 0.25); font-size: 13px; + line-height: 1.5; color: var(--text-primary); display: flex; align-items: flex-start; - gap: 10px; + gap: 12px; + box-shadow: 0 2px 8px rgba(var(--warning-rgb), 0.08); } .daily-subscription-paused-notice .notice-icon { - font-size: 18px; + font-size: 20px; line-height: 1; + flex-shrink: 0; + } + + .daily-subscription-paused-notice span:last-child { + flex: 1; } :root[data-theme="dark"] .daily-subscription-status { @@ -8515,6 +8522,19 @@ return div.innerHTML; } + function getDaysWord(count, lang = 'ru') { + if (lang === 'en') { + return count === 1 ? 'day' : 'days'; + } + // Russian: 1 день, 2-4 дня, 5-20 дней, 21 день, 22-24 дня, etc. + const n = Math.abs(count) % 100; + const n1 = n % 10; + if (n > 10 && n < 20) return 'дней'; + if (n1 === 1) return 'день'; + if (n1 >= 2 && n1 <= 4) return 'дня'; + return 'дней'; + } + function normalizeUrl(value) { if (typeof value !== 'string') { return null; @@ -20670,16 +20690,32 @@ serverTags = `🌍 Все серверы`; } - // В режиме смены тарифа показываем остаток дней - const remainingDaysText = preferredLanguage === 'en' - ? `${daysLeft} days remaining — will be preserved on switch` - : `Осталось ${daysLeft} дн. — сохранятся при смене`; - const remainingInfo = isInstantSwitchMode - ? `
- - ${remainingDaysText} -
` - : ''; + // Проверяем, суточный ли текущий тариф + const isCurrentDaily = currentTariff.is_daily ?? currentTariff.isDaily ?? false; + + // В режиме смены тарифа показываем остаток + let remainingInfo = ''; + if (isInstantSwitchMode) { + let remainingText = ''; + if (isCurrentDaily) { + // Для суточного тарифа показываем другой текст + remainingText = preferredLanguage === 'en' + ? 'Daily subscription — time is not transferred' + : 'Суточная подписка — время не переносится'; + } else if (daysLeft > 0) { + // Для обычных тарифов показываем дни с правильным склонением + const daysWord = getDaysWord(daysLeft, preferredLanguage); + remainingText = preferredLanguage === 'en' + ? `${daysLeft} ${daysWord} remaining — will be preserved on switch` + : `Осталось ${daysLeft} ${daysWord} — сохранятся при смене`; + } + if (remainingText) { + remainingInfo = `
+ ${isCurrentDaily ? '🔄' : '⏰'} + ${remainingText} +
`; + } + } currentName.innerHTML = `
@@ -20767,9 +20803,20 @@ ${preferredLanguage === 'en' ? 'Instant tariff switch' : 'Мгновенная смена тарифа'}
- ${preferredLanguage === 'en' - ? '⬆️ Upgrade = pay the difference • ⬇️ Downgrade = free
Your remaining ' + daysLeft + ' days will be preserved' - : '⬆️ Повышение = доплата • ⬇️ Понижение = бесплатно
Ваши ' + daysLeft + ' дней сохраняются'} + ${(() => { + const isCurrentDailyTariff = currentTariff?.is_daily ?? currentTariff?.isDaily ?? false; + if (isCurrentDailyTariff) { + // Переключение с суточного тарифа - оплачивается новый тариф + return preferredLanguage === 'en' + ? 'Switching from daily tariff requires full payment for the new tariff' + : 'Переключение с суточного тарифа — оплата нового тарифа полностью'; + } else { + const daysWord = getDaysWord(daysLeft, preferredLanguage); + return preferredLanguage === 'en' + ? '⬆️ Upgrade = pay the difference • ⬇️ Downgrade = free
Your remaining ' + daysLeft + ' ' + daysWord + ' will be preserved' + : '⬆️ Повышение = доплата • ⬇️ Понижение = бесплатно
Ваши ' + daysLeft + ' ' + daysWord + ' сохраняются'; + } + })()}
@@ -20856,22 +20903,48 @@ ${serverTags ? `
${serverTags}
` : ''}
- ${isDaily ? ` - - ${dailyLabel} - ${dailyPriceLabel || ''} - - ` : ` - - ${isUpgrade - ? `${upgradeLabel}+${upgradeCostLabel}` - : freeLabel} - - `} + ${(() => { + const currentIsDaily = currentTariff?.is_daily ?? currentTariff?.isDaily ?? false; + const switchingFromDaily = currentIsDaily && !isDaily; + + if (isDaily) { + // Переключение на суточный тариф + return ` + + ${dailyLabel} + ${dailyPriceLabel || ''} + + `; + } else if (switchingFromDaily) { + // Переключение С суточного НА периодный - показываем "от X₽" + const fromLabel = preferredLanguage === 'en' ? 'from' : 'от'; + return ` + + ${fromLabel} + ${upgradeCostLabel} + + `; + } else if (isUpgrade) { + // Повышение тарифа - доплата + return ` + + ${upgradeLabel} + +${upgradeCostLabel} + + `; + } else { + // Понижение тарифа - бесплатно + return ` + + ${freeLabel} + + `; + } + })()}
`; - div.addEventListener('click', () => showInstantSwitchConfirm(tariff, isUpgrade, upgradeCost)); + div.addEventListener('click', () => showInstantSwitchConfirm(tariff, isUpgrade, upgradeCost, currentTariff?.is_daily ?? currentTariff?.isDaily ?? false)); } else { // Обычный режим покупки const isDaily = tariff.is_daily ?? tariff.isDaily ?? false; @@ -20999,8 +21072,9 @@ } // Показать подтверждение мгновенной смены тарифа - async function showInstantSwitchConfirm(tariff, isUpgrade, estimatedCost) { + async function showInstantSwitchConfirm(tariff, isUpgrade, estimatedCost, currentIsDaily = false) { const currentTariff = tariffsData?.current_tariff || tariffsData?.currentTariff; + const switchingFromDaily = currentIsDaily && !(tariff.is_daily ?? tariff.isDaily ?? false); // Запрашиваем точную стоимость с сервера try { @@ -21053,6 +21127,9 @@ confirmUpgrade: preferredLanguage === 'en' ? `Switch to "${tariff.name}"?\n\nUpgrade cost: ${costLabel}\nYour balance: ${balanceLabel}` : `Сменить тариф на «${tariff.name}»?\n\nДоплата: ${costLabel}\nВаш баланс: ${balanceLabel}`, + confirmFromDaily: preferredLanguage === 'en' + ? `Switch to "${tariff.name}"?\n\nCost: ${costLabel}\n(Daily subscription time is not transferred)\nYour balance: ${balanceLabel}` + : `Сменить тариф на «${tariff.name}»?\n\nСтоимость: ${costLabel}\n(Время суточной подписки не переносится)\nВаш баланс: ${balanceLabel}`, confirmDowngrade: preferredLanguage === 'en' ? `Switch to "${tariff.name}"?\n\nFree (downgrade)` : `Сменить тариф на «${tariff.name}»?\n\nБесплатно (понижение тарифа)`, @@ -21063,9 +21140,18 @@ }; // Используем кастомный попап или showConfirm - if (typeof tg.showConfirm === 'function' && hasEnough) { - const message = actualIsUpgrade ? txt.confirmUpgrade : txt.confirmDowngrade; + // Выбираем нужное сообщение в зависимости от типа перехода + let message; + if (switchingFromDaily) { + // Переход с суточного на периодный - особый текст + message = txt.confirmFromDaily; + } else if (actualIsUpgrade) { + message = txt.confirmUpgrade; + } else { + message = txt.confirmDowngrade; + } + if (typeof tg.showConfirm === 'function' && hasEnough) { tg.showConfirm(message, async (confirmed) => { if (confirmed) { await executeInstantSwitch(tariff); @@ -21073,8 +21159,6 @@ }); } else if (hasEnough) { // Fallback на простой confirm - const message = actualIsUpgrade ? txt.confirmUpgrade : txt.confirmDowngrade; - if (confirm(message)) { await executeInstantSwitch(tariff); } From 4e3b6a081c391b38137da77576bcefb985060488 Mon Sep 17 00:00:00 2001 From: Egor Date: Mon, 12 Jan 2026 19:48:17 +0300 Subject: [PATCH 58/61] Update index.html --- miniapp/index.html | 25 ++++++++++++++++++++----- 1 file changed, 20 insertions(+), 5 deletions(-) diff --git a/miniapp/index.html b/miniapp/index.html index 000284bc..a95803cb 100644 --- a/miniapp/index.html +++ b/miniapp/index.html @@ -20916,12 +20916,19 @@ `; } else if (switchingFromDaily) { - // Переключение С суточного НА периодный - показываем "от X₽" + // Переключение С суточного НА периодный - показываем "от X₽ / N дн" const fromLabel = preferredLanguage === 'en' ? 'from' : 'от'; + // Находим минимальный период тарифа + const tariffPeriods = tariff.periods || []; + let minPeriodDays = 30; + if (tariffPeriods.length > 0) { + minPeriodDays = Math.min(...tariffPeriods.map(p => p.days || 30)); + } + const periodSuffix = preferredLanguage === 'en' ? ` / ${minPeriodDays}d` : ` / ${minPeriodDays} дн`; return ` ${fromLabel} - ${upgradeCostLabel} + ${upgradeCostLabel}${periodSuffix} `; } else if (isUpgrade) { @@ -21127,9 +21134,17 @@ confirmUpgrade: preferredLanguage === 'en' ? `Switch to "${tariff.name}"?\n\nUpgrade cost: ${costLabel}\nYour balance: ${balanceLabel}` : `Сменить тариф на «${tariff.name}»?\n\nДоплата: ${costLabel}\nВаш баланс: ${balanceLabel}`, - confirmFromDaily: preferredLanguage === 'en' - ? `Switch to "${tariff.name}"?\n\nCost: ${costLabel}\n(Daily subscription time is not transferred)\nYour balance: ${balanceLabel}` - : `Сменить тариф на «${tariff.name}»?\n\nСтоимость: ${costLabel}\n(Время суточной подписки не переносится)\nВаш баланс: ${balanceLabel}`, + confirmFromDaily: (() => { + // Получаем минимальный период для отображения + const tariffPeriods = tariff.periods || []; + let minPeriodDays = 30; + if (tariffPeriods.length > 0) { + minPeriodDays = Math.min(...tariffPeriods.map(p => p.days || 30)); + } + return preferredLanguage === 'en' + ? `Switch to "${tariff.name}"?\n\nCost: ${costLabel} for ${minPeriodDays} days\n(Daily subscription time is not transferred)\nYour balance: ${balanceLabel}` + : `Сменить тариф на «${tariff.name}»?\n\nСтоимость: ${costLabel} за ${minPeriodDays} дн.\n(Время суточной подписки не переносится)\nВаш баланс: ${balanceLabel}`; + })(), confirmDowngrade: preferredLanguage === 'en' ? `Switch to "${tariff.name}"?\n\nFree (downgrade)` : `Сменить тариф на «${tariff.name}»?\n\nБесплатно (понижение тарифа)`, From 43548fdb2777fd0798092adc4a534fc8d5a91d41 Mon Sep 17 00:00:00 2001 From: Egor Date: Mon, 12 Jan 2026 20:00:28 +0300 Subject: [PATCH 59/61] Update index.html --- miniapp/index.html | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/miniapp/index.html b/miniapp/index.html index a95803cb..c81c60d7 100644 --- a/miniapp/index.html +++ b/miniapp/index.html @@ -20653,10 +20653,13 @@ userInfo?.subscriptionStatus || '' ).toLowerCase(); - const hasActiveSubscription = actualStatus === 'active' || actualStatus === 'trial' || - userInfo?.has_active_subscription || userInfo?.hasActiveSubscription; + // Триал НЕ считается активной подпиской для режима смены тарифа - триалу нужно КУПИТЬ + const hasActiveSubscription = actualStatus === 'active' || + (userInfo?.has_active_subscription || userInfo?.hasActiveSubscription); + const isTrial = actualStatus === 'trial'; const currentTariff = tariffsData?.current_tariff || tariffsData?.currentTariff; - isInstantSwitchMode = hasActiveSubscription && currentTariff; + // Режим смены тарифа только для активных (не триал) подписок + isInstantSwitchMode = hasActiveSubscription && !isTrial && currentTariff; // Вычисляем оставшиеся дни из expires_at let daysLeft = 0; From e9a9b8579108b89ab3aa5fb2676e5dcf2579bbb1 Mon Sep 17 00:00:00 2001 From: Egor Date: Mon, 12 Jan 2026 20:03:47 +0300 Subject: [PATCH 60/61] Update index.html --- miniapp/index.html | 21 +++++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/miniapp/index.html b/miniapp/index.html index c81c60d7..ce920cf4 100644 --- a/miniapp/index.html +++ b/miniapp/index.html @@ -20653,13 +20653,22 @@ userInfo?.subscriptionStatus || '' ).toLowerCase(); - // Триал НЕ считается активной подпиской для режима смены тарифа - триалу нужно КУПИТЬ - const hasActiveSubscription = actualStatus === 'active' || - (userInfo?.has_active_subscription || userInfo?.hasActiveSubscription); - const isTrial = actualStatus === 'trial'; + + // Проверяем триал по всем возможным полям + const isTrial = actualStatus === 'trial' || + userInfo?.is_trial || + userInfo?.isTrial || + (userInfo?.subscription_type || userInfo?.subscriptionType || '').toLowerCase() === 'trial'; + + console.log('🔍 Status check:', { actualStatus, isTrial, userInfo }); + const currentTariff = tariffsData?.current_tariff || tariffsData?.currentTariff; - // Режим смены тарифа только для активных (не триал) подписок - isInstantSwitchMode = hasActiveSubscription && !isTrial && currentTariff; + + // Режим смены тарифа ТОЛЬКО для ОПЛАЧЕННЫХ активных подписок (НЕ триал!) + // Триалу нужно КУПИТЬ подписку, а не менять тариф + isInstantSwitchMode = actualStatus === 'active' && !isTrial && currentTariff; + + console.log('🔍 Mode:', { isInstantSwitchMode, actualStatus, isTrial, hasTariff: !!currentTariff }); // Вычисляем оставшиеся дни из expires_at let daysLeft = 0; From c36d51be30d4e599b6b16ec9554b2e9d63dbf34f Mon Sep 17 00:00:00 2001 From: Egor Date: Mon, 12 Jan 2026 20:06:37 +0300 Subject: [PATCH 61/61] Update index.html --- miniapp/index.html | 29 ++++++++++++++++++++--------- 1 file changed, 20 insertions(+), 9 deletions(-) diff --git a/miniapp/index.html b/miniapp/index.html index ce920cf4..f537fdbb 100644 --- a/miniapp/index.html +++ b/miniapp/index.html @@ -20644,8 +20644,18 @@ body?.classList.remove('hidden'); // Определяем режим: мгновенная смена или покупка - // Данные пользователя находятся в userData.user (структура MiniAppSubscriptionResponse) + // userData - глобальная переменная с данными подписки пользователя const userInfo = userData?.user || userData; + + // Проверяем тип подписки - триал или платная + // subscription_type хранит 'trial' | 'paid' | 'none' + const subscriptionType = ( + userData?.subscription_type || + userData?.user?.subscription_type || + userInfo?.subscription_type || + '' + ).toLowerCase(); + const actualStatus = ( userInfo?.subscription_actual_status || userInfo?.subscriptionActualStatus || @@ -20654,21 +20664,22 @@ '' ).toLowerCase(); - // Проверяем триал по всем возможным полям - const isTrial = actualStatus === 'trial' || + // Триал определяется по subscription_type = 'trial' + const isTrial = subscriptionType === 'trial' || + actualStatus === 'trial' || userInfo?.is_trial || - userInfo?.isTrial || - (userInfo?.subscription_type || userInfo?.subscriptionType || '').toLowerCase() === 'trial'; + userInfo?.isTrial; - console.log('🔍 Status check:', { actualStatus, isTrial, userInfo }); + console.log('🔍 Status check:', { subscriptionType, actualStatus, isTrial, userData }); const currentTariff = tariffsData?.current_tariff || tariffsData?.currentTariff; - // Режим смены тарифа ТОЛЬКО для ОПЛАЧЕННЫХ активных подписок (НЕ триал!) + // Режим смены тарифа ТОЛЬКО для ОПЛАЧЕННЫХ подписок (subscription_type = 'paid') // Триалу нужно КУПИТЬ подписку, а не менять тариф - isInstantSwitchMode = actualStatus === 'active' && !isTrial && currentTariff; + const isPaid = subscriptionType === 'paid'; + isInstantSwitchMode = isPaid && !isTrial && currentTariff; - console.log('🔍 Mode:', { isInstantSwitchMode, actualStatus, isTrial, hasTariff: !!currentTariff }); + console.log('🔍 Mode:', { isInstantSwitchMode, isPaid, isTrial, subscriptionType, hasTariff: !!currentTariff }); // Вычисляем оставшиеся дни из expires_at let daysLeft = 0;