Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e6a4927f57 |
+5
-64
@@ -67,14 +67,6 @@ REMNAWAVE_SECRET_KEY=
|
||||
# {telegram_id} — ID Telegram
|
||||
REMNAWAVE_USER_DESCRIPTION_TEMPLATE="Bot user: {full_name} {username}"
|
||||
|
||||
# Шаблон имени пользователя в панели Remnawave
|
||||
# Доступные плейсхолдеры аналогичны описанию выше
|
||||
# {full_name} — Имя, Фамилия из Telegram
|
||||
# {username} — @логин из Telegram (c @)
|
||||
# {username_clean} — логин из Telegram (без @)
|
||||
# {telegram_id} — ID Telegram
|
||||
REMNAWAVE_USER_USERNAME_TEMPLATE="user_{telegram_id}"
|
||||
|
||||
# Режим удаления пользователей из панели RemnaWave
|
||||
# delete - полностью удалить пользователя из панели
|
||||
# disable - только деактивировать пользователя
|
||||
@@ -85,8 +77,7 @@ REMNAWAVE_USER_DELETE_MODE=delete
|
||||
TRIAL_DURATION_DAYS=3
|
||||
TRIAL_TRAFFIC_LIMIT_GB=10
|
||||
TRIAL_DEVICE_LIMIT=1
|
||||
TRIAL_PAYMENT_ENABLED=false
|
||||
TRIAL_ACTIVATION_PRICE=0
|
||||
TRIAL_SQUAD_UUID=
|
||||
|
||||
# ===== ПЛАТНАЯ ПОДПИСКА =====
|
||||
# Сколько устройств доступно по дефолту при покупке платной подписки
|
||||
@@ -155,10 +146,6 @@ TRAFFIC_PACKAGES_CONFIG="5:2000:false,10:3500:false,25:7000:false,50:11000:true,
|
||||
|
||||
# Цена за дополнительное устройство (DEFAULT_DEVICE_LIMIT идет бесплатно!)
|
||||
PRICE_PER_DEVICE=10000
|
||||
# Включить выбор количества устройств при покупке и продлении
|
||||
DEVICES_SELECTION_ENABLED=true
|
||||
# Единое количество устройств для режима без выбора (0 — не назначать устройства)
|
||||
DEVICES_SELECTION_DISABLED_AMOUNT=0
|
||||
|
||||
# ===== РЕФЕРАЛЬНАЯ СИСТЕМА =====
|
||||
REFERRAL_PROGRAM_ENABLED=true
|
||||
@@ -180,8 +167,8 @@ MIN_BALANCE_FOR_AUTOPAY_KOPEKS=10000
|
||||
# ===== ПЛАТЕЖНЫЕ СИСТЕМЫ =====
|
||||
|
||||
# Telegram Stars (работает автоматически)
|
||||
TELEGRAM_STARS_ENABLED=false
|
||||
TELEGRAM_STARS_RATE_RUB=1.79
|
||||
TELEGRAM_STARS_ENABLED=true
|
||||
TELEGRAM_STARS_RATE_RUB=1.3
|
||||
|
||||
# Tribute (https://tribute.app)
|
||||
TRIBUTE_ENABLED=false
|
||||
@@ -189,8 +176,6 @@ TRIBUTE_API_KEY=
|
||||
TRIBUTE_DONATE_LINK=
|
||||
TRIBUTE_WEBHOOK_PATH=/tribute-webhook
|
||||
TRIBUTE_WEBHOOK_HOST=0.0.0.0
|
||||
# Примечание: индивидуальные *_WEBHOOK_PORT используются только для старой схемы запуска.
|
||||
# В режиме единого FastAPI сервера оставьте эти значения по умолчанию.
|
||||
TRIBUTE_WEBHOOK_PORT=8081
|
||||
|
||||
# YooKassa (https://yookassa.ru)
|
||||
@@ -243,6 +228,7 @@ YOOKASSA_PAYMENT_SUBJECT=service
|
||||
YOOKASSA_WEBHOOK_PATH=/yookassa-webhook
|
||||
YOOKASSA_WEBHOOK_HOST=0.0.0.0
|
||||
YOOKASSA_WEBHOOK_PORT=8082
|
||||
YOOKASSA_WEBHOOK_SECRET=your_webhook_secret
|
||||
|
||||
# Лимиты сумм пополнения через YooKassa (в копейках)
|
||||
YOOKASSA_MIN_AMOUNT_KOPEKS=5000
|
||||
@@ -254,13 +240,8 @@ YOOKASSA_QUICK_AMOUNT_SELECTION_ENABLED=true
|
||||
# Отключить отображение кнопок выбора суммы пополнения (оставить только ввод вручную)
|
||||
DISABLE_TOPUP_BUTTONS=false
|
||||
|
||||
# Автоматическая проверка зависших пополнений и повторные обращения к провайдерам
|
||||
PAYMENT_VERIFICATION_AUTO_CHECK_ENABLED=false
|
||||
# Интервал (в минутах) между автоматическими проверками пополнений
|
||||
PAYMENT_VERIFICATION_AUTO_CHECK_INTERVAL_MINUTES=10
|
||||
|
||||
# ===== НАСТРОЙКИ ОПИСАНИЙ ПЛАТЕЖЕЙ =====
|
||||
# Эти настройки позволяют изменить описания платежей,
|
||||
# Эти настройки позволяют изменить описания платежей,
|
||||
# чтобы избежать блокировок платежных систем
|
||||
PAYMENT_SERVICE_NAME=Интернет-сервис
|
||||
PAYMENT_BALANCE_DESCRIPTION=Пополнение баланса
|
||||
@@ -275,8 +256,6 @@ CRYPTOBOT_WEBHOOK_SECRET=your_webhook_secret_here
|
||||
CRYPTOBOT_BASE_URL=https://pay.crypt.bot
|
||||
CRYPTOBOT_TESTNET=false
|
||||
CRYPTOBOT_WEBHOOK_PATH=/cryptobot-webhook
|
||||
# Эти порты используются только при ручном запуске старого aiohttp сервера.
|
||||
# В новой конфигурации все вебхуки обрабатываются через порт 8080.
|
||||
CRYPTOBOT_WEBHOOK_PORT=8081
|
||||
CRYPTOBOT_DEFAULT_ASSET=USDT
|
||||
CRYPTOBOT_ASSETS=USDT,TON,BTC,ETH,LTC,BNB,TRX,USDC
|
||||
@@ -331,22 +310,6 @@ PAL24_SBP_BUTTON_VISIBLE=true
|
||||
# Отображать кнопку оплаты картой в PayPalych (true - отображать, false - скрывать)
|
||||
PAL24_CARD_BUTTON_VISIBLE=true
|
||||
|
||||
# PLATEGA
|
||||
PLATEGA_ENABLED=false
|
||||
PLATEGA_MERCHANT_ID=
|
||||
PLATEGA_SECRET=
|
||||
PLATEGA_BASE_URL=https://app.platega.io
|
||||
PLATEGA_RETURN_URL=
|
||||
PLATEGA_FAILED_URL=
|
||||
PLATEGA_CURRENCY=RUB
|
||||
# Список ID активных методов из кабинета Platega (через запятую)
|
||||
PLATEGA_ACTIVE_METHODS=2,10,11,12,13
|
||||
PLATEGA_MIN_AMOUNT_KOPEKS=10000
|
||||
PLATEGA_MAX_AMOUNT_KOPEKS=100000000
|
||||
PLATEGA_WEBHOOK_PATH=/platega-webhook
|
||||
PLATEGA_WEBHOOK_HOST=0.0.0.0
|
||||
PLATEGA_WEBHOOK_PORT=8086
|
||||
|
||||
# ===== ИНТЕРФЕЙС И UX =====
|
||||
|
||||
# Включить логотип для всех сообщений (true - с изображением, false - только текст)
|
||||
@@ -369,7 +332,6 @@ CONNECT_BUTTON_MODE=guide
|
||||
|
||||
# URL для режима miniapp_custom (обязателен при CONNECT_BUTTON_MODE=miniapp_custom)
|
||||
MINIAPP_CUSTOM_URL=
|
||||
MINIAPP_STATIC_PATH=miniapp
|
||||
MINIAPP_SERVICE_NAME_EN=Bedolaga VPN
|
||||
MINIAPP_SERVICE_NAME_RU=Bedolaga VPN
|
||||
MINIAPP_SERVICE_DESCRIPTION_EN=Secure & Fast Connection
|
||||
@@ -421,8 +383,6 @@ SERVER_STATUS_ITEMS_PER_PAGE=10
|
||||
MAINTENANCE_MODE=false
|
||||
MAINTENANCE_CHECK_INTERVAL=30
|
||||
MAINTENANCE_AUTO_ENABLE=true
|
||||
MAINTENANCE_MONITORING_ENABLED=true
|
||||
MAINTENANCE_RETRY_ATTEMPTS=1
|
||||
MAINTENANCE_MESSAGE=Ведутся технические работы. Сервис временно недоступен. Попробуйте позже.
|
||||
|
||||
# ===== ЛОКАЛИЗАЦИЯ =====
|
||||
@@ -431,8 +391,6 @@ DEFAULT_LANGUAGE=ru
|
||||
AVAILABLE_LANGUAGES=ru,en
|
||||
# Включить выбор языка при старте и отображение кнопки в меню
|
||||
LANGUAGE_SELECTION_ENABLED=true
|
||||
# Часовой пояс
|
||||
TZ=Europe/Moscow # или UTC, America/New_York и т.д.
|
||||
|
||||
# ===== ДОПОЛНИТЕЛЬНЫЕ НАСТРОЙКИ =====
|
||||
# Конфигурация приложений для гайда подключения
|
||||
@@ -470,20 +428,3 @@ LOG_FILE=logs/bot.log
|
||||
DEBUG=false
|
||||
WEBHOOK_URL=
|
||||
WEBHOOK_PATH=/webhook
|
||||
WEBHOOK_SECRET_TOKEN=
|
||||
WEBHOOK_DROP_PENDING_UPDATES=true
|
||||
WEBHOOK_MAX_QUEUE_SIZE=1024
|
||||
WEBHOOK_WORKERS=4
|
||||
WEBHOOK_ENQUEUE_TIMEOUT=0.1
|
||||
WEBHOOK_WORKER_SHUTDOWN_TIMEOUT=30.0
|
||||
BOT_RUN_MODE=polling # polling, webhook или both
|
||||
|
||||
# ===== ЕДИНЫЙ ВЕБ-СЕРВЕР =====
|
||||
WEB_API_ENABLED=false
|
||||
WEB_API_HOST=0.0.0.0
|
||||
WEB_API_PORT=8080
|
||||
WEB_API_ALLOWED_ORIGINS=*
|
||||
WEB_API_DOCS_ENABLED=false
|
||||
WEB_API_DEFAULT_TOKEN=
|
||||
WEB_API_DEFAULT_TOKEN_NAME=Bootstrap Token
|
||||
MINIAPP_STATIC_PATH=miniapp
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
---
|
||||
name: Bug Bedolage
|
||||
about: Создать баг-репорт «Бедолаге»
|
||||
title: "[BUG] Заголовок"
|
||||
labels: ''
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
## Описание / Конфигурация
|
||||
|
||||
Кратко описываем текущую проблему.
|
||||
|
||||
Указываем настройки из `.env` конфига (если таковые имеются):
|
||||
|
||||
```yml
|
||||
|
||||
```
|
||||
|
||||
## Шаги для воспроизведения
|
||||
|
||||
1.
|
||||
2.
|
||||
3.
|
||||
|
||||
### Фактический результат
|
||||
|
||||
*
|
||||
*
|
||||
*
|
||||
|
||||
### Скрины поведения
|
||||
|
||||
Копируем изображение и вставляем прям сюда, если есть. Если нет — удаляем секцию.
|
||||
|
||||
## Ожидаемое поведение
|
||||
|
||||
Опишите, как должно работать.
|
||||
|
||||
## Дополнительная информация
|
||||
|
||||
Добавляем сюда дополнительную инфу, если есть. Если нет — удаляем секцию.
|
||||
@@ -1,32 +0,0 @@
|
||||
---
|
||||
name: Feat Bedolage
|
||||
about: Создать запрос на фичу «Бедолаге»
|
||||
title: "[FEAT] Заголовок"
|
||||
labels: ''
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
## Описание / Конфигурация
|
||||
|
||||
Кратко описываем что хотим.
|
||||
|
||||
Указываем настройки из `.env` конфига (если таковые к месту):
|
||||
|
||||
```yml
|
||||
|
||||
```
|
||||
|
||||
## Текущее поведение
|
||||
|
||||
### Скрины поведения
|
||||
|
||||
Копируем изображение и вставляем прям сюда, если есть. Если нет — удаляем секцию.
|
||||
|
||||
## Желаемое поведение
|
||||
|
||||
Опишите, как хотелось бы, чтобы было.
|
||||
|
||||
## Дополнительная информация
|
||||
|
||||
Добавляем сюда дополнительную инфу, если есть. Если нет — удаляем секцию.
|
||||
@@ -36,15 +36,15 @@ jobs:
|
||||
TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:latest,fr1ngg/remnawave-bedolaga-telegram-bot:${VERSION}"
|
||||
echo "🏷️ Собираем релизную версию: $VERSION"
|
||||
elif [[ $GITHUB_REF == refs/heads/main ]]; then
|
||||
VERSION="v2.6.2-$(git rev-parse --short HEAD)"
|
||||
VERSION="v2.5.6-$(git rev-parse --short HEAD)"
|
||||
TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:latest,fr1ngg/remnawave-bedolaga-telegram-bot:${VERSION}"
|
||||
echo "🚀 Собираем версию из main: $VERSION"
|
||||
elif [[ $GITHUB_REF == refs/heads/dev ]]; then
|
||||
VERSION="v2.6.2-dev-$(git rev-parse --short HEAD)"
|
||||
VERSION="v2.5.6-dev-$(git rev-parse --short HEAD)"
|
||||
TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:dev,fr1ngg/remnawave-bedolaga-telegram-bot:${VERSION}"
|
||||
echo "🧪 Собираем dev версию: $VERSION"
|
||||
else
|
||||
VERSION="v2.6.2-pr-$(git rev-parse --short HEAD)"
|
||||
VERSION="v2.5.6-pr-$(git rev-parse --short HEAD)"
|
||||
TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:pr-$(git rev-parse --short HEAD)"
|
||||
echo "🔀 Собираем PR версию: $VERSION"
|
||||
fi
|
||||
|
||||
@@ -49,13 +49,13 @@ jobs:
|
||||
VERSION=${GITHUB_REF#refs/tags/}
|
||||
echo "🏷️ Building release version: $VERSION"
|
||||
elif [[ $GITHUB_REF == refs/heads/main ]]; then
|
||||
VERSION="v2.6.2-$(git rev-parse --short HEAD)"
|
||||
VERSION="v2.5.6-$(git rev-parse --short HEAD)"
|
||||
echo "🚀 Building main version: $VERSION"
|
||||
elif [[ $GITHUB_REF == refs/heads/dev ]]; then
|
||||
VERSION="v2.6.2-dev-$(git rev-parse --short HEAD)"
|
||||
VERSION="v2.5.6-dev-$(git rev-parse --short HEAD)"
|
||||
echo "🧪 Building dev version: $VERSION"
|
||||
else
|
||||
VERSION="v2.6.2-pr-$(git rev-parse --short HEAD)"
|
||||
VERSION="v2.5.6-pr-$(git rev-parse --short HEAD)"
|
||||
echo "🔀 Building PR version: $VERSION"
|
||||
fi
|
||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
# Игнорируем все файлы и папки по умолчанию
|
||||
*
|
||||
docker-compose.override.yml
|
||||
|
||||
# Исключения: разрешаем только нужные файлы
|
||||
!.dockerignore
|
||||
|
||||
+5
-5
@@ -1,4 +1,4 @@
|
||||
FROM python:3.14-slim AS builder
|
||||
FROM python:3.13-slim AS builder
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
gcc \
|
||||
@@ -12,9 +12,9 @@ COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir --upgrade pip && \
|
||||
pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
FROM python:3.14-slim
|
||||
FROM python:3.13-slim
|
||||
|
||||
ARG VERSION="v2.6.2"
|
||||
ARG VERSION="v2.5.6"
|
||||
ARG BUILD_DATE
|
||||
ARG VCS_REF
|
||||
|
||||
@@ -45,7 +45,7 @@ ENV PYTHONPATH=/app \
|
||||
BUILD_DATE=${BUILD_DATE} \
|
||||
VCS_REF=${VCS_REF}
|
||||
|
||||
EXPOSE 8080
|
||||
EXPOSE 8080 8081 8082
|
||||
|
||||
LABEL org.opencontainers.image.title="Bedolaga RemnaWave Bot" \
|
||||
org.opencontainers.image.description="Telegram bot for RemnaWave VPN service" \
|
||||
@@ -57,6 +57,6 @@ LABEL org.opencontainers.image.title="Bedolaga RemnaWave Bot" \
|
||||
org.opencontainers.image.vendor="fr1ngg"
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \
|
||||
CMD wget --no-verbose --tries=1 --spider http://localhost:8080/health || exit 1
|
||||
CMD wget --no-verbose --tries=1 --spider http://localhost:8081/health || exit 1
|
||||
|
||||
CMD ["python", "main.py"]
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
.PHONY: up
|
||||
up: ## Поднять контейнеры (detached)
|
||||
@echo "🚀 Поднимаем контейнеры (detached)..."
|
||||
docker compose up -d --build
|
||||
|
||||
.PHONY: up-follow
|
||||
up-follow: ## Поднять контейнеры с логами
|
||||
@echo "📡 Поднимаем контейнеры (в консоли)..."
|
||||
docker compose up --build
|
||||
|
||||
.PHONY: down
|
||||
down: ## Остановить и удалить контейнеры
|
||||
@echo "🛑 Останавливаем и удаляем контейнеры..."
|
||||
docker compose down
|
||||
|
||||
.PHONY: reload
|
||||
reload: ## Перезапустить контейнеры (detached)
|
||||
@$(MAKE) down
|
||||
@$(MAKE) up
|
||||
|
||||
.PHONY: reload-follow
|
||||
reload-follow: ## Перезапустить контейнеры с логами
|
||||
@$(MAKE) down
|
||||
@$(MAKE) up-follow
|
||||
|
||||
.PHONY: test
|
||||
test: ## Запустить тесты
|
||||
@echo "🧪 Запускаем тесты..."
|
||||
pytest -v
|
||||
|
||||
.PHONY: help
|
||||
help: ## Показать список доступных команд
|
||||
@echo ""
|
||||
@echo "📘 Команды Makefile:"
|
||||
@echo ""
|
||||
@grep -E '^[a-zA-Z0-9_-]+:.*?##' $(MAKEFILE_LIST) | \
|
||||
sed -E 's/:.*?## /| /' | \
|
||||
awk -F'|' '{printf " \033[36m%-16s\033[0m %s\n", $$1, $$2}'
|
||||
@echo ""
|
||||
+4
-2
@@ -136,7 +136,8 @@ logger.debug(f"Webhook payload: {webhook_data}")
|
||||
# Открыть только необходимые порты
|
||||
ufw allow 80/tcp # HTTP (redirect to HTTPS)
|
||||
ufw allow 443/tcp # HTTPS
|
||||
ufw deny 8080/tcp # Unified FastAPI сервер доступен только из внутренней сети
|
||||
ufw deny 8081/tcp # Webhook порты (только через reverse proxy)
|
||||
ufw deny 8082/tcp
|
||||
```
|
||||
|
||||
### 📊 Мониторинг безопасности
|
||||
@@ -238,6 +239,7 @@ REMNAWAVE_API_KEY=your_api_key
|
||||
ADMIN_IDS=123456789,987654321
|
||||
|
||||
# Webhook секреты - генерируйте случайные значения
|
||||
YOOKASSA_WEBHOOK_SECRET=random_secret_here
|
||||
TRIBUTE_WEBHOOK_SECRET=another_random_secret
|
||||
CRYPTOBOT_WEBHOOK_SECRET=yet_another_secret
|
||||
```
|
||||
@@ -281,7 +283,7 @@ services:
|
||||
|
||||
# Не пробрасывайте порты наружу без необходимости
|
||||
ports:
|
||||
- "127.0.0.1:8080:8080" # Только localhost
|
||||
- "127.0.0.1:8081:8081" # Только localhost
|
||||
|
||||
# Read-only root filesystem
|
||||
read_only: true
|
||||
|
||||
+5
-8
@@ -182,14 +182,11 @@ async def setup_bot() -> tuple[Bot, Dispatcher]:
|
||||
logger.info("⚡ Зарегистрированы обработчики простой покупки")
|
||||
logger.info("⚡ Зарегистрированы обработчики простой подписки")
|
||||
|
||||
if settings.is_maintenance_monitoring_enabled():
|
||||
try:
|
||||
await maintenance_service.start_monitoring()
|
||||
logger.info("Мониторинг техработ запущен")
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка запуска мониторинга техработ: {e}")
|
||||
else:
|
||||
logger.info("Мониторинг техработ отключен настройками")
|
||||
try:
|
||||
await maintenance_service.start_monitoring()
|
||||
logger.info("Мониторинг техработ запущен")
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка запуска мониторинга техработ: {e}")
|
||||
|
||||
logger.info("🛡️ GlobalErrorMiddleware активирован - бот защищен от устаревших callback queries")
|
||||
logger.info("Бот успешно настроен")
|
||||
|
||||
+38
-256
@@ -7,7 +7,6 @@ import html
|
||||
from collections import defaultdict
|
||||
from datetime import time
|
||||
from typing import List, Optional, Union, Dict
|
||||
from zoneinfo import ZoneInfo
|
||||
from pydantic_settings import BaseSettings
|
||||
from pydantic import field_validator, Field
|
||||
from pathlib import Path
|
||||
@@ -61,8 +60,6 @@ class Settings(BaseSettings):
|
||||
|
||||
SQLITE_PATH: str = "./data/bot.db"
|
||||
LOCALES_PATH: str = "./locales"
|
||||
|
||||
TIMEZONE: str = Field(default_factory=lambda: os.getenv("TZ", "UTC"))
|
||||
|
||||
DATABASE_MODE: str = "auto"
|
||||
|
||||
@@ -76,7 +73,6 @@ class Settings(BaseSettings):
|
||||
REMNAWAVE_PASSWORD: Optional[str] = None
|
||||
REMNAWAVE_AUTH_TYPE: str = "api_key"
|
||||
REMNAWAVE_USER_DESCRIPTION_TEMPLATE: str = "Bot user: {full_name} {username}"
|
||||
REMNAWAVE_USER_USERNAME_TEMPLATE: str = "user_{telegram_id}"
|
||||
REMNAWAVE_USER_DELETE_MODE: str = "delete" # "delete" или "disable"
|
||||
REMNAWAVE_AUTO_SYNC_ENABLED: bool = False
|
||||
REMNAWAVE_AUTO_SYNC_TIMES: str = "03:00"
|
||||
@@ -85,10 +81,9 @@ class Settings(BaseSettings):
|
||||
TRIAL_TRAFFIC_LIMIT_GB: int = 10
|
||||
TRIAL_DEVICE_LIMIT: int = 2
|
||||
TRIAL_ADD_REMAINING_DAYS_TO_PAID: bool = False
|
||||
TRIAL_PAYMENT_ENABLED: bool = False
|
||||
TRIAL_ACTIVATION_PRICE: int = 0
|
||||
DEFAULT_TRAFFIC_LIMIT_GB: int = 100
|
||||
DEFAULT_DEVICE_LIMIT: int = 1
|
||||
TRIAL_SQUAD_UUID: Optional[str] = None
|
||||
DEFAULT_TRAFFIC_RESET_STRATEGY: str = "MONTH"
|
||||
RESET_TRAFFIC_ON_PAYMENT: bool = False
|
||||
MAX_DEVICES_LIMIT: int = 20
|
||||
@@ -128,12 +123,10 @@ class Settings(BaseSettings):
|
||||
PRICE_TRAFFIC_500GB: int = 19000
|
||||
PRICE_TRAFFIC_1000GB: int = 19500
|
||||
PRICE_TRAFFIC_UNLIMITED: int = 20000
|
||||
|
||||
|
||||
TRAFFIC_PACKAGES_CONFIG: str = ""
|
||||
|
||||
PRICE_PER_DEVICE: int = 5000
|
||||
DEVICES_SELECTION_ENABLED: bool = True
|
||||
DEVICES_SELECTION_DISABLED_AMOUNT: Optional[int] = None
|
||||
|
||||
BASE_PROMO_GROUP_PERIOD_DISCOUNTS_ENABLED: bool = False
|
||||
BASE_PROMO_GROUP_PERIOD_DISCOUNTS: str = ""
|
||||
@@ -149,6 +142,7 @@ class Settings(BaseSettings):
|
||||
REFERRAL_PROGRAM_ENABLED: bool = True
|
||||
REFERRAL_NOTIFICATIONS_ENABLED: bool = True
|
||||
REFERRAL_NOTIFICATION_RETRY_ATTEMPTS: int = 3
|
||||
REFERRED_USER_REWARD: int = 0
|
||||
|
||||
AUTOPAY_WARNING_DAYS: str = "3,1"
|
||||
|
||||
@@ -160,10 +154,8 @@ class Settings(BaseSettings):
|
||||
INACTIVE_USER_DELETE_MONTHS: int = 3
|
||||
|
||||
MAINTENANCE_MODE: bool = False
|
||||
MAINTENANCE_CHECK_INTERVAL: int = 30
|
||||
MAINTENANCE_AUTO_ENABLE: bool = True
|
||||
MAINTENANCE_MONITORING_ENABLED: bool = True
|
||||
MAINTENANCE_RETRY_ATTEMPTS: int = 1
|
||||
MAINTENANCE_CHECK_INTERVAL: int = 30
|
||||
MAINTENANCE_AUTO_ENABLE: bool = True
|
||||
MAINTENANCE_MESSAGE: str = "🔧 Ведутся технические работы. Сервис временно недоступен. Попробуйте позже."
|
||||
|
||||
TELEGRAM_STARS_ENABLED: bool = True
|
||||
@@ -188,7 +180,7 @@ class Settings(BaseSettings):
|
||||
YOOKASSA_WEBHOOK_PATH: str = "/yookassa-webhook"
|
||||
YOOKASSA_WEBHOOK_HOST: str = "0.0.0.0"
|
||||
YOOKASSA_WEBHOOK_PORT: int = 8082
|
||||
YOOKASSA_TRUSTED_PROXY_NETWORKS: str = ""
|
||||
YOOKASSA_WEBHOOK_SECRET: Optional[str] = None
|
||||
YOOKASSA_MIN_AMOUNT_KOPEKS: int = 5000
|
||||
YOOKASSA_MAX_AMOUNT_KOPEKS: int = 1000000
|
||||
YOOKASSA_QUICK_AMOUNT_SELECTION_ENABLED: bool = False
|
||||
@@ -271,20 +263,6 @@ class Settings(BaseSettings):
|
||||
PAL24_SBP_BUTTON_VISIBLE: bool = True
|
||||
PAL24_CARD_BUTTON_VISIBLE: bool = True
|
||||
|
||||
PLATEGA_ENABLED: bool = False
|
||||
PLATEGA_MERCHANT_ID: Optional[str] = None
|
||||
PLATEGA_SECRET: Optional[str] = None
|
||||
PLATEGA_BASE_URL: str = "https://app.platega.io"
|
||||
PLATEGA_RETURN_URL: Optional[str] = None
|
||||
PLATEGA_FAILED_URL: Optional[str] = None
|
||||
PLATEGA_CURRENCY: str = "RUB"
|
||||
PLATEGA_ACTIVE_METHODS: str = "2,10,11,12,13"
|
||||
PLATEGA_MIN_AMOUNT_KOPEKS: int = 10000
|
||||
PLATEGA_MAX_AMOUNT_KOPEKS: int = 100000000
|
||||
PLATEGA_WEBHOOK_PATH: str = "/platega-webhook"
|
||||
PLATEGA_WEBHOOK_HOST: str = "0.0.0.0"
|
||||
PLATEGA_WEBHOOK_PORT: int = 8086
|
||||
|
||||
WATA_ENABLED: bool = False
|
||||
WATA_BASE_URL: str = "https://api.wata.pro/api/h2h"
|
||||
WATA_ACCESS_TOKEN: Optional[str] = None
|
||||
@@ -306,7 +284,6 @@ class Settings(BaseSettings):
|
||||
MAIN_MENU_MODE: str = "default"
|
||||
CONNECT_BUTTON_MODE: str = "guide"
|
||||
MINIAPP_CUSTOM_URL: str = ""
|
||||
MINIAPP_STATIC_PATH: str = "miniapp"
|
||||
MINIAPP_PURCHASE_URL: str = ""
|
||||
MINIAPP_SERVICE_NAME_EN: str = "Bedolaga VPN"
|
||||
MINIAPP_SERVICE_NAME_RU: str = "Bedolaga VPN"
|
||||
@@ -335,13 +312,6 @@ class Settings(BaseSettings):
|
||||
DEBUG: bool = False
|
||||
WEBHOOK_URL: Optional[str] = None
|
||||
WEBHOOK_PATH: str = "/webhook"
|
||||
WEBHOOK_SECRET_TOKEN: Optional[str] = None
|
||||
WEBHOOK_DROP_PENDING_UPDATES: bool = True
|
||||
WEBHOOK_MAX_QUEUE_SIZE: int = 1024
|
||||
WEBHOOK_WORKERS: int = 4
|
||||
WEBHOOK_ENQUEUE_TIMEOUT: float = 0.1
|
||||
WEBHOOK_WORKER_SHUTDOWN_TIMEOUT: float = 30.0
|
||||
BOT_RUN_MODE: str = "polling"
|
||||
|
||||
WEB_API_ENABLED: bool = False
|
||||
WEB_API_HOST: str = "0.0.0.0"
|
||||
@@ -580,34 +550,6 @@ class Settings(BaseSettings):
|
||||
description = re.sub(r'\s+', ' ', description).strip()
|
||||
return description
|
||||
|
||||
def format_remnawave_username(
|
||||
self,
|
||||
*,
|
||||
full_name: str,
|
||||
username: Optional[str],
|
||||
telegram_id: int
|
||||
) -> str:
|
||||
template = self.REMNAWAVE_USER_USERNAME_TEMPLATE or "user_{telegram_id}"
|
||||
|
||||
username_clean = (username or "").lstrip("@")
|
||||
full_name_value = full_name or ""
|
||||
|
||||
values = defaultdict(str, {
|
||||
"full_name": full_name_value,
|
||||
"username": username_clean,
|
||||
"username_clean": username_clean,
|
||||
"telegram_id": str(telegram_id),
|
||||
})
|
||||
|
||||
raw_username = template.format_map(values).strip()
|
||||
sanitized_username = re.sub(r"[^0-9A-Za-z._-]+", "_", raw_username)
|
||||
sanitized_username = re.sub(r"_+", "_", sanitized_username).strip("._-")
|
||||
|
||||
if not sanitized_username:
|
||||
sanitized_username = f"user_{telegram_id}"
|
||||
|
||||
return sanitized_username[:64]
|
||||
|
||||
@staticmethod
|
||||
def parse_daily_time_list(raw_value: Optional[str]) -> List[time]:
|
||||
if not raw_value:
|
||||
@@ -855,53 +797,9 @@ class Settings(BaseSettings):
|
||||
|
||||
def is_traffic_fixed(self) -> bool:
|
||||
return self.TRAFFIC_SELECTION_MODE.lower() == "fixed"
|
||||
|
||||
|
||||
def get_fixed_traffic_limit(self) -> int:
|
||||
return self.FIXED_TRAFFIC_LIMIT_GB
|
||||
|
||||
def is_devices_selection_enabled(self) -> bool:
|
||||
return self.DEVICES_SELECTION_ENABLED
|
||||
|
||||
def get_devices_selection_disabled_amount(self) -> Optional[int]:
|
||||
raw_value = self.DEVICES_SELECTION_DISABLED_AMOUNT
|
||||
|
||||
if raw_value in (None, ""):
|
||||
return None
|
||||
|
||||
try:
|
||||
value = int(raw_value)
|
||||
except (TypeError, ValueError):
|
||||
logger.warning(
|
||||
"Некорректное значение DEVICES_SELECTION_DISABLED_AMOUNT: %s",
|
||||
raw_value,
|
||||
)
|
||||
return None
|
||||
|
||||
if value < 0:
|
||||
return 0
|
||||
|
||||
return value
|
||||
|
||||
def get_disabled_mode_device_limit(self) -> Optional[int]:
|
||||
return self.get_devices_selection_disabled_amount()
|
||||
|
||||
def is_trial_paid_activation_enabled(self) -> bool:
|
||||
return bool(self.TRIAL_PAYMENT_ENABLED)
|
||||
|
||||
def get_trial_activation_price(self) -> int:
|
||||
try:
|
||||
value = int(self.TRIAL_ACTIVATION_PRICE)
|
||||
except (TypeError, ValueError):
|
||||
logger.warning(
|
||||
"Некорректное значение TRIAL_ACTIVATION_PRICE: %s",
|
||||
self.TRIAL_ACTIVATION_PRICE,
|
||||
)
|
||||
return 0
|
||||
|
||||
if value < 0:
|
||||
return 0
|
||||
|
||||
return value
|
||||
|
||||
def is_yookassa_enabled(self) -> bool:
|
||||
return (self.YOOKASSA_ENABLED and
|
||||
@@ -950,74 +848,6 @@ class Settings(BaseSettings):
|
||||
and self.PAL24_SHOP_ID is not None
|
||||
)
|
||||
|
||||
def is_platega_enabled(self) -> bool:
|
||||
return (
|
||||
self.PLATEGA_ENABLED
|
||||
and self.PLATEGA_MERCHANT_ID is not None
|
||||
and self.PLATEGA_SECRET is not None
|
||||
)
|
||||
|
||||
def get_platega_return_url(self) -> Optional[str]:
|
||||
if self.PLATEGA_RETURN_URL:
|
||||
return self.PLATEGA_RETURN_URL
|
||||
if self.WEBHOOK_URL:
|
||||
return f"{self.WEBHOOK_URL}/payment-success"
|
||||
return None
|
||||
|
||||
def get_platega_failed_url(self) -> Optional[str]:
|
||||
if self.PLATEGA_FAILED_URL:
|
||||
return self.PLATEGA_FAILED_URL
|
||||
if self.WEBHOOK_URL:
|
||||
return f"{self.WEBHOOK_URL}/payment-failed"
|
||||
return None
|
||||
|
||||
def get_platega_active_methods(self) -> List[int]:
|
||||
raw_value = str(self.PLATEGA_ACTIVE_METHODS or "")
|
||||
normalized = raw_value.replace(";", ",")
|
||||
methods: list[int] = []
|
||||
seen: set[int] = set()
|
||||
for part in normalized.split(","):
|
||||
part = part.strip()
|
||||
if not part:
|
||||
continue
|
||||
try:
|
||||
method_code = int(part)
|
||||
except ValueError:
|
||||
logger.warning("Некорректный код метода Platega: %s", part)
|
||||
continue
|
||||
if method_code in {2, 10, 11, 12, 13} and method_code not in seen:
|
||||
methods.append(method_code)
|
||||
seen.add(method_code)
|
||||
|
||||
if not methods:
|
||||
return [2]
|
||||
|
||||
return methods
|
||||
|
||||
@staticmethod
|
||||
def get_platega_method_definitions() -> Dict[int, Dict[str, str]]:
|
||||
return {
|
||||
2: {"name": "СБП (QR)", "title": "🏦 СБП (QR)"},
|
||||
10: {"name": "Банковские карты (RUB)", "title": "💳 Карты (RUB)"},
|
||||
11: {"name": "Банковские карты", "title": "💳 Банковские карты"},
|
||||
12: {"name": "Международные карты", "title": "🌍 Международные карты"},
|
||||
13: {"name": "Криптовалюта", "title": "🪙 Криптовалюта"},
|
||||
}
|
||||
|
||||
def get_platega_method_display_name(self, method_code: int) -> str:
|
||||
definitions = self.get_platega_method_definitions()
|
||||
info = definitions.get(method_code)
|
||||
if info and info.get("name"):
|
||||
return info["name"]
|
||||
return f"Метод {method_code}"
|
||||
|
||||
def get_platega_method_display_title(self, method_code: int) -> str:
|
||||
definitions = self.get_platega_method_definitions()
|
||||
info = definitions.get(method_code)
|
||||
if not info:
|
||||
return f"Platega {method_code}"
|
||||
return info.get("title") or info.get("name") or f"Platega {method_code}"
|
||||
|
||||
def is_wata_enabled(self) -> bool:
|
||||
return (
|
||||
self.WATA_ENABLED
|
||||
@@ -1124,13 +954,6 @@ class Settings(BaseSettings):
|
||||
def get_maintenance_check_interval(self) -> int:
|
||||
return self.MAINTENANCE_CHECK_INTERVAL
|
||||
|
||||
def get_maintenance_retry_attempts(self) -> int:
|
||||
try:
|
||||
attempts = int(self.MAINTENANCE_RETRY_ATTEMPTS)
|
||||
except (TypeError, ValueError):
|
||||
attempts = 1
|
||||
return max(1, attempts)
|
||||
|
||||
def is_base_promo_group_period_discount_enabled(self) -> bool:
|
||||
return self.BASE_PROMO_GROUP_PERIOD_DISCOUNTS_ENABLED
|
||||
|
||||
@@ -1173,9 +996,6 @@ class Settings(BaseSettings):
|
||||
def is_maintenance_auto_enable(self) -> bool:
|
||||
return self.MAINTENANCE_AUTO_ENABLE
|
||||
|
||||
def is_maintenance_monitoring_enabled(self) -> bool:
|
||||
return self.MAINTENANCE_MONITORING_ENABLED
|
||||
|
||||
def get_available_subscription_periods(self) -> List[int]:
|
||||
try:
|
||||
periods_str = self.AVAILABLE_SUBSCRIPTION_PERIODS
|
||||
@@ -1267,7 +1087,34 @@ class Settings(BaseSettings):
|
||||
return (self.BACKUP_SEND_ENABLED and
|
||||
self.get_backup_send_chat_id() is not None)
|
||||
|
||||
def get_referred_user_reward_kopeks(self) -> int:
|
||||
"""Return the referred user reward normalized to kopeks.
|
||||
|
||||
Historically the value was stored in kopeks, however some
|
||||
installations provide it in rubles. To keep backward compatibility we
|
||||
treat any value greater than or equal to one thousand as already being
|
||||
in kopeks (≥ 10 ₽). Smaller positive values are assumed to be provided
|
||||
in rubles and therefore converted to kopeks.
|
||||
"""
|
||||
|
||||
raw_value = getattr(self, "REFERRED_USER_REWARD", 0)
|
||||
|
||||
try:
|
||||
value = int(raw_value)
|
||||
except (TypeError, ValueError):
|
||||
return 0
|
||||
|
||||
if value <= 0:
|
||||
return 0
|
||||
|
||||
if value >= 1000:
|
||||
return value
|
||||
|
||||
return value * 100
|
||||
|
||||
def get_referral_settings(self) -> Dict:
|
||||
referred_reward_kopeks = self.get_referred_user_reward_kopeks()
|
||||
|
||||
return {
|
||||
"program_enabled": self.is_referral_program_enabled(),
|
||||
"minimum_topup_kopeks": self.REFERRAL_MINIMUM_TOPUP_KOPEKS,
|
||||
@@ -1275,6 +1122,8 @@ class Settings(BaseSettings):
|
||||
"inviter_bonus_kopeks": self.REFERRAL_INVITER_BONUS_KOPEKS,
|
||||
"commission_percent": self.REFERRAL_COMMISSION_PERCENT,
|
||||
"notifications_enabled": self.REFERRAL_NOTIFICATIONS_ENABLED,
|
||||
"referred_user_reward": referred_reward_kopeks,
|
||||
"referred_user_reward_kopeks": referred_reward_kopeks,
|
||||
}
|
||||
|
||||
def is_referral_program_enabled(self) -> bool:
|
||||
@@ -1532,82 +1381,15 @@ class Settings(BaseSettings):
|
||||
|
||||
def is_support_contact_enabled(self) -> bool:
|
||||
return self.get_support_system_mode() in {"contact", "both"}
|
||||
|
||||
def get_bot_run_mode(self) -> str:
|
||||
mode = (self.BOT_RUN_MODE or "polling").strip().lower()
|
||||
if mode not in {"polling", "webhook", "both"}:
|
||||
return "polling"
|
||||
return mode
|
||||
|
||||
def get_telegram_webhook_path(self) -> str:
|
||||
raw_path = (self.WEBHOOK_PATH or "/webhook").strip()
|
||||
if not raw_path:
|
||||
raw_path = "/webhook"
|
||||
if not raw_path.startswith("/"):
|
||||
raw_path = "/" + raw_path
|
||||
return raw_path
|
||||
|
||||
def get_webhook_queue_maxsize(self) -> int:
|
||||
try:
|
||||
size = int(self.WEBHOOK_MAX_QUEUE_SIZE)
|
||||
except (TypeError, ValueError):
|
||||
size = 1024
|
||||
return max(1, size)
|
||||
|
||||
def get_webhook_worker_count(self) -> int:
|
||||
try:
|
||||
workers = int(self.WEBHOOK_WORKERS)
|
||||
except (TypeError, ValueError):
|
||||
workers = 1
|
||||
return max(1, workers)
|
||||
|
||||
def get_webhook_enqueue_timeout(self) -> float:
|
||||
try:
|
||||
timeout = float(self.WEBHOOK_ENQUEUE_TIMEOUT)
|
||||
except (TypeError, ValueError):
|
||||
timeout = 0.0
|
||||
return max(0.0, timeout)
|
||||
|
||||
def get_webhook_shutdown_timeout(self) -> float:
|
||||
try:
|
||||
timeout = float(self.WEBHOOK_WORKER_SHUTDOWN_TIMEOUT)
|
||||
except (TypeError, ValueError):
|
||||
timeout = 30.0
|
||||
return max(1.0, timeout)
|
||||
|
||||
def get_telegram_webhook_url(self) -> Optional[str]:
|
||||
base_url = (self.WEBHOOK_URL or "").strip()
|
||||
if not base_url:
|
||||
return None
|
||||
path = self.get_telegram_webhook_path()
|
||||
return f"{base_url.rstrip('/')}{path}"
|
||||
|
||||
def get_miniapp_static_path(self) -> Path:
|
||||
raw_path = (self.MINIAPP_STATIC_PATH or "miniapp").strip()
|
||||
if not raw_path:
|
||||
raw_path = "miniapp"
|
||||
return Path(raw_path)
|
||||
|
||||
|
||||
model_config = {
|
||||
"env_file": ".env",
|
||||
"env_file_encoding": "utf-8",
|
||||
"extra": "ignore"
|
||||
"extra": "ignore"
|
||||
}
|
||||
|
||||
@field_validator("TIMEZONE")
|
||||
@classmethod
|
||||
def validate_timezone(cls, value: str) -> str:
|
||||
try:
|
||||
ZoneInfo(value)
|
||||
except Exception as exc: # pragma: no cover - defensive validation
|
||||
raise ValueError(
|
||||
f"Некорректный идентификатор часового пояса: {value}"
|
||||
) from exc
|
||||
return value
|
||||
|
||||
|
||||
settings = Settings()
|
||||
ENV_OVERRIDE_KEYS = set(settings.model_fields_set)
|
||||
|
||||
_PERIOD_PRICE_FIELDS: Dict[int, str] = {
|
||||
14: "PRICE_14_DAYS",
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
"""Database package exports."""
|
||||
|
||||
from .database import (
|
||||
DatabaseManager,
|
||||
batch_ops,
|
||||
close_db,
|
||||
db_manager,
|
||||
get_db,
|
||||
get_db_read_only,
|
||||
get_pool_metrics,
|
||||
init_db,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"DatabaseManager",
|
||||
"batch_ops",
|
||||
"close_db",
|
||||
"db_manager",
|
||||
"get_db",
|
||||
"get_db_read_only",
|
||||
"get_pool_metrics",
|
||||
"init_db",
|
||||
]
|
||||
@@ -1,156 +0,0 @@
|
||||
"""CRUD-операции для платежей Platega."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Any, Optional
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.models import PlategaPayment
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def create_platega_payment(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
user_id: int,
|
||||
amount_kopeks: int,
|
||||
currency: str,
|
||||
description: Optional[str],
|
||||
status: str,
|
||||
payment_method_code: int,
|
||||
correlation_id: str,
|
||||
platega_transaction_id: Optional[str],
|
||||
redirect_url: Optional[str],
|
||||
return_url: Optional[str],
|
||||
failed_url: Optional[str],
|
||||
payload: Optional[str],
|
||||
metadata: Optional[dict[str, Any]] = None,
|
||||
expires_at: Optional[datetime] = None,
|
||||
) -> PlategaPayment:
|
||||
payment = PlategaPayment(
|
||||
user_id=user_id,
|
||||
amount_kopeks=amount_kopeks,
|
||||
currency=currency,
|
||||
description=description,
|
||||
status=status,
|
||||
payment_method_code=payment_method_code,
|
||||
correlation_id=correlation_id,
|
||||
platega_transaction_id=platega_transaction_id,
|
||||
redirect_url=redirect_url,
|
||||
return_url=return_url,
|
||||
failed_url=failed_url,
|
||||
payload=payload,
|
||||
metadata_json=metadata or {},
|
||||
expires_at=expires_at,
|
||||
)
|
||||
|
||||
db.add(payment)
|
||||
await db.commit()
|
||||
await db.refresh(payment)
|
||||
|
||||
logger.info(
|
||||
"Создан Platega платеж #%s (tx=%s) на сумму %s копеек для пользователя %s",
|
||||
payment.id,
|
||||
platega_transaction_id,
|
||||
amount_kopeks,
|
||||
user_id,
|
||||
)
|
||||
|
||||
return payment
|
||||
|
||||
|
||||
async def get_platega_payment_by_id(
|
||||
db: AsyncSession, payment_id: int
|
||||
) -> Optional[PlategaPayment]:
|
||||
result = await db.execute(
|
||||
select(PlategaPayment).where(PlategaPayment.id == payment_id)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_platega_payment_by_id_for_update(
|
||||
db: AsyncSession, payment_id: int
|
||||
) -> Optional[PlategaPayment]:
|
||||
result = await db.execute(
|
||||
select(PlategaPayment)
|
||||
.where(PlategaPayment.id == payment_id)
|
||||
.with_for_update()
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_platega_payment_by_transaction_id(
|
||||
db: AsyncSession, transaction_id: str
|
||||
) -> Optional[PlategaPayment]:
|
||||
result = await db.execute(
|
||||
select(PlategaPayment).where(
|
||||
PlategaPayment.platega_transaction_id == transaction_id
|
||||
)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_platega_payment_by_correlation_id(
|
||||
db: AsyncSession, correlation_id: str
|
||||
) -> Optional[PlategaPayment]:
|
||||
result = await db.execute(
|
||||
select(PlategaPayment).where(
|
||||
PlategaPayment.correlation_id == correlation_id
|
||||
)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def update_platega_payment(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
payment: PlategaPayment,
|
||||
status: Optional[str] = None,
|
||||
is_paid: Optional[bool] = None,
|
||||
paid_at: Optional[datetime] = None,
|
||||
platega_transaction_id: Optional[str] = None,
|
||||
redirect_url: Optional[str] = None,
|
||||
callback_payload: Optional[dict[str, Any]] = None,
|
||||
metadata: Optional[dict[str, Any]] = None,
|
||||
expires_at: Optional[datetime] = None,
|
||||
) -> PlategaPayment:
|
||||
if status is not None:
|
||||
payment.status = status
|
||||
if is_paid is not None:
|
||||
payment.is_paid = is_paid
|
||||
if paid_at is not None:
|
||||
payment.paid_at = paid_at
|
||||
if platega_transaction_id and not payment.platega_transaction_id:
|
||||
payment.platega_transaction_id = platega_transaction_id
|
||||
if redirect_url is not None:
|
||||
payment.redirect_url = redirect_url
|
||||
if callback_payload is not None:
|
||||
payment.callback_payload = callback_payload
|
||||
if metadata is not None:
|
||||
payment.metadata_json = metadata
|
||||
if expires_at is not None:
|
||||
payment.expires_at = expires_at
|
||||
|
||||
payment.updated_at = datetime.utcnow()
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(payment)
|
||||
return payment
|
||||
|
||||
|
||||
async def link_platega_payment_to_transaction(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
payment: PlategaPayment,
|
||||
transaction_id: int,
|
||||
) -> PlategaPayment:
|
||||
payment.transaction_id = transaction_id
|
||||
payment.updated_at = datetime.utcnow()
|
||||
await db.commit()
|
||||
await db.refresh(payment)
|
||||
return payment
|
||||
@@ -1,11 +1,11 @@
|
||||
import logging
|
||||
from typing import Dict, List, Optional, Set, Tuple
|
||||
from typing import Dict, List, Optional, Tuple
|
||||
|
||||
from sqlalchemy import func, select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.database.models import PromoGroup, User, UserPromoGroup
|
||||
from app.database.models import PromoGroup, User
|
||||
|
||||
|
||||
def _normalize_period_discounts(period_discounts: Optional[Dict[int, int]]) -> Dict[int, int]:
|
||||
@@ -38,7 +38,7 @@ async def get_promo_groups_with_counts(
|
||||
select(PromoGroup, func.count(User.id))
|
||||
.outerjoin(User, User.promo_group_id == PromoGroup.id)
|
||||
.group_by(PromoGroup.id)
|
||||
.order_by(PromoGroup.priority.desc(), PromoGroup.name)
|
||||
.order_by(PromoGroup.is_default.desc(), PromoGroup.name)
|
||||
)
|
||||
|
||||
if offset:
|
||||
@@ -89,7 +89,6 @@ async def create_promo_group(
|
||||
db: AsyncSession,
|
||||
name: str,
|
||||
*,
|
||||
priority: int = 0,
|
||||
server_discount_percent: int,
|
||||
traffic_discount_percent: int,
|
||||
device_discount_percent: int,
|
||||
@@ -111,7 +110,6 @@ async def create_promo_group(
|
||||
|
||||
promo_group = PromoGroup(
|
||||
name=name.strip(),
|
||||
priority=max(0, priority),
|
||||
server_discount_percent=max(0, min(100, server_discount_percent)),
|
||||
traffic_discount_percent=max(0, min(100, traffic_discount_percent)),
|
||||
device_discount_percent=max(0, min(100, device_discount_percent)),
|
||||
@@ -154,7 +152,6 @@ async def update_promo_group(
|
||||
group: PromoGroup,
|
||||
*,
|
||||
name: Optional[str] = None,
|
||||
priority: Optional[int] = None,
|
||||
server_discount_percent: Optional[int] = None,
|
||||
traffic_discount_percent: Optional[int] = None,
|
||||
device_discount_percent: Optional[int] = None,
|
||||
@@ -165,8 +162,6 @@ async def update_promo_group(
|
||||
) -> PromoGroup:
|
||||
if name is not None:
|
||||
group.name = name.strip()
|
||||
if priority is not None:
|
||||
group.priority = max(0, priority)
|
||||
if server_discount_percent is not None:
|
||||
group.server_discount_percent = max(0, min(100, server_discount_percent))
|
||||
if traffic_discount_percent is not None:
|
||||
@@ -233,42 +228,11 @@ async def delete_promo_group(db: AsyncSession, group: PromoGroup) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
# Получаем список пользователей, связанных с удаляемой промогруппой
|
||||
affected_user_ids: Set[int] = set()
|
||||
|
||||
user_ids_result = await db.execute(
|
||||
select(User.id).where(User.promo_group_id == group.id)
|
||||
)
|
||||
affected_user_ids.update(user_ids_result.scalars().all())
|
||||
|
||||
promo_group_links_result = await db.execute(
|
||||
select(UserPromoGroup.user_id).where(UserPromoGroup.promo_group_id == group.id)
|
||||
)
|
||||
affected_user_ids.update(promo_group_links_result.scalars().all())
|
||||
|
||||
await db.execute(
|
||||
update(User)
|
||||
.where(User.promo_group_id == group.id)
|
||||
.values(promo_group_id=default_group.id)
|
||||
)
|
||||
|
||||
if affected_user_ids:
|
||||
existing_defaults_result = await db.execute(
|
||||
select(UserPromoGroup.user_id)
|
||||
.where(UserPromoGroup.promo_group_id == default_group.id)
|
||||
.where(UserPromoGroup.user_id.in_(affected_user_ids))
|
||||
)
|
||||
existing_default_user_ids = set(existing_defaults_result.scalars().all())
|
||||
|
||||
for user_id in affected_user_ids - existing_default_user_ids:
|
||||
db.add(
|
||||
UserPromoGroup(
|
||||
user_id=user_id,
|
||||
promo_group_id=default_group.id,
|
||||
assigned_by="system",
|
||||
)
|
||||
)
|
||||
|
||||
await db.delete(group)
|
||||
await db.commit()
|
||||
|
||||
|
||||
@@ -13,50 +13,12 @@ logger = logging.getLogger(__name__)
|
||||
async def get_promocode_by_code(db: AsyncSession, code: str) -> Optional[PromoCode]:
|
||||
result = await db.execute(
|
||||
select(PromoCode)
|
||||
.options(
|
||||
selectinload(PromoCode.uses),
|
||||
selectinload(PromoCode.promo_group)
|
||||
)
|
||||
.options(selectinload(PromoCode.uses))
|
||||
.where(PromoCode.code == code.upper())
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_promocode_by_id(db: AsyncSession, promo_id: int) -> Optional[PromoCode]:
|
||||
"""
|
||||
Получает промокод по ID с eager loading всех связанных данных.
|
||||
Используется для избежания lazy loading в async контексте.
|
||||
"""
|
||||
result = await db.execute(
|
||||
select(PromoCode)
|
||||
.options(
|
||||
selectinload(PromoCode.uses),
|
||||
selectinload(PromoCode.promo_group)
|
||||
)
|
||||
.where(PromoCode.id == promo_id)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def check_promocode_validity(db: AsyncSession, code: str) -> dict:
|
||||
"""
|
||||
Проверяет существование и валидность промокода без активации.
|
||||
Возвращает словарь с информацией о промокоде.
|
||||
"""
|
||||
promocode = await get_promocode_by_code(db, code)
|
||||
|
||||
if not promocode:
|
||||
return {"valid": False, "error": "not_found", "promocode": None}
|
||||
|
||||
if not promocode.is_valid:
|
||||
if promocode.current_uses >= promocode.max_uses:
|
||||
return {"valid": False, "error": "used", "promocode": None}
|
||||
else:
|
||||
return {"valid": False, "error": "expired", "promocode": None}
|
||||
|
||||
return {"valid": True, "error": None, "promocode": promocode}
|
||||
|
||||
|
||||
async def create_promocode(
|
||||
db: AsyncSession,
|
||||
code: str,
|
||||
@@ -65,8 +27,7 @@ async def create_promocode(
|
||||
subscription_days: int = 0,
|
||||
max_uses: int = 1,
|
||||
valid_until: Optional[datetime] = None,
|
||||
created_by: Optional[int] = None,
|
||||
promo_group_id: Optional[int] = None
|
||||
created_by: Optional[int] = None
|
||||
) -> PromoCode:
|
||||
|
||||
promocode = PromoCode(
|
||||
@@ -76,18 +37,14 @@ async def create_promocode(
|
||||
subscription_days=subscription_days,
|
||||
max_uses=max_uses,
|
||||
valid_until=valid_until,
|
||||
created_by=created_by,
|
||||
promo_group_id=promo_group_id
|
||||
created_by=created_by
|
||||
)
|
||||
|
||||
db.add(promocode)
|
||||
await db.commit()
|
||||
await db.refresh(promocode)
|
||||
|
||||
if promo_group_id:
|
||||
logger.info(f"✅ Создан промокод: {code} с промогруппой ID {promo_group_id}")
|
||||
else:
|
||||
logger.info(f"✅ Создан промокод: {code}")
|
||||
|
||||
logger.info(f"✅ Создан промокод: {code}")
|
||||
return promocode
|
||||
|
||||
|
||||
@@ -96,9 +53,9 @@ async def use_promocode(
|
||||
promocode_id: int,
|
||||
user_id: int
|
||||
) -> bool:
|
||||
|
||||
|
||||
try:
|
||||
promocode = await get_promocode_by_id(db, promocode_id)
|
||||
promocode = await db.get(PromoCode, promocode_id)
|
||||
if not promocode:
|
||||
return False
|
||||
|
||||
@@ -186,17 +143,14 @@ async def get_promocodes_list(
|
||||
limit: int = 50,
|
||||
is_active: Optional[bool] = None
|
||||
) -> List[PromoCode]:
|
||||
|
||||
query = select(PromoCode).options(
|
||||
selectinload(PromoCode.uses),
|
||||
selectinload(PromoCode.promo_group)
|
||||
)
|
||||
|
||||
|
||||
query = select(PromoCode).options(selectinload(PromoCode.uses))
|
||||
|
||||
if is_active is not None:
|
||||
query = query.where(PromoCode.is_active == is_active)
|
||||
|
||||
|
||||
query = query.order_by(PromoCode.created_at.desc()).offset(offset).limit(limit)
|
||||
|
||||
|
||||
result = await db.execute(query)
|
||||
return result.scalars().all()
|
||||
|
||||
|
||||
@@ -47,7 +47,6 @@ async def create_server_squad(
|
||||
max_users: int = None,
|
||||
is_available: bool = True,
|
||||
is_trial_eligible: bool = False,
|
||||
sort_order: int = 0,
|
||||
promo_group_ids: Optional[Iterable[int]] = None,
|
||||
) -> ServerSquad:
|
||||
|
||||
@@ -81,7 +80,6 @@ async def create_server_squad(
|
||||
max_users=max_users,
|
||||
is_available=is_available,
|
||||
is_trial_eligible=is_trial_eligible,
|
||||
sort_order=sort_order,
|
||||
allowed_promo_groups=promo_groups,
|
||||
)
|
||||
|
||||
@@ -262,15 +260,8 @@ async def update_server_squad(
|
||||
) -> Optional[ServerSquad]:
|
||||
|
||||
valid_fields = {
|
||||
"display_name",
|
||||
"original_name",
|
||||
"country_code",
|
||||
"price_kopeks",
|
||||
"description",
|
||||
"max_users",
|
||||
"is_available",
|
||||
"sort_order",
|
||||
"is_trial_eligible",
|
||||
'display_name', 'country_code', 'price_kopeks', 'description',
|
||||
'max_users', 'is_available', 'sort_order', 'is_trial_eligible'
|
||||
}
|
||||
|
||||
filtered_updates = {k: v for k, v in updates.items() if k in valid_fields}
|
||||
@@ -515,6 +506,7 @@ async def choose_random_trial_server_squad(
|
||||
|
||||
async def get_random_trial_squad_uuid(
|
||||
db: AsyncSession,
|
||||
fallback_uuid: Optional[str] = None,
|
||||
) -> Optional[str]:
|
||||
|
||||
squad = await choose_random_trial_server_squad(db)
|
||||
@@ -522,7 +514,7 @@ async def get_random_trial_squad_uuid(
|
||||
if squad:
|
||||
return squad.squad_uuid
|
||||
|
||||
return None
|
||||
return fallback_uuid
|
||||
|
||||
|
||||
def _generate_display_name(original_name: str) -> str:
|
||||
|
||||
@@ -11,12 +11,10 @@ from app.database.models import (
|
||||
User,
|
||||
SubscriptionServer,
|
||||
PromoGroup,
|
||||
UserPromoGroup,
|
||||
)
|
||||
from app.database.crud.notification import clear_notifications
|
||||
from app.utils.pricing_utils import calculate_months_from_days, get_remaining_months
|
||||
from app.config import settings
|
||||
from app.utils.timezone import format_local_datetime
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -43,19 +41,21 @@ async def create_trial_subscription(
|
||||
user_id: int,
|
||||
duration_days: int = None,
|
||||
traffic_limit_gb: int = None,
|
||||
device_limit: Optional[int] = None,
|
||||
device_limit: int = None,
|
||||
squad_uuid: str = None
|
||||
) -> Subscription:
|
||||
|
||||
duration_days = duration_days or settings.TRIAL_DURATION_DAYS
|
||||
traffic_limit_gb = traffic_limit_gb or settings.TRIAL_TRAFFIC_LIMIT_GB
|
||||
if device_limit is None:
|
||||
device_limit = settings.TRIAL_DEVICE_LIMIT
|
||||
device_limit = device_limit or settings.TRIAL_DEVICE_LIMIT
|
||||
if not squad_uuid:
|
||||
try:
|
||||
from app.database.crud.server_squad import get_random_trial_squad_uuid
|
||||
|
||||
squad_uuid = await get_random_trial_squad_uuid(db)
|
||||
squad_uuid = await get_random_trial_squad_uuid(
|
||||
db,
|
||||
settings.TRIAL_SQUAD_UUID,
|
||||
)
|
||||
|
||||
if squad_uuid:
|
||||
logger.debug(
|
||||
@@ -69,6 +69,7 @@ async def create_trial_subscription(
|
||||
user_id,
|
||||
error,
|
||||
)
|
||||
squad_uuid = settings.TRIAL_SQUAD_UUID
|
||||
|
||||
end_date = datetime.utcnow() + timedelta(days=duration_days)
|
||||
|
||||
@@ -125,16 +126,13 @@ async def create_paid_subscription(
|
||||
user_id: int,
|
||||
duration_days: int,
|
||||
traffic_limit_gb: int = 0,
|
||||
device_limit: Optional[int] = None,
|
||||
device_limit: int = 1,
|
||||
connected_squads: List[str] = None,
|
||||
update_server_counters: bool = False,
|
||||
) -> Subscription:
|
||||
|
||||
end_date = datetime.utcnow() + timedelta(days=duration_days)
|
||||
|
||||
if device_limit is None:
|
||||
device_limit = settings.DEFAULT_DEVICE_LIMIT
|
||||
|
||||
subscription = Subscription(
|
||||
user_id=user_id,
|
||||
status=SubscriptionStatus.ACTIVE.value,
|
||||
@@ -192,27 +190,10 @@ async def extend_subscription(
|
||||
days: int
|
||||
) -> Subscription:
|
||||
current_time = datetime.utcnow()
|
||||
|
||||
|
||||
logger.info(f"🔄 Продление подписки {subscription.id} на {days} дней")
|
||||
logger.info(f"📊 Текущие параметры: статус={subscription.status}, окончание={subscription.end_date}")
|
||||
|
||||
# НОВОЕ: Вычисляем бонусные дни от триала ДО изменения end_date
|
||||
bonus_days = 0
|
||||
if subscription.is_trial and settings.TRIAL_ADD_REMAINING_DAYS_TO_PAID:
|
||||
# Вычисляем остаток триала
|
||||
if subscription.end_date and subscription.end_date > current_time:
|
||||
remaining = subscription.end_date - current_time
|
||||
if remaining.total_seconds() > 0:
|
||||
bonus_days = max(0, remaining.days)
|
||||
logger.info(
|
||||
"🎁 Обнаружен остаток триала: %s дней для подписки %s",
|
||||
bonus_days,
|
||||
subscription.id,
|
||||
)
|
||||
|
||||
# Применяем продление с учетом бонусных дней
|
||||
total_days = days + bonus_days
|
||||
|
||||
|
||||
if days < 0:
|
||||
subscription.end_date = subscription.end_date + timedelta(days=days)
|
||||
logger.info(
|
||||
@@ -221,15 +202,27 @@ async def extend_subscription(
|
||||
subscription.end_date,
|
||||
)
|
||||
elif subscription.end_date > current_time:
|
||||
subscription.end_date = subscription.end_date + timedelta(days=total_days)
|
||||
logger.info(f"📅 Подписка активна, добавляем {total_days} дней ({days} + {bonus_days} бонус) к текущей дате окончания")
|
||||
subscription.end_date = subscription.end_date + timedelta(days=days)
|
||||
logger.info(f"📅 Подписка активна, добавляем {days} дней к текущей дате окончания")
|
||||
else:
|
||||
subscription.end_date = current_time + timedelta(days=total_days)
|
||||
logger.info(f"📅 Подписка истекла, устанавливаем новую дату окончания на {total_days} дней ({days} + {bonus_days} бонус)")
|
||||
subscription.end_date = current_time + timedelta(days=days)
|
||||
logger.info(f"📅 Подписка истекла, устанавливаем новую дату окончания")
|
||||
|
||||
# УДАЛЕНО: Автоматическая конвертация триала по длительности
|
||||
# Теперь триал конвертируется ТОЛЬКО после успешного коммита продления
|
||||
# и ТОЛЬКО вызывающей функцией (например, _auto_extend_subscription)
|
||||
if subscription.is_trial:
|
||||
start_date = subscription.start_date or current_time
|
||||
total_duration = subscription.end_date - start_date
|
||||
max_trial_duration = timedelta(days=settings.TRIAL_DURATION_DAYS)
|
||||
|
||||
if total_duration > max_trial_duration:
|
||||
subscription.is_trial = False
|
||||
logger.info(
|
||||
"🎯 Подписка %s автоматически переведена из триальной в платную после продления"
|
||||
", итоговая длительность: %s дней",
|
||||
subscription.id,
|
||||
total_duration.days,
|
||||
)
|
||||
if subscription.user:
|
||||
subscription.user.has_had_paid_subscription = True
|
||||
|
||||
# Логируем статус подписки перед проверкой
|
||||
logger.info(f"🔄 Продление подписки {subscription.id}, текущий статус: {subscription.status}, дни: {days}")
|
||||
@@ -917,7 +910,7 @@ async def get_subscription_renewal_cost(
|
||||
result = await db.execute(
|
||||
select(Subscription)
|
||||
.options(
|
||||
selectinload(Subscription.user).selectinload(User.user_promo_groups).selectinload(UserPromoGroup.promo_group),
|
||||
selectinload(Subscription.user).selectinload(User.promo_group),
|
||||
)
|
||||
.where(Subscription.id == subscription_id)
|
||||
)
|
||||
@@ -1134,13 +1127,7 @@ async def check_and_update_subscription_status(
|
||||
|
||||
current_time = datetime.utcnow()
|
||||
|
||||
logger.info(
|
||||
"🔍 Проверка статуса подписки %s, текущий статус: %s, дата окончания: %s, текущее время: %s",
|
||||
subscription.id,
|
||||
subscription.status,
|
||||
format_local_datetime(subscription.end_date),
|
||||
format_local_datetime(current_time),
|
||||
)
|
||||
logger.info(f"🔍 Проверка статуса подписки {subscription.id}, текущий статус: {subscription.status}, дата окончания: {subscription.end_date}, текущее время: {current_time}")
|
||||
|
||||
if (subscription.status == SubscriptionStatus.ACTIVE.value and
|
||||
subscription.end_date <= current_time):
|
||||
@@ -1157,63 +1144,6 @@ async def check_and_update_subscription_status(
|
||||
|
||||
return subscription
|
||||
|
||||
async def create_subscription_no_commit(
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
status: str = "trial",
|
||||
is_trial: bool = True,
|
||||
end_date: datetime = None,
|
||||
traffic_limit_gb: int = 10,
|
||||
traffic_used_gb: float = 0.0,
|
||||
device_limit: int = 1,
|
||||
connected_squads: list = None,
|
||||
remnawave_short_uuid: str = None,
|
||||
subscription_url: str = "",
|
||||
subscription_crypto_link: str = "",
|
||||
autopay_enabled: Optional[bool] = None,
|
||||
autopay_days_before: Optional[int] = None,
|
||||
) -> Subscription:
|
||||
"""
|
||||
Создает подписку без немедленного коммита для пакетной обработки
|
||||
"""
|
||||
|
||||
if end_date is None:
|
||||
end_date = datetime.utcnow() + timedelta(days=3)
|
||||
|
||||
if connected_squads is None:
|
||||
connected_squads = []
|
||||
|
||||
subscription = Subscription(
|
||||
user_id=user_id,
|
||||
status=status,
|
||||
is_trial=is_trial,
|
||||
end_date=end_date,
|
||||
traffic_limit_gb=traffic_limit_gb,
|
||||
traffic_used_gb=traffic_used_gb,
|
||||
device_limit=device_limit,
|
||||
connected_squads=connected_squads,
|
||||
remnawave_short_uuid=remnawave_short_uuid,
|
||||
subscription_url=subscription_url,
|
||||
subscription_crypto_link=subscription_crypto_link,
|
||||
autopay_enabled=(
|
||||
settings.is_autopay_enabled_by_default()
|
||||
if autopay_enabled is None
|
||||
else autopay_enabled
|
||||
),
|
||||
autopay_days_before=(
|
||||
settings.DEFAULT_AUTOPAY_DAYS_BEFORE
|
||||
if autopay_days_before is None
|
||||
else autopay_days_before
|
||||
),
|
||||
)
|
||||
|
||||
db.add(subscription)
|
||||
# Не коммитим сразу, оставляем для пакетной обработки
|
||||
|
||||
logger.info(f"✅ Подготовлена подписка для пользователя {user_id} (ожидает коммита)")
|
||||
return subscription
|
||||
|
||||
|
||||
async def create_subscription(
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
|
||||
+15
-165
@@ -5,7 +5,7 @@ from datetime import datetime, timedelta
|
||||
from typing import Optional, List, Dict
|
||||
from sqlalchemy import select, and_, or_, func, case, nullslast, text
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload, joinedload
|
||||
from sqlalchemy.orm import selectinload
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
|
||||
from app.database.models import (
|
||||
@@ -15,7 +15,6 @@ from app.database.models import (
|
||||
SubscriptionStatus,
|
||||
Transaction,
|
||||
PromoGroup,
|
||||
UserPromoGroup,
|
||||
PaymentMethod,
|
||||
TransactionType,
|
||||
)
|
||||
@@ -39,16 +38,14 @@ async def get_user_by_id(db: AsyncSession, user_id: int) -> Optional[User]:
|
||||
select(User)
|
||||
.options(
|
||||
selectinload(User.subscription),
|
||||
selectinload(User.user_promo_groups).selectinload(UserPromoGroup.promo_group),
|
||||
selectinload(User.referrer),
|
||||
selectinload(User.promo_group),
|
||||
selectinload(User.referrer),
|
||||
)
|
||||
.where(User.id == user_id)
|
||||
)
|
||||
user = result.scalar_one_or_none()
|
||||
|
||||
if user and user.subscription:
|
||||
# Загружаем дополнительные зависимости для subscription
|
||||
_ = user.subscription.is_active
|
||||
|
||||
return user
|
||||
@@ -59,64 +56,26 @@ async def get_user_by_telegram_id(db: AsyncSession, telegram_id: int) -> Optiona
|
||||
select(User)
|
||||
.options(
|
||||
selectinload(User.subscription),
|
||||
selectinload(User.user_promo_groups).selectinload(UserPromoGroup.promo_group),
|
||||
selectinload(User.referrer),
|
||||
selectinload(User.promo_group),
|
||||
selectinload(User.referrer),
|
||||
)
|
||||
.where(User.telegram_id == telegram_id)
|
||||
)
|
||||
user = result.scalar_one_or_none()
|
||||
|
||||
|
||||
if user and user.subscription:
|
||||
# Загружаем дополнительные зависимости для subscription
|
||||
_ = user.subscription.is_active
|
||||
|
||||
return user
|
||||
|
||||
|
||||
async def get_user_by_username(db: AsyncSession, username: str) -> Optional[User]:
|
||||
if not username:
|
||||
return None
|
||||
|
||||
normalized = username.lower()
|
||||
|
||||
result = await db.execute(
|
||||
select(User)
|
||||
.options(
|
||||
selectinload(User.subscription),
|
||||
selectinload(User.user_promo_groups).selectinload(UserPromoGroup.promo_group),
|
||||
selectinload(User.referrer),
|
||||
selectinload(User.promo_group),
|
||||
)
|
||||
.where(func.lower(User.username) == normalized)
|
||||
)
|
||||
|
||||
user = result.scalar_one_or_none()
|
||||
|
||||
if user and user.subscription:
|
||||
# Загружаем дополнительные зависимости для subscription
|
||||
_ = user.subscription.is_active
|
||||
|
||||
|
||||
return user
|
||||
|
||||
|
||||
async def get_user_by_referral_code(db: AsyncSession, referral_code: str) -> Optional[User]:
|
||||
result = await db.execute(
|
||||
select(User)
|
||||
.options(
|
||||
selectinload(User.subscription),
|
||||
selectinload(User.promo_group),
|
||||
selectinload(User.referrer),
|
||||
)
|
||||
.options(selectinload(User.promo_group))
|
||||
.where(User.referral_code == referral_code)
|
||||
)
|
||||
user = result.scalar_one_or_none()
|
||||
|
||||
if user and user.subscription:
|
||||
# Загружаем дополнительные зависимости для subscription
|
||||
_ = user.subscription.is_active
|
||||
|
||||
return user
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def create_unique_referral_code(db: AsyncSession) -> str:
|
||||
@@ -163,57 +122,6 @@ async def _get_or_create_default_promo_group(db: AsyncSession) -> PromoGroup:
|
||||
return default_group
|
||||
|
||||
|
||||
async def create_user_no_commit(
|
||||
db: AsyncSession,
|
||||
telegram_id: int,
|
||||
username: str = None,
|
||||
first_name: str = None,
|
||||
last_name: str = None,
|
||||
language: str = "ru",
|
||||
referred_by_id: int = None,
|
||||
referral_code: str = None
|
||||
) -> User:
|
||||
"""
|
||||
Создает пользователя без немедленного коммита для пакетной обработки
|
||||
"""
|
||||
|
||||
if not referral_code:
|
||||
referral_code = await create_unique_referral_code(db)
|
||||
|
||||
default_group = await _get_or_create_default_promo_group(db)
|
||||
promo_group_id = default_group.id
|
||||
|
||||
safe_first = sanitize_telegram_name(first_name)
|
||||
safe_last = sanitize_telegram_name(last_name)
|
||||
user = User(
|
||||
telegram_id=telegram_id,
|
||||
username=username,
|
||||
first_name=safe_first,
|
||||
last_name=safe_last,
|
||||
language=language,
|
||||
referred_by_id=referred_by_id,
|
||||
referral_code=referral_code,
|
||||
balance_kopeks=0,
|
||||
has_had_paid_subscription=False,
|
||||
has_made_first_topup=False,
|
||||
promo_group_id=promo_group_id,
|
||||
)
|
||||
|
||||
db.add(user)
|
||||
|
||||
# Обязательно выполняем flush, чтобы получить присвоенный первичный ключ
|
||||
await db.flush()
|
||||
|
||||
# Сохраняем ссылку на группу, чтобы дальнейшие операции могли её использовать
|
||||
user.promo_group = default_group
|
||||
|
||||
# Не коммитим сразу, оставляем для пакетной обработки
|
||||
logger.info(
|
||||
f"✅ Подготовлен пользователь {telegram_id} с реферальным кодом {referral_code} (ожидает коммита)"
|
||||
)
|
||||
return user
|
||||
|
||||
|
||||
async def create_user(
|
||||
db: AsyncSession,
|
||||
telegram_id: int,
|
||||
@@ -592,11 +500,7 @@ async def get_users_list(
|
||||
order_by_purchase_count: bool = False
|
||||
) -> List[User]:
|
||||
|
||||
query = select(User).options(
|
||||
selectinload(User.subscription),
|
||||
selectinload(User.promo_group),
|
||||
selectinload(User.referrer),
|
||||
)
|
||||
query = select(User).options(selectinload(User.subscription))
|
||||
|
||||
if status:
|
||||
query = query.where(User.status == status.value)
|
||||
@@ -610,14 +514,7 @@ async def get_users_list(
|
||||
]
|
||||
|
||||
if search.isdigit():
|
||||
try:
|
||||
search_int = int(search)
|
||||
# Добавляем условие поиска по telegram_id, который является BigInteger
|
||||
# и может содержать большие значения, в отличие от User.id (INTEGER)
|
||||
conditions.append(User.telegram_id == search_int)
|
||||
except ValueError:
|
||||
# Если не удалось преобразовать в int, просто ищем по текстовым полям
|
||||
pass
|
||||
conditions.append(User.telegram_id == int(search))
|
||||
|
||||
query = query.where(or_(*conditions))
|
||||
|
||||
@@ -691,15 +588,7 @@ async def get_users_list(
|
||||
query = query.offset(offset).limit(limit)
|
||||
|
||||
result = await db.execute(query)
|
||||
users = result.scalars().all()
|
||||
|
||||
# Загружаем дополнительные зависимости для всех пользователей
|
||||
for user in users:
|
||||
if user and user.subscription:
|
||||
# Загружаем дополнительные зависимости для subscription
|
||||
_ = user.subscription.is_active
|
||||
|
||||
return users
|
||||
return result.scalars().all()
|
||||
|
||||
|
||||
async def get_users_count(
|
||||
@@ -722,14 +611,7 @@ async def get_users_count(
|
||||
]
|
||||
|
||||
if search.isdigit():
|
||||
try:
|
||||
search_int = int(search)
|
||||
# Добавляем условие поиска по telegram_id, который является BigInteger
|
||||
# и может содержать большие значения, в отличие от User.id (INTEGER)
|
||||
conditions.append(User.telegram_id == search_int)
|
||||
except ValueError:
|
||||
# Если не удалось преобразовать в int, просто ищем по текстовым полям
|
||||
pass
|
||||
conditions.append(User.telegram_id == int(search))
|
||||
|
||||
query = query.where(or_(*conditions))
|
||||
|
||||
@@ -798,22 +680,12 @@ async def get_referrals(db: AsyncSession, user_id: int) -> List[User]:
|
||||
select(User)
|
||||
.options(
|
||||
selectinload(User.subscription),
|
||||
selectinload(User.user_promo_groups).selectinload(UserPromoGroup.promo_group),
|
||||
selectinload(User.referrer),
|
||||
selectinload(User.promo_group),
|
||||
)
|
||||
.where(User.referred_by_id == user_id)
|
||||
.order_by(User.created_at.desc())
|
||||
)
|
||||
users = result.scalars().all()
|
||||
|
||||
# Загружаем дополнительные зависимости для всех пользователей
|
||||
for user in users:
|
||||
if user and user.subscription:
|
||||
# Загружаем дополнительные зависимости для subscription
|
||||
_ = user.subscription.is_active
|
||||
|
||||
return users
|
||||
return result.scalars().all()
|
||||
|
||||
|
||||
async def get_users_for_promo_segment(db: AsyncSession, segment: str) -> List[User]:
|
||||
@@ -821,11 +693,7 @@ async def get_users_for_promo_segment(db: AsyncSession, segment: str) -> List[Us
|
||||
|
||||
base_query = (
|
||||
select(User)
|
||||
.options(
|
||||
selectinload(User.subscription),
|
||||
selectinload(User.promo_group),
|
||||
selectinload(User.referrer),
|
||||
)
|
||||
.options(selectinload(User.subscription))
|
||||
.where(User.status == UserStatus.ACTIVE.value)
|
||||
)
|
||||
|
||||
@@ -870,15 +738,7 @@ async def get_users_for_promo_segment(db: AsyncSession, segment: str) -> List[Us
|
||||
return []
|
||||
|
||||
result = await db.execute(query.order_by(User.id))
|
||||
users = result.scalars().unique().all()
|
||||
|
||||
# Загружаем дополнительные зависимости для всех пользователей
|
||||
for user in users:
|
||||
if user and user.subscription:
|
||||
# Загружаем дополнительные зависимости для subscription
|
||||
_ = user.subscription.is_active
|
||||
|
||||
return users
|
||||
return result.scalars().unique().all()
|
||||
|
||||
|
||||
async def get_inactive_users(db: AsyncSession, months: int = 3) -> List[User]:
|
||||
@@ -888,8 +748,6 @@ async def get_inactive_users(db: AsyncSession, months: int = 3) -> List[User]:
|
||||
select(User)
|
||||
.options(
|
||||
selectinload(User.subscription),
|
||||
selectinload(User.user_promo_groups).selectinload(UserPromoGroup.promo_group),
|
||||
selectinload(User.referrer),
|
||||
selectinload(User.promo_group),
|
||||
)
|
||||
.where(
|
||||
@@ -899,15 +757,7 @@ async def get_inactive_users(db: AsyncSession, months: int = 3) -> List[User]:
|
||||
)
|
||||
)
|
||||
)
|
||||
users = result.scalars().all()
|
||||
|
||||
# Загружаем дополнительные зависимости для всех пользователей
|
||||
for user in users:
|
||||
if user and user.subscription:
|
||||
# Загружаем дополнительные зависимости для subscription
|
||||
_ = user.subscription.is_active
|
||||
|
||||
return users
|
||||
return result.scalars().all()
|
||||
|
||||
|
||||
async def delete_user(db: AsyncSession, user: User) -> bool:
|
||||
|
||||
@@ -1,311 +0,0 @@
|
||||
"""CRUD операции для связи пользователей с промогруппами (Many-to-Many)."""
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import List, Optional
|
||||
|
||||
from sqlalchemy import select, and_, desc
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.database.models import UserPromoGroup, PromoGroup, User
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def _sync_user_primary_promo_group(
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
) -> None:
|
||||
"""Синхронизирует колонку users.promo_group_id с primary промогруппой."""
|
||||
|
||||
try:
|
||||
result = await db.execute(
|
||||
select(UserPromoGroup.promo_group_id)
|
||||
.join(PromoGroup, UserPromoGroup.promo_group_id == PromoGroup.id)
|
||||
.where(UserPromoGroup.user_id == user_id)
|
||||
.order_by(desc(PromoGroup.priority), PromoGroup.id)
|
||||
)
|
||||
|
||||
first = result.first()
|
||||
new_primary_id = first[0] if first else None
|
||||
|
||||
user = await db.get(User, user_id)
|
||||
if not user:
|
||||
return
|
||||
|
||||
if user.promo_group_id != new_primary_id:
|
||||
user.promo_group_id = new_primary_id
|
||||
user.updated_at = datetime.utcnow()
|
||||
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
"Ошибка синхронизации primary промогруппы пользователя %s: %s",
|
||||
user_id,
|
||||
error,
|
||||
)
|
||||
|
||||
|
||||
async def sync_user_primary_promo_group(
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
) -> None:
|
||||
"""Публичная обертка для синхронизации primary промогруппы пользователя."""
|
||||
|
||||
await _sync_user_primary_promo_group(db, user_id)
|
||||
|
||||
|
||||
async def add_user_to_promo_group(
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
promo_group_id: int,
|
||||
assigned_by: str = "admin"
|
||||
) -> Optional[UserPromoGroup]:
|
||||
"""
|
||||
Добавляет пользователю промогруппу.
|
||||
|
||||
Args:
|
||||
db: Сессия БД
|
||||
user_id: ID пользователя
|
||||
promo_group_id: ID промогруппы
|
||||
assigned_by: Кто назначил ('admin', 'system', 'auto', 'promocode')
|
||||
|
||||
Returns:
|
||||
UserPromoGroup или None если уже существует
|
||||
"""
|
||||
try:
|
||||
# Проверяем существование связи
|
||||
existing = await has_user_promo_group(db, user_id, promo_group_id)
|
||||
if existing:
|
||||
logger.info(f"Пользователь {user_id} уже имеет промогруппу {promo_group_id}")
|
||||
return None
|
||||
|
||||
# Создаем новую связь
|
||||
user_promo_group = UserPromoGroup(
|
||||
user_id=user_id,
|
||||
promo_group_id=promo_group_id,
|
||||
assigned_by=assigned_by,
|
||||
)
|
||||
db.add(user_promo_group)
|
||||
await db.flush()
|
||||
|
||||
await _sync_user_primary_promo_group(db, user_id)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(user_promo_group)
|
||||
|
||||
logger.info(f"Пользователю {user_id} добавлена промогруппа {promo_group_id} ({assigned_by})")
|
||||
return user_promo_group
|
||||
|
||||
except Exception as error:
|
||||
logger.error(f"Ошибка добавления промогруппы пользователю: {error}")
|
||||
await db.rollback()
|
||||
return None
|
||||
|
||||
|
||||
async def remove_user_from_promo_group(
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
promo_group_id: int
|
||||
) -> bool:
|
||||
"""
|
||||
Удаляет промогруппу у пользователя.
|
||||
|
||||
Args:
|
||||
db: Сессия БД
|
||||
user_id: ID пользователя
|
||||
promo_group_id: ID промогруппы
|
||||
|
||||
Returns:
|
||||
True если удалено, False если связи не было
|
||||
"""
|
||||
try:
|
||||
result = await db.execute(
|
||||
select(UserPromoGroup).where(
|
||||
and_(
|
||||
UserPromoGroup.user_id == user_id,
|
||||
UserPromoGroup.promo_group_id == promo_group_id
|
||||
)
|
||||
)
|
||||
)
|
||||
user_promo_group = result.scalar_one_or_none()
|
||||
|
||||
if not user_promo_group:
|
||||
logger.warning(f"Связь пользователя {user_id} с промогруппой {promo_group_id} не найдена")
|
||||
return False
|
||||
|
||||
await db.delete(user_promo_group)
|
||||
await db.flush()
|
||||
|
||||
await _sync_user_primary_promo_group(db, user_id)
|
||||
|
||||
await db.commit()
|
||||
|
||||
logger.info(f"У пользователя {user_id} удалена промогруппа {promo_group_id}")
|
||||
return True
|
||||
|
||||
except Exception as error:
|
||||
logger.error(f"Ошибка удаления промогруппы у пользователя: {error}")
|
||||
await db.rollback()
|
||||
return False
|
||||
|
||||
|
||||
async def get_user_promo_groups(
|
||||
db: AsyncSession,
|
||||
user_id: int
|
||||
) -> List[UserPromoGroup]:
|
||||
"""
|
||||
Получает все промогруппы пользователя, отсортированные по приоритету.
|
||||
|
||||
Args:
|
||||
db: Сессия БД
|
||||
user_id: ID пользователя
|
||||
|
||||
Returns:
|
||||
Список UserPromoGroup с загруженными PromoGroup, отсортированный по приоритету DESC
|
||||
"""
|
||||
try:
|
||||
result = await db.execute(
|
||||
select(UserPromoGroup)
|
||||
.options(selectinload(UserPromoGroup.promo_group))
|
||||
.where(UserPromoGroup.user_id == user_id)
|
||||
.join(PromoGroup, UserPromoGroup.promo_group_id == PromoGroup.id)
|
||||
.order_by(desc(PromoGroup.priority), PromoGroup.id)
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
except Exception as error:
|
||||
logger.error(f"Ошибка получения промогрупп пользователя {user_id}: {error}")
|
||||
return []
|
||||
|
||||
|
||||
async def get_primary_user_promo_group(
|
||||
db: AsyncSession,
|
||||
user_id: int
|
||||
) -> Optional[PromoGroup]:
|
||||
"""
|
||||
Получает промогруппу пользователя с максимальным приоритетом.
|
||||
|
||||
Args:
|
||||
db: Сессия БД
|
||||
user_id: ID пользователя
|
||||
|
||||
Returns:
|
||||
PromoGroup с максимальным приоритетом или None
|
||||
"""
|
||||
try:
|
||||
user_promo_groups = await get_user_promo_groups(db, user_id)
|
||||
|
||||
if not user_promo_groups:
|
||||
return None
|
||||
|
||||
# Первая в списке имеет максимальный приоритет (список уже отсортирован)
|
||||
return user_promo_groups[0].promo_group if user_promo_groups[0].promo_group else None
|
||||
|
||||
except Exception as error:
|
||||
logger.error(f"Ошибка получения primary промогруппы пользователя {user_id}: {error}")
|
||||
return None
|
||||
|
||||
|
||||
async def has_user_promo_group(
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
promo_group_id: int
|
||||
) -> bool:
|
||||
"""
|
||||
Проверяет наличие промогруппы у пользователя.
|
||||
|
||||
Args:
|
||||
db: Сессия БД
|
||||
user_id: ID пользователя
|
||||
promo_group_id: ID промогруппы
|
||||
|
||||
Returns:
|
||||
True если пользователь уже имеет эту промогруппу
|
||||
"""
|
||||
try:
|
||||
result = await db.execute(
|
||||
select(UserPromoGroup).where(
|
||||
and_(
|
||||
UserPromoGroup.user_id == user_id,
|
||||
UserPromoGroup.promo_group_id == promo_group_id
|
||||
)
|
||||
)
|
||||
)
|
||||
return result.scalar_one_or_none() is not None
|
||||
|
||||
except Exception as error:
|
||||
logger.error(f"Ошибка проверки промогруппы пользователя: {error}")
|
||||
return False
|
||||
|
||||
|
||||
async def count_user_promo_groups(
|
||||
db: AsyncSession,
|
||||
user_id: int
|
||||
) -> int:
|
||||
"""
|
||||
Подсчитывает количество промогрупп у пользователя.
|
||||
|
||||
Args:
|
||||
db: Сессия БД
|
||||
user_id: ID пользователя
|
||||
|
||||
Returns:
|
||||
Количество промогрупп
|
||||
"""
|
||||
try:
|
||||
result = await db.execute(
|
||||
select(UserPromoGroup).where(UserPromoGroup.user_id == user_id)
|
||||
)
|
||||
return len(list(result.scalars().all()))
|
||||
|
||||
except Exception as error:
|
||||
logger.error(f"Ошибка подсчета промогрупп пользователя: {error}")
|
||||
return 0
|
||||
|
||||
|
||||
async def replace_user_promo_groups(
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
promo_group_ids: List[int],
|
||||
assigned_by: str = "admin"
|
||||
) -> bool:
|
||||
"""
|
||||
Заменяет все промогруппы пользователя на новый список.
|
||||
|
||||
Args:
|
||||
db: Сессия БД
|
||||
user_id: ID пользователя
|
||||
promo_group_ids: Список ID промогрупп
|
||||
assigned_by: Кто назначил
|
||||
|
||||
Returns:
|
||||
True если успешно
|
||||
"""
|
||||
try:
|
||||
# Удаляем все текущие промогруппы
|
||||
await db.execute(
|
||||
select(UserPromoGroup).where(UserPromoGroup.user_id == user_id)
|
||||
)
|
||||
result = await db.execute(
|
||||
select(UserPromoGroup).where(UserPromoGroup.user_id == user_id)
|
||||
)
|
||||
for upg in result.scalars().all():
|
||||
await db.delete(upg)
|
||||
|
||||
# Добавляем новые
|
||||
for promo_group_id in promo_group_ids:
|
||||
user_promo_group = UserPromoGroup(
|
||||
user_id=user_id,
|
||||
promo_group_id=promo_group_id,
|
||||
assigned_by=assigned_by
|
||||
)
|
||||
db.add(user_promo_group)
|
||||
|
||||
await db.commit()
|
||||
logger.info(f"Промогруппы пользователя {user_id} заменены на {promo_group_ids}")
|
||||
return True
|
||||
|
||||
except Exception as error:
|
||||
logger.error(f"Ошибка замены промогрупп пользователя: {error}")
|
||||
await db.rollback()
|
||||
return False
|
||||
+14
-360
@@ -1,167 +1,31 @@
|
||||
import logging
|
||||
from typing import AsyncGenerator, Optional
|
||||
from contextlib import asynccontextmanager
|
||||
from sqlalchemy.ext.asyncio import (
|
||||
AsyncSession,
|
||||
create_async_engine,
|
||||
async_sessionmaker,
|
||||
AsyncEngine
|
||||
)
|
||||
from sqlalchemy.pool import NullPool, AsyncAdaptedQueuePool
|
||||
from sqlalchemy import event, text, bindparam, inspect
|
||||
from sqlalchemy.engine import Engine
|
||||
import time
|
||||
from typing import AsyncGenerator
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker
|
||||
from sqlalchemy.pool import NullPool
|
||||
|
||||
from app.config import settings
|
||||
from app.database.models import Base
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ============================================================================
|
||||
# PRODUCTION-GRADE CONNECTION POOLING
|
||||
# ============================================================================
|
||||
|
||||
if settings.get_database_url().startswith("sqlite"):
|
||||
poolclass = NullPool
|
||||
pool_kwargs = {}
|
||||
else:
|
||||
poolclass = AsyncAdaptedQueuePool
|
||||
pool_kwargs = {
|
||||
"pool_size": 20,
|
||||
"max_overflow": 30,
|
||||
"pool_timeout": 30,
|
||||
"pool_recycle": 3600,
|
||||
"pool_pre_ping": True,
|
||||
# 🔥 Агрессивная очистка мертвых соединений
|
||||
"pool_reset_on_return": "rollback",
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# ENGINE WITH ADVANCED OPTIMIZATIONS
|
||||
# ============================================================================
|
||||
|
||||
engine = create_async_engine(
|
||||
settings.get_database_url(),
|
||||
poolclass=poolclass,
|
||||
poolclass=NullPool,
|
||||
echo=settings.DEBUG,
|
||||
future=True,
|
||||
**pool_kwargs,
|
||||
|
||||
connect_args={
|
||||
"server_settings": {
|
||||
"application_name": "remnawave_bot",
|
||||
"jit": "on",
|
||||
"statement_timeout": "60000", # 60 секунд
|
||||
"idle_in_transaction_session_timeout": "300000", # 5 минут
|
||||
},
|
||||
"command_timeout": 60,
|
||||
"timeout": 10,
|
||||
} if not settings.get_database_url().startswith("sqlite") else {},
|
||||
|
||||
execution_options={
|
||||
"isolation_level": "READ COMMITTED", # Оптимальный для большинства случаев
|
||||
"compiled_cache_size": 500, # Кеш скомпилированных запросов
|
||||
}
|
||||
future=True
|
||||
)
|
||||
|
||||
# ============================================================================
|
||||
# SESSION FACTORY WITH OPTIMIZATIONS
|
||||
# ============================================================================
|
||||
|
||||
AsyncSessionLocal = async_sessionmaker(
|
||||
bind=engine,
|
||||
class_=AsyncSession,
|
||||
expire_on_commit=False,
|
||||
autoflush=False, # 🔥 Критично для производительности
|
||||
autocommit=False,
|
||||
autoflush=True,
|
||||
autocommit=False
|
||||
)
|
||||
|
||||
# ============================================================================
|
||||
# QUERY PERFORMANCE MONITORING
|
||||
# ============================================================================
|
||||
|
||||
if settings.DEBUG:
|
||||
@event.listens_for(Engine, "before_cursor_execute")
|
||||
def before_cursor_execute(conn, cursor, statement, parameters, context, executemany):
|
||||
conn.info.setdefault("query_start_time", []).append(time.time())
|
||||
logger.debug(f"🔍 Executing query: {statement[:100]}...")
|
||||
|
||||
@event.listens_for(Engine, "after_cursor_execute")
|
||||
def after_cursor_execute(conn, cursor, statement, parameters, context, executemany):
|
||||
total = time.time() - conn.info["query_start_time"].pop(-1)
|
||||
if total > 0.1: # Логируем медленные запросы > 100ms
|
||||
logger.warning(f"🐌 Slow query ({total:.3f}s): {statement[:100]}...")
|
||||
else:
|
||||
logger.debug(f"⚡ Query executed in {total:.3f}s")
|
||||
|
||||
# ============================================================================
|
||||
# ADVANCED SESSION MANAGER WITH READ REPLICAS
|
||||
# ============================================================================
|
||||
|
||||
class DatabaseManager:
|
||||
"""Продвинутый менеджер БД с поддержкой реплик и кеширования"""
|
||||
|
||||
def __init__(self):
|
||||
self.engine = engine
|
||||
self.read_replica_engine: Optional[AsyncEngine] = None
|
||||
|
||||
if hasattr(settings, 'DATABASE_READ_REPLICA_URL') and settings.DATABASE_READ_REPLICA_URL:
|
||||
self.read_replica_engine = create_async_engine(
|
||||
settings.DATABASE_READ_REPLICA_URL,
|
||||
poolclass=poolclass,
|
||||
pool_size=30, # Больше для read операций
|
||||
max_overflow=50,
|
||||
pool_pre_ping=True,
|
||||
echo=False,
|
||||
)
|
||||
|
||||
@asynccontextmanager
|
||||
async def session(self, read_only: bool = False):
|
||||
target_engine = self.read_replica_engine if (read_only and self.read_replica_engine) else self.engine
|
||||
|
||||
async_session = async_sessionmaker(
|
||||
bind=target_engine,
|
||||
class_=AsyncSession,
|
||||
expire_on_commit=False,
|
||||
autoflush=False,
|
||||
)
|
||||
|
||||
async with async_session() as session:
|
||||
try:
|
||||
yield session
|
||||
if not read_only:
|
||||
await session.commit()
|
||||
except Exception:
|
||||
await session.rollback()
|
||||
raise
|
||||
|
||||
async def health_check(self) -> dict:
|
||||
pool = self.engine.pool
|
||||
|
||||
try:
|
||||
async with AsyncSessionLocal() as session:
|
||||
start = time.time()
|
||||
await session.execute(text("SELECT 1"))
|
||||
latency = (time.time() - start) * 1000
|
||||
status = "healthy"
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Database health check failed: {e}")
|
||||
status = "unhealthy"
|
||||
latency = None
|
||||
|
||||
return {
|
||||
"status": status,
|
||||
"latency_ms": round(latency, 2) if latency else None,
|
||||
"pool": _collect_health_pool_metrics(pool),
|
||||
}
|
||||
|
||||
db_manager = DatabaseManager()
|
||||
|
||||
# ============================================================================
|
||||
# SESSION DEPENDENCY FOR FASTAPI/AIOGRAM
|
||||
# ============================================================================
|
||||
|
||||
async def get_db() -> AsyncGenerator[AsyncSession, None]:
|
||||
"""Стандартная dependency для FastAPI"""
|
||||
async with AsyncSessionLocal() as session:
|
||||
try:
|
||||
yield session
|
||||
@@ -169,229 +33,19 @@ async def get_db() -> AsyncGenerator[AsyncSession, None]:
|
||||
except Exception:
|
||||
await session.rollback()
|
||||
raise
|
||||
finally:
|
||||
await session.close()
|
||||
|
||||
async def get_db_read_only() -> AsyncGenerator[AsyncSession, None]:
|
||||
"""Read-only dependency для тяжелых SELECT запросов"""
|
||||
async with db_manager.session(read_only=True) as session:
|
||||
yield session
|
||||
|
||||
# ============================================================================
|
||||
# BATCH OPERATIONS FOR PERFORMANCE
|
||||
# ============================================================================
|
||||
|
||||
class BatchOperations:
|
||||
"""Утилиты для массовых операций"""
|
||||
|
||||
@staticmethod
|
||||
async def bulk_insert(session: AsyncSession, model, data: list[dict], chunk_size: int = 1000):
|
||||
"""Массовая вставка с чанками"""
|
||||
for i in range(0, len(data), chunk_size):
|
||||
chunk = data[i:i + chunk_size]
|
||||
session.add_all([model(**item) for item in chunk])
|
||||
await session.flush()
|
||||
await session.commit()
|
||||
|
||||
@staticmethod
|
||||
async def bulk_update(session: AsyncSession, model, data: list[dict], chunk_size: int = 1000):
|
||||
"""Массовое обновление с чанками"""
|
||||
if not data:
|
||||
return
|
||||
|
||||
primary_keys = [column.name for column in model.__table__.primary_key.columns]
|
||||
if not primary_keys:
|
||||
raise ValueError("Model must have a primary key for bulk_update")
|
||||
|
||||
updatable_columns = [
|
||||
column.name
|
||||
for column in model.__table__.columns
|
||||
if column.name not in primary_keys
|
||||
]
|
||||
|
||||
if not updatable_columns:
|
||||
raise ValueError("No columns available for update in bulk_update")
|
||||
|
||||
stmt = (
|
||||
model.__table__.update()
|
||||
.where(
|
||||
*[
|
||||
getattr(model.__table__.c, pk) == bindparam(pk)
|
||||
for pk in primary_keys
|
||||
]
|
||||
)
|
||||
.values(
|
||||
**{
|
||||
column: bindparam(column, required=False)
|
||||
for column in updatable_columns
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
for i in range(0, len(data), chunk_size):
|
||||
chunk = data[i:i + chunk_size]
|
||||
filtered_chunk = []
|
||||
for item in chunk:
|
||||
missing_keys = [pk for pk in primary_keys if pk not in item]
|
||||
if missing_keys:
|
||||
raise ValueError(
|
||||
f"Missing primary key values {missing_keys} for bulk_update"
|
||||
)
|
||||
|
||||
filtered_item = {
|
||||
key: value
|
||||
for key, value in item.items()
|
||||
if key in primary_keys or key in updatable_columns
|
||||
}
|
||||
filtered_chunk.append(filtered_item)
|
||||
|
||||
await session.execute(stmt, filtered_chunk)
|
||||
await session.commit()
|
||||
|
||||
batch_ops = BatchOperations()
|
||||
|
||||
# ============================================================================
|
||||
# INITIALIZATION AND CLEANUP
|
||||
# ============================================================================
|
||||
|
||||
async def init_db():
|
||||
"""Инициализация БД с оптимизациями"""
|
||||
logger.info("🚀 Создание таблиц базы данных...")
|
||||
logger.info("Создание таблиц базы данных...")
|
||||
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
|
||||
if not settings.get_database_url().startswith("sqlite"):
|
||||
logger.info("📊 Создание индексов для оптимизации...")
|
||||
|
||||
async with engine.begin() as conn:
|
||||
indexes = [
|
||||
("users", "CREATE INDEX IF NOT EXISTS idx_users_telegram_id ON users(telegram_id)"),
|
||||
(
|
||||
"subscriptions",
|
||||
"CREATE INDEX IF NOT EXISTS idx_subscriptions_user_id ON subscriptions(user_id)",
|
||||
),
|
||||
(
|
||||
"subscriptions",
|
||||
"CREATE INDEX IF NOT EXISTS idx_subscriptions_status ON subscriptions(status) WHERE status = 'active'",
|
||||
),
|
||||
(
|
||||
"payments",
|
||||
"CREATE INDEX IF NOT EXISTS idx_payments_created_at ON payments(created_at DESC)",
|
||||
),
|
||||
]
|
||||
|
||||
for table_name, index_sql in indexes:
|
||||
table_exists = await conn.run_sync(lambda sync_conn: inspect(sync_conn).has_table(table_name))
|
||||
|
||||
if not table_exists:
|
||||
logger.debug(
|
||||
"Пропускаем создание индекса %s: таблица %s отсутствует",
|
||||
index_sql,
|
||||
table_name,
|
||||
)
|
||||
continue
|
||||
|
||||
try:
|
||||
await conn.execute(text(index_sql))
|
||||
except Exception as e:
|
||||
logger.debug("Index creation skipped for %s: %s", table_name, e)
|
||||
|
||||
logger.info("✅ База данных успешно инициализирована")
|
||||
|
||||
health = await db_manager.health_check()
|
||||
logger.info(f"📊 Database health: {health}")
|
||||
|
||||
|
||||
async def close_db():
|
||||
"""Корректное закрытие всех соединений"""
|
||||
logger.info("🔄 Закрытие соединений с БД...")
|
||||
|
||||
await engine.dispose()
|
||||
|
||||
if db_manager.read_replica_engine:
|
||||
await db_manager.read_replica_engine.dispose()
|
||||
|
||||
logger.info("✅ Все подключения к базе данных закрыты")
|
||||
|
||||
# ============================================================================
|
||||
# CONNECTION POOL METRICS (для мониторинга)
|
||||
# ============================================================================
|
||||
|
||||
def _pool_counters(pool):
|
||||
"""Return basic pool counters or ``None`` when unsupported."""
|
||||
|
||||
required_methods = ("size", "checkedin", "checkedout", "overflow")
|
||||
|
||||
for method_name in required_methods:
|
||||
method = getattr(pool, method_name, None)
|
||||
if method is None or not callable(method):
|
||||
return None
|
||||
|
||||
size = pool.size()
|
||||
checked_in = pool.checkedin()
|
||||
checked_out = pool.checkedout()
|
||||
overflow = pool.overflow()
|
||||
|
||||
total_connections = size + overflow
|
||||
|
||||
return {
|
||||
"size": size,
|
||||
"checked_in": checked_in,
|
||||
"checked_out": checked_out,
|
||||
"overflow": overflow,
|
||||
"total_connections": total_connections,
|
||||
"utilization_percent": (checked_out / total_connections * 100) if total_connections else 0.0,
|
||||
}
|
||||
|
||||
|
||||
def _collect_health_pool_metrics(pool) -> dict:
|
||||
counters = _pool_counters(pool)
|
||||
|
||||
if counters is None:
|
||||
return {
|
||||
"metrics_available": False,
|
||||
"size": 0,
|
||||
"checked_in": 0,
|
||||
"checked_out": 0,
|
||||
"overflow": 0,
|
||||
"total_connections": 0,
|
||||
"utilization": "0.0%",
|
||||
}
|
||||
|
||||
return {
|
||||
"metrics_available": True,
|
||||
"size": counters["size"],
|
||||
"checked_in": counters["checked_in"],
|
||||
"checked_out": counters["checked_out"],
|
||||
"overflow": counters["overflow"],
|
||||
"total_connections": counters["total_connections"],
|
||||
"utilization": f"{counters['utilization_percent']:.1f}%",
|
||||
}
|
||||
|
||||
|
||||
async def get_pool_metrics() -> dict:
|
||||
"""Детальные метрики пула для Prometheus/Grafana"""
|
||||
pool = engine.pool
|
||||
|
||||
counters = _pool_counters(pool)
|
||||
|
||||
if counters is None:
|
||||
return {
|
||||
"metrics_available": False,
|
||||
"pool_size": 0,
|
||||
"checked_in_connections": 0,
|
||||
"checked_out_connections": 0,
|
||||
"overflow_connections": 0,
|
||||
"total_connections": 0,
|
||||
"max_possible_connections": 0,
|
||||
"pool_utilization_percent": 0.0,
|
||||
}
|
||||
|
||||
return {
|
||||
"metrics_available": True,
|
||||
"pool_size": counters["size"],
|
||||
"checked_in_connections": counters["checked_in"],
|
||||
"checked_out_connections": counters["checked_out"],
|
||||
"overflow_connections": counters["overflow"],
|
||||
"total_connections": counters["total_connections"],
|
||||
"max_possible_connections": counters["total_connections"] + (getattr(pool, "_max_overflow", 0) or 0),
|
||||
"pool_utilization_percent": round(counters["utilization_percent"], 2),
|
||||
}
|
||||
logger.info("✅ Подключение к базе данных закрыто")
|
||||
|
||||
+11
-111
@@ -67,10 +67,9 @@ class TransactionType(Enum):
|
||||
|
||||
|
||||
class PromoCodeType(Enum):
|
||||
BALANCE = "balance"
|
||||
SUBSCRIPTION_DAYS = "subscription_days"
|
||||
TRIAL_SUBSCRIPTION = "trial_subscription"
|
||||
PROMO_GROUP = "promo_group"
|
||||
BALANCE = "balance"
|
||||
SUBSCRIPTION_DAYS = "subscription_days"
|
||||
TRIAL_SUBSCRIPTION = "trial_subscription"
|
||||
|
||||
|
||||
class PaymentMethod(Enum):
|
||||
@@ -82,7 +81,6 @@ class PaymentMethod(Enum):
|
||||
MULENPAY = "mulenpay"
|
||||
PAL24 = "pal24"
|
||||
WATA = "wata"
|
||||
PLATEGA = "platega"
|
||||
MANUAL = "manual"
|
||||
|
||||
|
||||
@@ -415,62 +413,11 @@ class WataPayment(Base):
|
||||
)
|
||||
|
||||
|
||||
class PlategaPayment(Base):
|
||||
__tablename__ = "platega_payments"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
||||
|
||||
platega_transaction_id = Column(String(255), unique=True, nullable=True, index=True)
|
||||
correlation_id = Column(String(64), unique=True, nullable=False, index=True)
|
||||
amount_kopeks = Column(Integer, nullable=False)
|
||||
currency = Column(String(10), nullable=False, default="RUB")
|
||||
description = Column(Text, nullable=True)
|
||||
|
||||
payment_method_code = Column(Integer, nullable=False)
|
||||
status = Column(String(50), nullable=False, default="PENDING")
|
||||
is_paid = Column(Boolean, default=False)
|
||||
paid_at = Column(DateTime, nullable=True)
|
||||
|
||||
redirect_url = Column(Text, nullable=True)
|
||||
return_url = Column(Text, nullable=True)
|
||||
failed_url = Column(Text, nullable=True)
|
||||
payload = Column(String(255), nullable=True)
|
||||
metadata_json = Column(JSON, nullable=True)
|
||||
callback_payload = Column(JSON, nullable=True)
|
||||
|
||||
expires_at = Column(DateTime, nullable=True)
|
||||
|
||||
transaction_id = Column(Integer, ForeignKey("transactions.id"), nullable=True)
|
||||
|
||||
created_at = Column(DateTime, default=func.now())
|
||||
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
|
||||
|
||||
user = relationship("User", backref="platega_payments")
|
||||
transaction = relationship("Transaction", backref="platega_payment")
|
||||
|
||||
@property
|
||||
def amount_rubles(self) -> float:
|
||||
return self.amount_kopeks / 100
|
||||
|
||||
def __repr__(self) -> str: # pragma: no cover - debug helper
|
||||
return (
|
||||
"<PlategaPayment(id={0}, transaction_id={1}, amount={2}₽, status={3}, method={4})>".format(
|
||||
self.id,
|
||||
self.platega_transaction_id,
|
||||
self.amount_rubles,
|
||||
self.status,
|
||||
self.payment_method_code,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
class PromoGroup(Base):
|
||||
__tablename__ = "promo_groups"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
name = Column(String(255), unique=True, nullable=False)
|
||||
priority = Column(Integer, nullable=False, default=0, index=True)
|
||||
server_discount_percent = Column(Integer, nullable=False, default=0)
|
||||
traffic_discount_percent = Column(Integer, nullable=False, default=0)
|
||||
device_discount_percent = Column(Integer, nullable=False, default=0)
|
||||
@@ -482,7 +429,6 @@ class PromoGroup(Base):
|
||||
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
|
||||
|
||||
users = relationship("User", back_populates="promo_group")
|
||||
user_promo_groups = relationship("UserPromoGroup", back_populates="promo_group", cascade="all, delete-orphan")
|
||||
server_squads = relationship(
|
||||
"ServerSquad",
|
||||
secondary=server_squad_promo_groups,
|
||||
@@ -546,22 +492,6 @@ class PromoGroup(Base):
|
||||
return max(0, min(100, percent))
|
||||
|
||||
|
||||
class UserPromoGroup(Base):
|
||||
"""Таблица связи Many-to-Many между пользователями и промогруппами."""
|
||||
__tablename__ = "user_promo_groups"
|
||||
|
||||
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), primary_key=True)
|
||||
promo_group_id = Column(Integer, ForeignKey("promo_groups.id", ondelete="CASCADE"), primary_key=True)
|
||||
assigned_at = Column(DateTime, default=func.now())
|
||||
assigned_by = Column(String(50), default="system")
|
||||
|
||||
user = relationship("User", back_populates="user_promo_groups")
|
||||
promo_group = relationship("PromoGroup", back_populates="user_promo_groups")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<UserPromoGroup(user_id={self.user_id}, promo_group_id={self.promo_group_id}, assigned_by='{self.assigned_by}')>"
|
||||
|
||||
|
||||
class User(Base):
|
||||
__tablename__ = "users"
|
||||
|
||||
@@ -599,51 +529,23 @@ class User(Base):
|
||||
vless_uuid = Column(String(255), nullable=True)
|
||||
ss_password = Column(String(255), nullable=True)
|
||||
has_made_first_topup: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||
promo_group_id = Column(Integer, ForeignKey("promo_groups.id", ondelete="RESTRICT"), nullable=True, index=True)
|
||||
promo_group_id = Column(Integer, ForeignKey("promo_groups.id", ondelete="RESTRICT"), nullable=False, index=True)
|
||||
promo_group = relationship("PromoGroup", back_populates="users")
|
||||
user_promo_groups = relationship("UserPromoGroup", back_populates="user", cascade="all, delete-orphan")
|
||||
poll_responses = relationship("PollResponse", back_populates="user")
|
||||
|
||||
|
||||
@property
|
||||
def balance_rubles(self) -> float:
|
||||
return self.balance_kopeks / 100
|
||||
|
||||
|
||||
@property
|
||||
def full_name(self) -> str:
|
||||
parts = [self.first_name, self.last_name]
|
||||
return " ".join(filter(None, parts)) or self.username or f"ID{self.telegram_id}"
|
||||
|
||||
def get_primary_promo_group(self):
|
||||
"""Возвращает промогруппу с максимальным приоритетом."""
|
||||
if not self.user_promo_groups:
|
||||
return getattr(self, "promo_group", None)
|
||||
|
||||
try:
|
||||
# Сортируем по приоритету группы (убывание), затем по ID группы
|
||||
# Используем getattr для защиты от ленивой загрузки
|
||||
sorted_groups = sorted(
|
||||
self.user_promo_groups,
|
||||
key=lambda upg: (
|
||||
getattr(upg.promo_group, 'priority', 0) if upg.promo_group else 0,
|
||||
upg.promo_group_id
|
||||
),
|
||||
reverse=True
|
||||
)
|
||||
|
||||
if sorted_groups and sorted_groups[0].promo_group:
|
||||
return sorted_groups[0].promo_group
|
||||
except Exception:
|
||||
# Если возникла ошибка (например, ленивая загрузка), fallback на старую связь
|
||||
pass
|
||||
|
||||
# Fallback на старую связь если новая пустая или возникла ошибка
|
||||
return getattr(self, "promo_group", None)
|
||||
|
||||
def get_promo_discount(self, category: str, period_days: Optional[int] = None) -> int:
|
||||
primary_group = self.get_primary_promo_group()
|
||||
if not primary_group:
|
||||
if not self.promo_group:
|
||||
return 0
|
||||
return primary_group.get_discount_percent(category, period_days)
|
||||
return self.promo_group.get_discount_percent(category, period_days)
|
||||
|
||||
def add_balance(self, kopeks: int) -> None:
|
||||
self.balance_kopeks += kopeks
|
||||
@@ -891,15 +793,13 @@ class PromoCode(Base):
|
||||
valid_until = Column(DateTime, nullable=True)
|
||||
|
||||
is_active = Column(Boolean, default=True)
|
||||
|
||||
|
||||
created_by = Column(Integer, ForeignKey("users.id"), nullable=True)
|
||||
promo_group_id = Column(Integer, ForeignKey("promo_groups.id", ondelete="SET NULL"), nullable=True, index=True)
|
||||
|
||||
|
||||
created_at = Column(DateTime, default=func.now())
|
||||
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
|
||||
|
||||
|
||||
uses = relationship("PromoCodeUse", back_populates="promocode")
|
||||
promo_group = relationship("PromoGroup")
|
||||
|
||||
@property
|
||||
def is_valid(self) -> bool:
|
||||
|
||||
@@ -2107,9 +2107,6 @@ async def ensure_promo_groups_setup():
|
||||
addon_discount_column_exists = await check_column_exists(
|
||||
"promo_groups", "apply_discounts_to_addons"
|
||||
)
|
||||
priority_column_exists = await check_column_exists(
|
||||
"promo_groups", "priority"
|
||||
)
|
||||
|
||||
if not addon_discount_column_exists:
|
||||
if db_type == "sqlite":
|
||||
@@ -2292,23 +2289,7 @@ async def ensure_promo_groups_setup():
|
||||
"is_default": True,
|
||||
}
|
||||
|
||||
if priority_column_exists:
|
||||
insert_params["priority"] = 0
|
||||
|
||||
if addon_discount_column_exists and priority_column_exists:
|
||||
insert_sql = """
|
||||
INSERT INTO promo_groups (
|
||||
name,
|
||||
priority,
|
||||
server_discount_percent,
|
||||
traffic_discount_percent,
|
||||
device_discount_percent,
|
||||
apply_discounts_to_addons,
|
||||
is_default
|
||||
) VALUES (:name, :priority, 0, 0, 0, :apply_discounts_to_addons, :is_default)
|
||||
"""
|
||||
insert_params["apply_discounts_to_addons"] = True
|
||||
elif addon_discount_column_exists:
|
||||
if addon_discount_column_exists:
|
||||
insert_sql = """
|
||||
INSERT INTO promo_groups (
|
||||
name,
|
||||
@@ -2320,17 +2301,6 @@ async def ensure_promo_groups_setup():
|
||||
) VALUES (:name, 0, 0, 0, :apply_discounts_to_addons, :is_default)
|
||||
"""
|
||||
insert_params["apply_discounts_to_addons"] = True
|
||||
elif priority_column_exists:
|
||||
insert_sql = """
|
||||
INSERT INTO promo_groups (
|
||||
name,
|
||||
priority,
|
||||
server_discount_percent,
|
||||
traffic_discount_percent,
|
||||
device_discount_percent,
|
||||
is_default
|
||||
) VALUES (:name, :priority, 0, 0, 0, :is_default)
|
||||
"""
|
||||
else:
|
||||
insert_sql = """
|
||||
INSERT INTO promo_groups (
|
||||
@@ -3489,219 +3459,6 @@ async def ensure_default_web_api_token() -> bool:
|
||||
return False
|
||||
|
||||
|
||||
async def add_promo_group_priority_column() -> bool:
|
||||
"""Добавляет колонку priority в таблицу promo_groups."""
|
||||
column_exists = await check_column_exists('promo_groups', 'priority')
|
||||
if column_exists:
|
||||
logger.info("Колонка priority уже существует в promo_groups")
|
||||
return True
|
||||
|
||||
try:
|
||||
async with engine.begin() as conn:
|
||||
db_type = await get_database_type()
|
||||
|
||||
if db_type == 'sqlite':
|
||||
column_def = 'INTEGER NOT NULL DEFAULT 0'
|
||||
elif db_type == 'postgresql':
|
||||
column_def = 'INTEGER NOT NULL DEFAULT 0'
|
||||
else:
|
||||
column_def = 'INT NOT NULL DEFAULT 0'
|
||||
|
||||
await conn.execute(
|
||||
text(f"ALTER TABLE promo_groups ADD COLUMN priority {column_def}")
|
||||
)
|
||||
|
||||
# Создаем индекс для оптимизации сортировки
|
||||
if db_type == 'postgresql':
|
||||
await conn.execute(
|
||||
text("CREATE INDEX IF NOT EXISTS idx_promo_groups_priority ON promo_groups(priority DESC)")
|
||||
)
|
||||
elif db_type == 'sqlite':
|
||||
await conn.execute(
|
||||
text("CREATE INDEX IF NOT EXISTS idx_promo_groups_priority ON promo_groups(priority DESC)")
|
||||
)
|
||||
else: # MySQL
|
||||
await conn.execute(
|
||||
text("CREATE INDEX idx_promo_groups_priority ON promo_groups(priority DESC)")
|
||||
)
|
||||
|
||||
logger.info("✅ Добавлена колонка priority в promo_groups с индексом")
|
||||
return True
|
||||
|
||||
except Exception as error:
|
||||
logger.error(f"Ошибка добавления колонки priority: {error}")
|
||||
return False
|
||||
|
||||
|
||||
async def create_user_promo_groups_table() -> bool:
|
||||
"""Создает таблицу user_promo_groups для связи Many-to-Many между users и promo_groups."""
|
||||
table_exists = await check_table_exists("user_promo_groups")
|
||||
if table_exists:
|
||||
logger.info("ℹ️ Таблица user_promo_groups уже существует")
|
||||
return True
|
||||
|
||||
try:
|
||||
async with engine.begin() as conn:
|
||||
db_type = await get_database_type()
|
||||
|
||||
if db_type == "sqlite":
|
||||
create_sql = """
|
||||
CREATE TABLE user_promo_groups (
|
||||
user_id INTEGER NOT NULL,
|
||||
promo_group_id INTEGER NOT NULL,
|
||||
assigned_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
assigned_by VARCHAR(50) DEFAULT 'system',
|
||||
PRIMARY KEY (user_id, promo_group_id),
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (promo_group_id) REFERENCES promo_groups(id) ON DELETE CASCADE
|
||||
);
|
||||
"""
|
||||
index_sql = "CREATE INDEX idx_user_promo_groups_user_id ON user_promo_groups(user_id);"
|
||||
elif db_type == "postgresql":
|
||||
create_sql = """
|
||||
CREATE TABLE user_promo_groups (
|
||||
user_id INTEGER NOT NULL,
|
||||
promo_group_id INTEGER NOT NULL,
|
||||
assigned_at TIMESTAMP DEFAULT NOW(),
|
||||
assigned_by VARCHAR(50) DEFAULT 'system',
|
||||
PRIMARY KEY (user_id, promo_group_id),
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (promo_group_id) REFERENCES promo_groups(id) ON DELETE CASCADE
|
||||
);
|
||||
"""
|
||||
index_sql = "CREATE INDEX idx_user_promo_groups_user_id ON user_promo_groups(user_id);"
|
||||
else: # MySQL
|
||||
create_sql = """
|
||||
CREATE TABLE user_promo_groups (
|
||||
user_id INT NOT NULL,
|
||||
promo_group_id INT NOT NULL,
|
||||
assigned_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
assigned_by VARCHAR(50) DEFAULT 'system',
|
||||
PRIMARY KEY (user_id, promo_group_id),
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (promo_group_id) REFERENCES promo_groups(id) ON DELETE CASCADE
|
||||
);
|
||||
"""
|
||||
index_sql = "CREATE INDEX idx_user_promo_groups_user_id ON user_promo_groups(user_id);"
|
||||
|
||||
await conn.execute(text(create_sql))
|
||||
await conn.execute(text(index_sql))
|
||||
logger.info("✅ Таблица user_promo_groups создана с индексом")
|
||||
return True
|
||||
|
||||
except Exception as error:
|
||||
logger.error(f"❌ Ошибка создания таблицы user_promo_groups: {error}")
|
||||
return False
|
||||
|
||||
|
||||
async def migrate_existing_user_promo_groups_data() -> bool:
|
||||
"""Переносит существующие связи users.promo_group_id в таблицу user_promo_groups."""
|
||||
try:
|
||||
table_exists = await check_table_exists("user_promo_groups")
|
||||
if not table_exists:
|
||||
logger.warning("⚠️ Таблица user_promo_groups не существует, пропускаем миграцию данных")
|
||||
return False
|
||||
|
||||
column_exists = await check_column_exists('users', 'promo_group_id')
|
||||
if not column_exists:
|
||||
logger.warning("⚠️ Колонка users.promo_group_id не существует, пропускаем миграцию данных")
|
||||
return True
|
||||
|
||||
async with engine.begin() as conn:
|
||||
# Проверяем есть ли уже данные в user_promo_groups
|
||||
result = await conn.execute(text("SELECT COUNT(*) FROM user_promo_groups"))
|
||||
count = result.scalar()
|
||||
|
||||
if count > 0:
|
||||
logger.info(f"ℹ️ В таблице user_promo_groups уже есть {count} записей, пропускаем миграцию")
|
||||
return True
|
||||
|
||||
# Переносим данные из users.promo_group_id
|
||||
db_type = await get_database_type()
|
||||
|
||||
if db_type == "sqlite":
|
||||
migrate_sql = """
|
||||
INSERT INTO user_promo_groups (user_id, promo_group_id, assigned_at, assigned_by)
|
||||
SELECT id, promo_group_id, CURRENT_TIMESTAMP, 'system'
|
||||
FROM users
|
||||
WHERE promo_group_id IS NOT NULL
|
||||
"""
|
||||
else: # PostgreSQL and MySQL
|
||||
migrate_sql = """
|
||||
INSERT INTO user_promo_groups (user_id, promo_group_id, assigned_at, assigned_by)
|
||||
SELECT id, promo_group_id, NOW(), 'system'
|
||||
FROM users
|
||||
WHERE promo_group_id IS NOT NULL
|
||||
"""
|
||||
|
||||
result = await conn.execute(text(migrate_sql))
|
||||
migrated_count = result.rowcount if hasattr(result, 'rowcount') else 0
|
||||
|
||||
logger.info(f"✅ Перенесено {migrated_count} связей пользователей с промогруппами")
|
||||
return True
|
||||
|
||||
except Exception as error:
|
||||
logger.error(f"❌ Ошибка миграции данных user_promo_groups: {error}")
|
||||
return False
|
||||
|
||||
|
||||
async def add_promocode_promo_group_column() -> bool:
|
||||
"""Добавляет колонку promo_group_id в таблицу promocodes."""
|
||||
column_exists = await check_column_exists('promocodes', 'promo_group_id')
|
||||
if column_exists:
|
||||
logger.info("Колонка promo_group_id уже существует в promocodes")
|
||||
return True
|
||||
|
||||
try:
|
||||
async with engine.begin() as conn:
|
||||
db_type = await get_database_type()
|
||||
|
||||
# Add column
|
||||
if db_type == 'sqlite':
|
||||
await conn.execute(
|
||||
text("ALTER TABLE promocodes ADD COLUMN promo_group_id INTEGER")
|
||||
)
|
||||
elif db_type == 'postgresql':
|
||||
await conn.execute(
|
||||
text("ALTER TABLE promocodes ADD COLUMN promo_group_id INTEGER")
|
||||
)
|
||||
# Add foreign key
|
||||
await conn.execute(
|
||||
text("""
|
||||
ALTER TABLE promocodes
|
||||
ADD CONSTRAINT fk_promocodes_promo_group
|
||||
FOREIGN KEY (promo_group_id)
|
||||
REFERENCES promo_groups(id)
|
||||
ON DELETE SET NULL
|
||||
""")
|
||||
)
|
||||
# Add index
|
||||
await conn.execute(
|
||||
text("CREATE INDEX IF NOT EXISTS idx_promocodes_promo_group_id ON promocodes(promo_group_id)")
|
||||
)
|
||||
elif db_type == 'mysql':
|
||||
await conn.execute(
|
||||
text("""
|
||||
ALTER TABLE promocodes
|
||||
ADD COLUMN promo_group_id INT,
|
||||
ADD CONSTRAINT fk_promocodes_promo_group
|
||||
FOREIGN KEY (promo_group_id)
|
||||
REFERENCES promo_groups(id)
|
||||
ON DELETE SET NULL
|
||||
""")
|
||||
)
|
||||
await conn.execute(
|
||||
text("CREATE INDEX idx_promocodes_promo_group_id ON promocodes(promo_group_id)")
|
||||
)
|
||||
|
||||
logger.info("✅ Добавлена колонка promo_group_id в promocodes")
|
||||
return True
|
||||
|
||||
except Exception as error:
|
||||
logger.error(f"❌ Ошибка добавления promo_group_id в promocodes: {error}")
|
||||
return False
|
||||
|
||||
|
||||
async def run_universal_migration():
|
||||
logger.info("=== НАЧАЛО УНИВЕРСАЛЬНОЙ МИГРАЦИИ ===")
|
||||
|
||||
@@ -3863,34 +3620,6 @@ async def run_universal_migration():
|
||||
else:
|
||||
logger.warning("⚠️ Проблемы с таблицей promo_offer_templates")
|
||||
|
||||
logger.info("=== ДОБАВЛЕНИЕ ПРИОРИТЕТА В ПРОМОГРУППЫ ===")
|
||||
priority_column_ready = await add_promo_group_priority_column()
|
||||
if priority_column_ready:
|
||||
logger.info("✅ Колонка priority в promo_groups готова")
|
||||
else:
|
||||
logger.warning("⚠️ Проблемы с добавлением priority в promo_groups")
|
||||
|
||||
logger.info("=== СОЗДАНИЕ ТАБЛИЦЫ USER_PROMO_GROUPS ===")
|
||||
user_promo_groups_ready = await create_user_promo_groups_table()
|
||||
if user_promo_groups_ready:
|
||||
logger.info("✅ Таблица user_promo_groups готова")
|
||||
else:
|
||||
logger.warning("⚠️ Проблемы с таблицей user_promo_groups")
|
||||
|
||||
logger.info("=== МИГРАЦИЯ ДАННЫХ В USER_PROMO_GROUPS ===")
|
||||
data_migrated = await migrate_existing_user_promo_groups_data()
|
||||
if data_migrated:
|
||||
logger.info("✅ Данные перенесены в user_promo_groups")
|
||||
else:
|
||||
logger.warning("⚠️ Проблемы с миграцией данных в user_promo_groups")
|
||||
|
||||
logger.info("=== ДОБАВЛЕНИЕ PROMO_GROUP_ID В PROMOCODES ===")
|
||||
promocode_column_ready = await add_promocode_promo_group_column()
|
||||
if promocode_column_ready:
|
||||
logger.info("✅ Колонка promo_group_id в promocodes готова")
|
||||
else:
|
||||
logger.warning("⚠️ Проблемы с добавлением promo_group_id в promocodes")
|
||||
|
||||
logger.info("=== СОЗДАНИЕ ТАБЛИЦЫ MAIN_MENU_BUTTONS ===")
|
||||
main_menu_buttons_created = await create_main_menu_buttons_table()
|
||||
if main_menu_buttons_created:
|
||||
|
||||
Vendored
+4
-4
@@ -1,4 +1,4 @@
|
||||
"""Flask webhook server for PayPalych callbacks."""
|
||||
"""Flask webhook server for PayPalych postbacks."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -65,7 +65,7 @@ def create_pal24_flask_app(
|
||||
return jsonify({"status": "error", "reason": "empty_payload"}), 400
|
||||
|
||||
try:
|
||||
parsed_payload = pal24_service.parse_callback(payload)
|
||||
parsed_payload = pal24_service.parse_postback(payload)
|
||||
except Pal24APIError as error:
|
||||
logger.error("Ошибка валидации Pal24 webhook: %s", error)
|
||||
return jsonify({"status": "error", "reason": str(error)}), 400
|
||||
@@ -73,7 +73,7 @@ def create_pal24_flask_app(
|
||||
async def process() -> bool:
|
||||
async with AsyncSessionLocal() as db:
|
||||
try:
|
||||
return await payment_service.process_pal24_callback(db, parsed_payload)
|
||||
return await payment_service.process_pal24_postback(db, parsed_payload)
|
||||
except Exception:
|
||||
await db.rollback()
|
||||
raise
|
||||
@@ -112,7 +112,7 @@ def create_pal24_flask_app(
|
||||
|
||||
|
||||
class Pal24WebhookServer:
|
||||
"""Threaded Flask server for Pal24 callbacks."""
|
||||
"""Threaded Flask server for Pal24 postbacks."""
|
||||
|
||||
def __init__(self, payment_service: PaymentService, loop: AbstractEventLoop) -> None:
|
||||
self.app = create_pal24_flask_app(payment_service, loop)
|
||||
|
||||
Vendored
+1
-1
@@ -163,7 +163,7 @@ class RemnaWaveAPI:
|
||||
async def __aenter__(self):
|
||||
conn_type = self._detect_connection_type()
|
||||
|
||||
logger.debug(f"Подключение к Remnawave: {self.base_url} (тип: {conn_type})")
|
||||
logger.info(f"Подключение к Remnawave: {self.base_url} (тип: {conn_type})")
|
||||
|
||||
headers = self._prepare_auth_headers()
|
||||
|
||||
|
||||
Vendored
+127
-268
@@ -1,16 +1,12 @@
|
||||
import asyncio
|
||||
import logging
|
||||
import json
|
||||
from ipaddress import (
|
||||
IPv4Address,
|
||||
IPv4Network,
|
||||
IPv6Address,
|
||||
IPv6Network,
|
||||
ip_address,
|
||||
ip_network,
|
||||
)
|
||||
from typing import Iterable, Optional, Dict, Any, List, Union, Tuple
|
||||
import hashlib
|
||||
import hmac
|
||||
import base64
|
||||
from typing import Optional, Dict, Any
|
||||
from aiohttp import web
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.services.payment_service import PaymentService
|
||||
@@ -19,292 +15,155 @@ from app.database.database import get_db
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
IPAddress = Union[IPv4Address, IPv6Address]
|
||||
IPNetwork = Union[IPv4Network, IPv6Network]
|
||||
|
||||
YOOKASSA_ALLOWED_IP_NETWORKS: tuple[IPNetwork, ...] = (
|
||||
ip_network("185.71.76.0/27"),
|
||||
ip_network("185.71.77.0/27"),
|
||||
ip_network("77.75.153.0/25"),
|
||||
ip_network("77.75.154.128/25"),
|
||||
ip_network("77.75.156.11/32"),
|
||||
ip_network("77.75.156.35/32"),
|
||||
ip_network("2a02:5180::/32"),
|
||||
)
|
||||
|
||||
|
||||
CLOUDFLARE_TRUSTED_NETWORKS: tuple[IPNetwork, ...] = (
|
||||
ip_network("173.245.48.0/20"),
|
||||
ip_network("103.21.244.0/22"),
|
||||
ip_network("103.22.200.0/22"),
|
||||
ip_network("103.31.4.0/22"),
|
||||
ip_network("141.101.64.0/18"),
|
||||
ip_network("108.162.192.0/18"),
|
||||
ip_network("190.93.240.0/20"),
|
||||
ip_network("188.114.96.0/20"),
|
||||
ip_network("197.234.240.0/22"),
|
||||
ip_network("198.41.128.0/17"),
|
||||
ip_network("162.158.0.0/15"),
|
||||
ip_network("104.16.0.0/13"),
|
||||
ip_network("104.24.0.0/14"),
|
||||
ip_network("172.64.0.0/13"),
|
||||
ip_network("131.0.72.0/22"),
|
||||
ip_network("2400:cb00::/32"),
|
||||
ip_network("2606:4700::/32"),
|
||||
ip_network("2803:f800::/32"),
|
||||
ip_network("2405:b500::/32"),
|
||||
ip_network("2405:8100::/32"),
|
||||
ip_network("2a06:98c0::/29"),
|
||||
ip_network("2c0f:f248::/32"),
|
||||
)
|
||||
|
||||
|
||||
YOOKASSA_ALLOWED_EVENTS: tuple[str, ...] = (
|
||||
"payment.succeeded",
|
||||
"payment.waiting_for_capture",
|
||||
"payment.canceled",
|
||||
)
|
||||
|
||||
|
||||
def collect_yookassa_ip_candidates(*values: Optional[str]) -> List[str]:
|
||||
candidates: List[str] = []
|
||||
for value in values:
|
||||
if not value:
|
||||
continue
|
||||
for part in value.split(","):
|
||||
normalized = part.strip()
|
||||
if normalized:
|
||||
candidates.append(normalized)
|
||||
return candidates
|
||||
|
||||
|
||||
def _parse_candidate_ip(candidate: str) -> Optional[IPAddress]:
|
||||
value = candidate.strip()
|
||||
if not value:
|
||||
return None
|
||||
|
||||
if value.startswith("[") and "]" in value:
|
||||
value = value[1:value.index("]")]
|
||||
|
||||
if "%" in value:
|
||||
value = value.split("%", 1)[0]
|
||||
|
||||
if value.count(":") == 1 and "." in value:
|
||||
host, _, port = value.rpartition(":")
|
||||
if port.isdigit():
|
||||
value = host
|
||||
|
||||
try:
|
||||
return ip_address(value)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def _should_trust_forwarded_headers(remote_ip: Optional[IPAddress]) -> bool:
|
||||
if remote_ip is None:
|
||||
return True
|
||||
|
||||
if _is_trusted_proxy_ip(remote_ip):
|
||||
return True
|
||||
|
||||
return any(
|
||||
getattr(remote_ip, attribute)
|
||||
for attribute in ("is_private", "is_loopback", "is_link_local", "is_reserved")
|
||||
)
|
||||
|
||||
|
||||
_TRUSTED_PROXY_NETWORKS_CACHE: Tuple[str, Tuple[IPNetwork, ...]] = ("", ())
|
||||
|
||||
|
||||
def _get_trusted_proxy_networks() -> Tuple[IPNetwork, ...]:
|
||||
global _TRUSTED_PROXY_NETWORKS_CACHE
|
||||
|
||||
raw_value = getattr(settings, "YOOKASSA_TRUSTED_PROXY_NETWORKS", "") or ""
|
||||
cached_raw, cached_networks = _TRUSTED_PROXY_NETWORKS_CACHE
|
||||
|
||||
if raw_value == cached_raw:
|
||||
return cached_networks
|
||||
|
||||
networks: List[IPNetwork] = []
|
||||
for part in raw_value.split(","):
|
||||
candidate = part.strip()
|
||||
if not candidate:
|
||||
continue
|
||||
|
||||
try:
|
||||
networks.append(ip_network(candidate, strict=False))
|
||||
except ValueError:
|
||||
logger.warning("Неверная сеть доверенного прокси YooKassa: %s", candidate)
|
||||
|
||||
cached_networks = tuple(networks)
|
||||
_TRUSTED_PROXY_NETWORKS_CACHE = (raw_value, cached_networks)
|
||||
return cached_networks
|
||||
|
||||
|
||||
def _is_trusted_proxy_ip(ip_object: IPAddress) -> bool:
|
||||
if any(
|
||||
getattr(ip_object, attribute)
|
||||
for attribute in ("is_private", "is_loopback", "is_link_local", "is_reserved")
|
||||
):
|
||||
return True
|
||||
|
||||
if any(ip_object in network for network in CLOUDFLARE_TRUSTED_NETWORKS):
|
||||
return True
|
||||
|
||||
return any(ip_object in network for network in _get_trusted_proxy_networks())
|
||||
|
||||
|
||||
def resolve_yookassa_ip(
|
||||
candidates: Iterable[str],
|
||||
*,
|
||||
remote: Optional[str] = None,
|
||||
) -> Optional[IPAddress]:
|
||||
remote_ip = _parse_candidate_ip(remote) if remote else None
|
||||
|
||||
if (
|
||||
remote_ip is not None
|
||||
and remote_ip.is_global
|
||||
and not _is_trusted_proxy_ip(remote_ip)
|
||||
):
|
||||
return remote_ip
|
||||
|
||||
candidate_list = list(candidates)
|
||||
|
||||
if _should_trust_forwarded_headers(remote_ip):
|
||||
last_hop = remote_ip
|
||||
for candidate in reversed(candidate_list):
|
||||
ip_object = _parse_candidate_ip(candidate)
|
||||
if ip_object is not None:
|
||||
if last_hop is None or _is_trusted_proxy_ip(last_hop):
|
||||
if _is_trusted_proxy_ip(ip_object):
|
||||
last_hop = ip_object
|
||||
continue
|
||||
return ip_object
|
||||
break
|
||||
|
||||
if last_hop is not None and not _is_trusted_proxy_ip(last_hop):
|
||||
return last_hop
|
||||
|
||||
return remote_ip if remote_ip is not None else next(
|
||||
(ip for ip in (_parse_candidate_ip(value) for value in candidate_list) if ip is not None),
|
||||
None,
|
||||
)
|
||||
|
||||
|
||||
def is_yookassa_ip_allowed(ip_object: IPAddress) -> bool:
|
||||
return any(ip_object in network for network in YOOKASSA_ALLOWED_IP_NETWORKS)
|
||||
|
||||
|
||||
class YooKassaWebhookHandler:
|
||||
|
||||
@staticmethod
|
||||
def verify_webhook_signature(body: str, signature: str, secret: str) -> bool:
|
||||
try:
|
||||
signature_parts = signature.strip().split(' ')
|
||||
|
||||
if len(signature_parts) < 4:
|
||||
logger.error(f"Неверный формат подписи YooKassa: {signature}")
|
||||
return False
|
||||
|
||||
version = signature_parts[0]
|
||||
payment_id = signature_parts[1]
|
||||
timestamp = signature_parts[2]
|
||||
received_signature = signature_parts[3]
|
||||
|
||||
if version != "v1":
|
||||
logger.error(f"Неподдерживаемая версия подписи: {version}")
|
||||
return False
|
||||
|
||||
logger.info(f"Проверка подписи v1 для платежа {payment_id}, timestamp: {timestamp}")
|
||||
|
||||
|
||||
expected_signature_1 = hmac.new(
|
||||
secret.encode('utf-8'),
|
||||
body.encode('utf-8'),
|
||||
hashlib.sha256
|
||||
).digest()
|
||||
expected_signature_1_b64 = base64.b64encode(expected_signature_1).decode('utf-8')
|
||||
|
||||
signed_payload_2 = f"{payment_id}.{timestamp}.{body}"
|
||||
expected_signature_2 = hmac.new(
|
||||
secret.encode('utf-8'),
|
||||
signed_payload_2.encode('utf-8'),
|
||||
hashlib.sha256
|
||||
).digest()
|
||||
expected_signature_2_b64 = base64.b64encode(expected_signature_2).decode('utf-8')
|
||||
|
||||
signed_payload_3 = f"{timestamp}.{body}"
|
||||
expected_signature_3 = hmac.new(
|
||||
secret.encode('utf-8'),
|
||||
signed_payload_3.encode('utf-8'),
|
||||
hashlib.sha256
|
||||
).digest()
|
||||
expected_signature_3_b64 = base64.b64encode(expected_signature_3).decode('utf-8')
|
||||
|
||||
logger.debug(f"Получена подпись: {received_signature}")
|
||||
logger.debug(f"Ожидаемая подпись (вариант 1): {expected_signature_1_b64}")
|
||||
logger.debug(f"Ожидаемая подпись (вариант 2): {expected_signature_2_b64}")
|
||||
logger.debug(f"Ожидаемая подпись (вариант 3): {expected_signature_3_b64}")
|
||||
|
||||
is_valid = (
|
||||
hmac.compare_digest(received_signature, expected_signature_1_b64) or
|
||||
hmac.compare_digest(received_signature, expected_signature_2_b64) or
|
||||
hmac.compare_digest(received_signature, expected_signature_3_b64)
|
||||
)
|
||||
|
||||
if is_valid:
|
||||
logger.info("✅ Подпись YooKassa webhook проверена успешно")
|
||||
else:
|
||||
logger.warning("⚠️ Подпись YooKassa webhook не совпадает ни с одним вариантом")
|
||||
|
||||
return is_valid
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка проверки подписи YooKassa: {e}")
|
||||
return False
|
||||
|
||||
def __init__(self, payment_service: PaymentService):
|
||||
self.payment_service = payment_service
|
||||
|
||||
|
||||
async def handle_webhook(self, request: web.Request) -> web.Response:
|
||||
|
||||
|
||||
try:
|
||||
logger.info(f"📥 Получен YooKassa webhook: {request.method} {request.path}")
|
||||
logger.info(f"📋 Headers: {dict(request.headers)}")
|
||||
|
||||
header_ip_candidates = collect_yookassa_ip_candidates(
|
||||
request.headers.get("X-Forwarded-For"),
|
||||
request.headers.get("X-Real-IP"),
|
||||
request.headers.get("Cf-Connecting-Ip"),
|
||||
)
|
||||
client_ip = resolve_yookassa_ip(
|
||||
header_ip_candidates,
|
||||
remote=request.remote,
|
||||
)
|
||||
|
||||
if client_ip is None:
|
||||
logger.warning(
|
||||
"🚫 Не удалось определить IP-адрес отправителя YooKassa webhook. Кандидаты: %s",
|
||||
header_ip_candidates + ([request.remote] if request.remote else []),
|
||||
)
|
||||
return web.Response(status=403, text="Forbidden")
|
||||
|
||||
if not is_yookassa_ip_allowed(client_ip):
|
||||
logger.warning(
|
||||
"🚫 YooKassa webhook отклонён: IP %s не входит в доверенные диапазоны (%s)",
|
||||
client_ip,
|
||||
", ".join(str(network) for network in YOOKASSA_ALLOWED_IP_NETWORKS),
|
||||
)
|
||||
return web.Response(status=403, text="Forbidden")
|
||||
|
||||
logger.info("🌐 IP-адрес YooKassa подтверждён: %s", client_ip)
|
||||
|
||||
|
||||
body = await request.text()
|
||||
|
||||
|
||||
if not body:
|
||||
logger.warning("⚠️ Получен пустой webhook от YooKassa")
|
||||
return web.Response(status=400, text="Empty body")
|
||||
|
||||
|
||||
logger.info(f"📄 Body: {body}")
|
||||
|
||||
|
||||
signature = request.headers.get('Signature') or request.headers.get('X-YooKassa-Signature')
|
||||
if signature:
|
||||
logger.info("ℹ️ Получена подпись YooKassa: %s", signature)
|
||||
|
||||
|
||||
if settings.YOOKASSA_WEBHOOK_SECRET and signature:
|
||||
logger.info(f"🔐 Получена подпись: {signature}")
|
||||
|
||||
if not YooKassaWebhookHandler.verify_webhook_signature(body, signature, settings.YOOKASSA_WEBHOOK_SECRET):
|
||||
logger.warning("❌ Подпись не совпала, но продолжаем обработку (режим отладки)")
|
||||
else:
|
||||
logger.info("✅ Подпись webhook проверена успешно")
|
||||
|
||||
elif settings.YOOKASSA_WEBHOOK_SECRET and not signature:
|
||||
logger.warning("⚠️ Webhook без подписи, но секрет настроен")
|
||||
|
||||
elif signature and not settings.YOOKASSA_WEBHOOK_SECRET:
|
||||
logger.info("ℹ️ Подпись получена, но проверка отключена (YOOKASSA_WEBHOOK_SECRET не настроен)")
|
||||
|
||||
else:
|
||||
logger.info("ℹ️ Проверка подписи отключена")
|
||||
|
||||
try:
|
||||
webhook_data = json.loads(body)
|
||||
except json.JSONDecodeError as e:
|
||||
logger.error(f"❌ Ошибка парсинга JSON webhook YooKassa: {e}")
|
||||
return web.Response(status=400, text="Invalid JSON")
|
||||
|
||||
|
||||
logger.info(f"📊 Обработка webhook YooKassa: {webhook_data.get('event', 'unknown_event')}")
|
||||
logger.debug(f"🔍 Полные данные webhook: {webhook_data}")
|
||||
|
||||
# Извлекаем ID платежа из вебхука для предотвращения дублирования
|
||||
yookassa_payment_id = webhook_data.get("object", {}).get("id")
|
||||
if not yookassa_payment_id:
|
||||
logger.warning("⚠️ Webhook YooKassa без ID платежа")
|
||||
return web.Response(status=400, text="No payment ID")
|
||||
|
||||
|
||||
event_type = webhook_data.get("event")
|
||||
if not event_type:
|
||||
logger.warning("⚠️ Webhook YooKassa без типа события")
|
||||
return web.Response(status=400, text="No event type")
|
||||
|
||||
if event_type not in YOOKASSA_ALLOWED_EVENTS:
|
||||
|
||||
if event_type not in ["payment.succeeded", "payment.waiting_for_capture"]:
|
||||
logger.info(f"ℹ️ Игнорируем событие YooKassa: {event_type}")
|
||||
return web.Response(status=200, text="OK")
|
||||
|
||||
|
||||
async for db in get_db():
|
||||
try:
|
||||
# Проверяем, не обрабатывается ли этот платеж уже (защита от дублирования)
|
||||
from app.database.models import PaymentMethod
|
||||
from app.database.crud.transaction import get_transaction_by_external_id
|
||||
existing_transaction = await get_transaction_by_external_id(db, yookassa_payment_id, PaymentMethod.YOOKASSA)
|
||||
|
||||
if existing_transaction and event_type == "payment.succeeded":
|
||||
logger.info(f"ℹ️ Платеж YooKassa {yookassa_payment_id} уже был обработан. Пропускаем дублирующий вебхук.")
|
||||
return web.Response(status=200, text="OK")
|
||||
|
||||
success = await self.payment_service.process_yookassa_webhook(db, webhook_data)
|
||||
|
||||
|
||||
if success:
|
||||
logger.info(f"✅ Успешно обработан webhook YooKassa: {event_type} для платежа {yookassa_payment_id}")
|
||||
logger.info(f"✅ Успешно обработан webhook YooKassa: {event_type}")
|
||||
return web.Response(status=200, text="OK")
|
||||
else:
|
||||
logger.error(f"❌ Ошибка обработки webhook YooKassa: {event_type} для платежа {yookassa_payment_id}")
|
||||
logger.error(f"❌ Ошибка обработки webhook YooKassa: {event_type}")
|
||||
return web.Response(status=500, text="Processing error")
|
||||
|
||||
|
||||
finally:
|
||||
await db.close()
|
||||
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Критическая ошибка обработки webhook YooKassa: {e}", exc_info=True)
|
||||
return web.Response(status=500, text="Internal server error")
|
||||
|
||||
|
||||
def setup_routes(self, app: web.Application) -> None:
|
||||
|
||||
|
||||
webhook_path = settings.YOOKASSA_WEBHOOK_PATH
|
||||
app.router.add_post(webhook_path, self.handle_webhook)
|
||||
app.router.add_get(webhook_path, self._get_handler)
|
||||
app.router.add_options(webhook_path, self._options_handler)
|
||||
|
||||
app.router.add_get(webhook_path, self._get_handler)
|
||||
app.router.add_options(webhook_path, self._options_handler)
|
||||
|
||||
logger.info(f"✅ Настроен YooKassa webhook на пути: POST {webhook_path}")
|
||||
|
||||
|
||||
async def _get_handler(self, request: web.Request) -> web.Response:
|
||||
return web.json_response({
|
||||
"status": "ok",
|
||||
@@ -313,7 +172,7 @@ class YooKassaWebhookHandler:
|
||||
"path": request.path,
|
||||
"note": "Use POST method for actual webhooks"
|
||||
})
|
||||
|
||||
|
||||
async def _options_handler(self, request: web.Request) -> web.Response:
|
||||
return web.Response(
|
||||
status=200,
|
||||
@@ -326,46 +185,46 @@ class YooKassaWebhookHandler:
|
||||
|
||||
|
||||
def create_yookassa_webhook_app(payment_service: PaymentService) -> web.Application:
|
||||
|
||||
|
||||
app = web.Application()
|
||||
|
||||
|
||||
webhook_handler = YooKassaWebhookHandler(payment_service)
|
||||
webhook_handler.setup_routes(app)
|
||||
|
||||
|
||||
async def health_check(request):
|
||||
return web.json_response({
|
||||
"status": "ok",
|
||||
"status": "ok",
|
||||
"service": "yookassa_webhook",
|
||||
"port": settings.YOOKASSA_WEBHOOK_PORT,
|
||||
"path": settings.YOOKASSA_WEBHOOK_PATH,
|
||||
"enabled": settings.is_yookassa_enabled()
|
||||
})
|
||||
|
||||
|
||||
app.router.add_get("/health", health_check)
|
||||
|
||||
|
||||
return app
|
||||
|
||||
|
||||
async def start_yookassa_webhook_server(payment_service: PaymentService) -> None:
|
||||
|
||||
|
||||
if not settings.is_yookassa_enabled():
|
||||
logger.info("ℹ️ YooKassa отключена, webhook сервер не запускается")
|
||||
return
|
||||
|
||||
|
||||
try:
|
||||
app = create_yookassa_webhook_app(payment_service)
|
||||
|
||||
|
||||
runner = web.AppRunner(app)
|
||||
await runner.setup()
|
||||
|
||||
|
||||
site = web.TCPSite(
|
||||
runner,
|
||||
host=settings.YOOKASSA_WEBHOOK_HOST,
|
||||
port=settings.YOOKASSA_WEBHOOK_PORT
|
||||
)
|
||||
|
||||
|
||||
await site.start()
|
||||
|
||||
|
||||
logger.info(
|
||||
"✅ YooKassa webhook сервер запущен на %s:%s",
|
||||
settings.YOOKASSA_WEBHOOK_HOST,
|
||||
@@ -377,7 +236,7 @@ async def start_yookassa_webhook_server(payment_service: PaymentService) -> None
|
||||
settings.YOOKASSA_WEBHOOK_PORT,
|
||||
settings.YOOKASSA_WEBHOOK_PATH,
|
||||
)
|
||||
|
||||
|
||||
try:
|
||||
while True:
|
||||
await asyncio.sleep(1)
|
||||
@@ -387,7 +246,7 @@ async def start_yookassa_webhook_server(payment_service: PaymentService) -> None
|
||||
await site.stop()
|
||||
await runner.cleanup()
|
||||
logger.info("✅ YooKassa webhook сервер остановлен")
|
||||
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Ошибка запуска YooKassa webhook сервера: {e}", exc_info=True)
|
||||
raise
|
||||
raise
|
||||
|
||||
@@ -42,16 +42,7 @@ CATEGORY_GROUP_METADATA: Dict[str, Dict[str, object]] = {
|
||||
"title": "🤖 Основные",
|
||||
"description": "Базовые настройки бота, обязательные каналы и ключевые сервисы.",
|
||||
"icon": "🤖",
|
||||
"categories": (
|
||||
"CORE",
|
||||
"CHANNEL",
|
||||
"TIMEZONE",
|
||||
"DATABASE",
|
||||
"POSTGRES",
|
||||
"SQLITE",
|
||||
"REDIS",
|
||||
"REMNAWAVE",
|
||||
),
|
||||
"categories": ("CORE", "CHANNEL"),
|
||||
},
|
||||
"support": {
|
||||
"title": "💬 Поддержка",
|
||||
@@ -61,18 +52,16 @@ CATEGORY_GROUP_METADATA: Dict[str, Dict[str, object]] = {
|
||||
},
|
||||
"payments": {
|
||||
"title": "💳 Платежные системы",
|
||||
"description": "YooKassa, CryptoBot, Heleket, MulenPay, PAL24, Wata, Platega, Tribute и Telegram Stars.",
|
||||
"description": "YooKassa, CryptoBot, Heleket, MulenPay, PAL24, Wata, Tribute и Telegram Stars.",
|
||||
"icon": "💳",
|
||||
"categories": (
|
||||
"PAYMENT",
|
||||
"PAYMENT_VERIFICATION",
|
||||
"YOOKASSA",
|
||||
"CRYPTOBOT",
|
||||
"HELEKET",
|
||||
"MULENPAY",
|
||||
"PAL24",
|
||||
"WATA",
|
||||
"PLATEGA",
|
||||
"TRIBUTE",
|
||||
"TELEGRAM",
|
||||
),
|
||||
@@ -125,6 +114,18 @@ CATEGORY_GROUP_METADATA: Dict[str, Dict[str, object]] = {
|
||||
"ADDITIONAL",
|
||||
),
|
||||
},
|
||||
"database": {
|
||||
"title": "💾 База данных",
|
||||
"description": "Режим базы, параметры PostgreSQL, SQLite и Redis.",
|
||||
"icon": "💾",
|
||||
"categories": ("DATABASE", "POSTGRES", "SQLITE", "REDIS"),
|
||||
},
|
||||
"remnawave": {
|
||||
"title": "🌐 RemnaWave API",
|
||||
"description": "Интеграция с RemnaWave: URL, ключи и способы авторизации.",
|
||||
"icon": "🌐",
|
||||
"categories": ("REMNAWAVE",),
|
||||
},
|
||||
"server": {
|
||||
"title": "📊 Статус серверов",
|
||||
"description": "Мониторинг серверов, SLA и внешние метрики.",
|
||||
@@ -141,14 +142,13 @@ CATEGORY_GROUP_METADATA: Dict[str, Dict[str, object]] = {
|
||||
"title": "⚡ Расширенные",
|
||||
"description": "Web API, webhook, логирование, модерация и режим отладки.",
|
||||
"icon": "⚡",
|
||||
"categories": (
|
||||
"WEB_API",
|
||||
"WEBHOOK",
|
||||
"LOG",
|
||||
"MODERATION",
|
||||
"DEBUG",
|
||||
"EXTERNAL_ADMIN",
|
||||
),
|
||||
"categories": ("WEB_API", "WEBHOOK", "LOG", "MODERATION", "DEBUG"),
|
||||
},
|
||||
"external_admin": {
|
||||
"title": "🛡️ Внешняя админка",
|
||||
"description": "Токен, по которому внешняя админка проверяет запросы.",
|
||||
"icon": "🛡️",
|
||||
"categories": ("EXTERNAL_ADMIN",),
|
||||
},
|
||||
}
|
||||
|
||||
@@ -161,9 +161,12 @@ CATEGORY_GROUP_ORDER: Tuple[str, ...] = (
|
||||
"referral",
|
||||
"notifications",
|
||||
"interface",
|
||||
"database",
|
||||
"remnawave",
|
||||
"server",
|
||||
"maintenance",
|
||||
"advanced",
|
||||
"external_admin",
|
||||
)
|
||||
|
||||
CATEGORY_GROUP_DEFINITIONS: Tuple[Tuple[str, str, Tuple[str, ...]], ...] = tuple(
|
||||
@@ -254,7 +257,6 @@ def _get_group_status(group_key: str) -> Tuple[str, str]:
|
||||
payment_statuses = {
|
||||
"YooKassa": settings.is_yookassa_enabled(),
|
||||
"CryptoBot": settings.is_cryptobot_enabled(),
|
||||
"Platega": settings.is_platega_enabled(),
|
||||
"MulenPay": settings.is_mulenpay_enabled(),
|
||||
"PAL24": settings.is_pal24_enabled(),
|
||||
"Tribute": settings.TRIBUTE_ENABLED,
|
||||
@@ -311,6 +313,7 @@ def _get_group_status(group_key: str) -> Tuple[str, str]:
|
||||
settings.REFERRAL_COMMISSION_PERCENT
|
||||
or settings.REFERRAL_FIRST_TOPUP_BONUS_KOPEKS
|
||||
or settings.REFERRAL_INVITER_BONUS_KOPEKS
|
||||
or settings.get_referred_user_reward_kopeks()
|
||||
)
|
||||
return ("🟢", "Программа активна") if active else ("⚪", "Бонусы не заданы")
|
||||
|
||||
|
||||
@@ -46,9 +46,6 @@ def _format_campaign_summary(campaign, texts) -> str:
|
||||
bonus_info = f"💰 Бонус на баланс: <b>{bonus_text}</b>"
|
||||
else:
|
||||
traffic_text = texts.format_traffic(campaign.subscription_traffic_gb or 0)
|
||||
device_limit = campaign.subscription_device_limit
|
||||
if device_limit is None:
|
||||
device_limit = settings.DEFAULT_DEVICE_LIMIT
|
||||
bonus_info = (
|
||||
"📱 Подписка: <b>{days} д.</b>\n"
|
||||
"🌐 Трафик: <b>{traffic}</b>\n"
|
||||
@@ -56,7 +53,7 @@ def _format_campaign_summary(campaign, texts) -> str:
|
||||
).format(
|
||||
days=campaign.subscription_duration_days or 0,
|
||||
traffic=traffic_text,
|
||||
devices=device_limit,
|
||||
devices=campaign.subscription_device_limit or settings.DEFAULT_DEVICE_LIMIT,
|
||||
)
|
||||
|
||||
return (
|
||||
@@ -938,9 +935,7 @@ async def start_edit_campaign_subscription_devices(
|
||||
campaign_edit_message_is_caption=is_caption,
|
||||
)
|
||||
|
||||
current_devices = campaign.subscription_device_limit
|
||||
if current_devices is None:
|
||||
current_devices = settings.DEFAULT_DEVICE_LIMIT
|
||||
current_devices = campaign.subscription_device_limit or settings.DEFAULT_DEVICE_LIMIT
|
||||
|
||||
await callback.message.edit_text(
|
||||
(
|
||||
|
||||
@@ -449,4 +449,4 @@ def register_handlers(dp: Dispatcher):
|
||||
dp.message.register(
|
||||
admin_commands_help,
|
||||
Command("admin_help")
|
||||
)
|
||||
)
|
||||
@@ -74,7 +74,6 @@ async def show_maintenance_panel(
|
||||
{status_emoji} <b>Режим техработ:</b> {status_text}
|
||||
{api_emoji} <b>API Remnawave:</b> {api_text}
|
||||
{monitoring_emoji} <b>Мониторинг:</b> {monitoring_text}
|
||||
🛠️ <b>Автозапуск мониторинга:</b> {'Включен' if status_info['monitoring_configured'] else 'Отключен'}
|
||||
⏱️ <b>Интервал проверки:</b> {status_info['check_interval']}с
|
||||
🤖 <b>Автовключение:</b> {'Включено' if status_info['auto_enable_configured'] else 'Отключено'}
|
||||
{panel_info}
|
||||
@@ -234,11 +233,7 @@ async def check_panel_status(
|
||||
f"👥 Пользователей онлайн: {status_data.get('users_online', 0)}",
|
||||
f"🖥️ Нод онлайн: {status_data.get('nodes_online', 0)}/{status_data.get('total_nodes', 0)}"
|
||||
]
|
||||
|
||||
attempts_used = status_data.get("attempts_used")
|
||||
if attempts_used:
|
||||
message_parts.append(f"🔁 Попыток проверки: {attempts_used}")
|
||||
|
||||
|
||||
if status_data.get("api_error"):
|
||||
message_parts.append(f"❌ Ошибка: {status_data['api_error'][:100]}")
|
||||
|
||||
|
||||
@@ -764,69 +764,48 @@ async def confirm_broadcast(
|
||||
|
||||
broadcast_keyboard = create_broadcast_keyboard(selected_buttons, db_user.language)
|
||||
|
||||
# Ограничение на количество одновременных отправок
|
||||
semaphore = asyncio.Semaphore(20)
|
||||
|
||||
async def send_single_broadcast(user):
|
||||
"""Отправляет одно сообщение рассылки с семафором ограничения"""
|
||||
async with semaphore:
|
||||
try:
|
||||
if has_media and media_file_id:
|
||||
if media_type == "photo":
|
||||
await callback.bot.send_photo(
|
||||
chat_id=user.telegram_id,
|
||||
photo=media_file_id,
|
||||
caption=message_text,
|
||||
parse_mode="HTML",
|
||||
reply_markup=broadcast_keyboard
|
||||
)
|
||||
elif media_type == "video":
|
||||
await callback.bot.send_video(
|
||||
chat_id=user.telegram_id,
|
||||
video=media_file_id,
|
||||
caption=message_text,
|
||||
parse_mode="HTML",
|
||||
reply_markup=broadcast_keyboard
|
||||
)
|
||||
elif media_type == "document":
|
||||
await callback.bot.send_document(
|
||||
chat_id=user.telegram_id,
|
||||
document=media_file_id,
|
||||
caption=message_text,
|
||||
parse_mode="HTML",
|
||||
reply_markup=broadcast_keyboard
|
||||
)
|
||||
else:
|
||||
await callback.bot.send_message(
|
||||
for user in users:
|
||||
try:
|
||||
if has_media and media_file_id:
|
||||
if media_type == "photo":
|
||||
await callback.bot.send_photo(
|
||||
chat_id=user.telegram_id,
|
||||
text=message_text,
|
||||
photo=media_file_id,
|
||||
caption=message_text,
|
||||
parse_mode="HTML",
|
||||
reply_markup=broadcast_keyboard
|
||||
)
|
||||
return True, user.telegram_id
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка отправки рассылки пользователю {user.telegram_id}: {e}")
|
||||
return False, user.telegram_id
|
||||
|
||||
# Отправляем сообщения пакетами для эффективности
|
||||
batch_size = 100
|
||||
for i in range(0, len(users), batch_size):
|
||||
batch = users[i:i + batch_size]
|
||||
tasks = [send_single_broadcast(user) for user in batch]
|
||||
results = await asyncio.gather(*tasks, return_exceptions=True)
|
||||
|
||||
for result in results:
|
||||
if isinstance(result, tuple): # (success, telegram_id)
|
||||
success, _ = result
|
||||
if success:
|
||||
sent_count += 1
|
||||
else:
|
||||
failed_count += 1
|
||||
elif isinstance(result, Exception):
|
||||
failed_count += 1
|
||||
|
||||
# Небольшая задержка между пакетами для снижения нагрузки на API
|
||||
await asyncio.sleep(0.1)
|
||||
elif media_type == "video":
|
||||
await callback.bot.send_video(
|
||||
chat_id=user.telegram_id,
|
||||
video=media_file_id,
|
||||
caption=message_text,
|
||||
parse_mode="HTML",
|
||||
reply_markup=broadcast_keyboard
|
||||
)
|
||||
elif media_type == "document":
|
||||
await callback.bot.send_document(
|
||||
chat_id=user.telegram_id,
|
||||
document=media_file_id,
|
||||
caption=message_text,
|
||||
parse_mode="HTML",
|
||||
reply_markup=broadcast_keyboard
|
||||
)
|
||||
else:
|
||||
await callback.bot.send_message(
|
||||
chat_id=user.telegram_id,
|
||||
text=message_text,
|
||||
parse_mode="HTML",
|
||||
reply_markup=broadcast_keyboard
|
||||
)
|
||||
sent_count += 1
|
||||
|
||||
if sent_count % 20 == 0:
|
||||
await asyncio.sleep(1)
|
||||
|
||||
except Exception as e:
|
||||
failed_count += 1
|
||||
logger.error(f"Ошибка отправки рассылки пользователю {user.telegram_id}: {e}")
|
||||
|
||||
broadcast_history.sent_count = sent_count
|
||||
broadcast_history.failed_count = failed_count
|
||||
|
||||
@@ -1027,20 +1027,6 @@ async def process_notification_value_input(message: Message, state: FSMContext):
|
||||
language = data.get("settings_language") or message.from_user.language_code or settings.DEFAULT_LANGUAGE
|
||||
texts = get_texts(language)
|
||||
|
||||
# Добавляем дополнительные проверки диапазона значений
|
||||
if (key == "expired_second_wave" and field == "percent") or (key == "expired_third_wave" and field == "percent"):
|
||||
if value < 0 or value > 100:
|
||||
await message.answer("❌ Процент скидки должен быть от 0 до 100.")
|
||||
return
|
||||
elif (key == "expired_second_wave" and field == "hours") or (key == "expired_third_wave" and field == "hours"):
|
||||
if value < 1 or value > 168: # Максимум 168 часов (7 дней)
|
||||
await message.answer("❌ Количество часов должно быть от 1 до 168.")
|
||||
return
|
||||
elif key == "expired_third_wave" and field == "trigger":
|
||||
if value < 2: # Минимум 2 дня
|
||||
await message.answer("❌ Количество дней должно быть не менее 2.")
|
||||
return
|
||||
|
||||
success = False
|
||||
if key == "expired_second_wave" and field == "percent":
|
||||
success = NotificationSettingsService.set_second_wave_discount_percent(value)
|
||||
@@ -1089,4 +1075,4 @@ async def process_notification_value_input(message: Message, state: FSMContext):
|
||||
|
||||
|
||||
def register_handlers(dp):
|
||||
dp.include_router(router)
|
||||
dp.include_router(router)
|
||||
@@ -37,8 +37,6 @@ def _method_display(method: PaymentMethod) -> str:
|
||||
return "Heleket"
|
||||
if method == PaymentMethod.YOOKASSA:
|
||||
return "YooKassa"
|
||||
if method == PaymentMethod.PLATEGA:
|
||||
return "Platega"
|
||||
if method == PaymentMethod.CRYPTOBOT:
|
||||
return "CryptoBot"
|
||||
if method == PaymentMethod.TELEGRAM_STARS:
|
||||
@@ -92,18 +90,6 @@ def _status_info(
|
||||
}
|
||||
return mapping.get(status, ("❓", texts.t("ADMIN_PAYMENT_STATUS_UNKNOWN", "❓ Unknown")))
|
||||
|
||||
if record.method == PaymentMethod.PLATEGA:
|
||||
mapping = {
|
||||
"pending": ("⏳", texts.t("ADMIN_PAYMENT_STATUS_PENDING", "⏳ Pending")),
|
||||
"inprogress": ("⌛", texts.t("ADMIN_PAYMENT_STATUS_PROCESSING", "⌛ Processing")),
|
||||
"confirmed": ("✅", texts.t("ADMIN_PAYMENT_STATUS_PAID", "✅ Paid")),
|
||||
"failed": ("❌", texts.t("ADMIN_PAYMENT_STATUS_FAILED", "❌ Failed")),
|
||||
"canceled": ("❌", texts.t("ADMIN_PAYMENT_STATUS_CANCELED", "❌ Cancelled")),
|
||||
"cancelled": ("❌", texts.t("ADMIN_PAYMENT_STATUS_CANCELED", "❌ Cancelled")),
|
||||
"expired": ("⌛", texts.t("ADMIN_PAYMENT_STATUS_EXPIRED", "⌛ Expired")),
|
||||
}
|
||||
return mapping.get(status, ("❓", texts.t("ADMIN_PAYMENT_STATUS_UNKNOWN", "❓ Unknown")))
|
||||
|
||||
if record.method == PaymentMethod.HELEKET:
|
||||
if status in {"pending", "created", "waiting", "check", "processing"}:
|
||||
return "⏳", texts.t("ADMIN_PAYMENT_STATUS_PENDING", "⏳ Pending")
|
||||
@@ -150,8 +136,6 @@ def _is_checkable(record: PendingPayment) -> bool:
|
||||
return status in {"created", "processing", "hold"}
|
||||
if record.method == PaymentMethod.WATA:
|
||||
return status in {"opened", "pending", "processing", "inprogress", "in_progress"}
|
||||
if record.method == PaymentMethod.PLATEGA:
|
||||
return status in {"pending", "inprogress", "in_progress"}
|
||||
if record.method == PaymentMethod.HELEKET:
|
||||
return status not in {"paid", "paid_over", "cancel", "canceled", "fail", "failed", "expired"}
|
||||
if record.method == PaymentMethod.YOOKASSA:
|
||||
|
||||
@@ -96,24 +96,6 @@ TRIAL_ENTRIES: Tuple[SettingEntry, ...] = (
|
||||
label_en="📱 Device limit",
|
||||
action="input",
|
||||
),
|
||||
SettingEntry(
|
||||
key="TRIAL_PAYMENT_ENABLED",
|
||||
section="trial",
|
||||
label_ru="💳 Платная активация",
|
||||
label_en="💳 Paid activation",
|
||||
action="toggle",
|
||||
description_ru="Если включено — за активацию триала будет списываться указанная сумма.",
|
||||
description_en="When enabled, the configured amount is charged during trial activation.",
|
||||
),
|
||||
SettingEntry(
|
||||
key="TRIAL_ACTIVATION_PRICE",
|
||||
section="trial",
|
||||
label_ru="💰 Стоимость активации",
|
||||
label_en="💰 Activation price",
|
||||
action="price",
|
||||
description_ru="Указывается в копейках. 0 — бесплатная активация.",
|
||||
description_en="Amount in kopeks. 0 — free activation.",
|
||||
),
|
||||
SettingEntry(
|
||||
key="TRIAL_ADD_REMAINING_DAYS_TO_PAID",
|
||||
section="trial",
|
||||
@@ -123,6 +105,15 @@ TRIAL_ENTRIES: Tuple[SettingEntry, ...] = (
|
||||
description_ru="Если включено — при покупке платной подписки оставшиеся дни триала будут добавлены к сроку.",
|
||||
description_en="When enabled, remaining trial days are added to paid subscription duration.",
|
||||
),
|
||||
SettingEntry(
|
||||
key="TRIAL_SQUAD_UUID",
|
||||
section="trial",
|
||||
label_ru="🆔 Squad UUID",
|
||||
label_en="🆔 Squad UUID",
|
||||
action="input",
|
||||
description_ru="Можно оставить пустым, если не требуется назначать пробные подписки в конкретный Squad.",
|
||||
description_en="Leave empty if trial subscriptions shouldn't be bound to a specific Squad.",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -299,14 +290,11 @@ def _format_trial_summary(lang_code: str) -> str:
|
||||
duration = settings.TRIAL_DURATION_DAYS
|
||||
traffic = settings.TRIAL_TRAFFIC_LIMIT_GB
|
||||
devices = settings.TRIAL_DEVICE_LIMIT
|
||||
price_note = ""
|
||||
if settings.is_trial_paid_activation_enabled():
|
||||
price_note = f", 💳 {settings.format_price(settings.get_trial_activation_price())}"
|
||||
|
||||
traffic_label = _format_traffic_label(traffic, lang_code, short=True)
|
||||
devices_label = f"{devices}📱" if lang_code == "ru" else f"{devices}📱"
|
||||
days_suffix = "д" if lang_code == "ru" else "d"
|
||||
return f"{duration}{days_suffix}, {traffic_label}, {devices_label}{price_note}"
|
||||
return f"{duration}{days_suffix}, {traffic_label}, {devices_label}"
|
||||
|
||||
|
||||
def _format_core_summary(lang_code: str) -> str:
|
||||
|
||||
@@ -211,14 +211,6 @@ def _format_rubles(amount_kopeks: int) -> str:
|
||||
return formatted.replace(",", " ")
|
||||
|
||||
|
||||
def _format_priority_line(texts, group: PromoGroup) -> str:
|
||||
priority = getattr(group, "priority", 0)
|
||||
return texts.t(
|
||||
"ADMIN_PROMO_GROUP_PRIORITY_LINE",
|
||||
"🎯 Приоритет: {priority}",
|
||||
).format(priority=priority)
|
||||
|
||||
|
||||
def _format_auto_assign_line(texts, group: PromoGroup) -> str:
|
||||
threshold = getattr(group, "auto_assign_total_spent_kopeks", 0) or 0
|
||||
|
||||
@@ -302,7 +294,6 @@ def _build_edit_menu_content(
|
||||
lines = [header]
|
||||
lines.extend(_format_discount_lines(texts, group))
|
||||
lines.append(_format_addon_discounts_line(texts, group))
|
||||
lines.append(_format_priority_line(texts, group))
|
||||
lines.append(_format_auto_assign_line(texts, group))
|
||||
|
||||
period_lines = _format_period_discounts_lines(texts, group, language)
|
||||
@@ -327,15 +318,6 @@ def _build_edit_menu_content(
|
||||
callback_data=f"promo_group_edit_field_{group.id}_name",
|
||||
)
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.t(
|
||||
"ADMIN_PROMO_GROUP_EDIT_FIELD_PRIORITY",
|
||||
"🎯 Приоритет",
|
||||
),
|
||||
callback_data=f"promo_group_edit_field_{group.id}_priority",
|
||||
)
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.t(
|
||||
@@ -658,32 +640,6 @@ async def process_create_group_name(message: types.Message, state: FSMContext):
|
||||
return
|
||||
|
||||
await state.update_data(new_group_name=name)
|
||||
await state.set_state(AdminStates.creating_promo_group_priority)
|
||||
texts = get_texts((await state.get_data()).get("language", "ru"))
|
||||
await message.answer(
|
||||
texts.t(
|
||||
"ADMIN_PROMO_GROUP_CREATE_PRIORITY_PROMPT",
|
||||
"Введите приоритет группы (0 = базовая, чем больше - тем выше приоритет):",
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
async def process_create_group_priority(message: types.Message, state: FSMContext):
|
||||
texts = get_texts((await state.get_data()).get("language", "ru"))
|
||||
try:
|
||||
priority = int(message.text)
|
||||
if priority < 0:
|
||||
raise ValueError
|
||||
except (ValueError, TypeError):
|
||||
await message.answer(
|
||||
texts.t(
|
||||
"ADMIN_PROMO_GROUP_INVALID_PRIORITY",
|
||||
"❌ Приоритет должен быть неотрицательным целым числом",
|
||||
)
|
||||
)
|
||||
return
|
||||
|
||||
await state.update_data(new_group_priority=priority)
|
||||
await state.set_state(AdminStates.creating_promo_group_traffic_discount)
|
||||
await _prompt_for_discount(
|
||||
message,
|
||||
@@ -816,7 +772,6 @@ async def process_create_group_auto_assign(
|
||||
group = await create_promo_group(
|
||||
db,
|
||||
data["new_group_name"],
|
||||
priority=data.get("new_group_priority", 0),
|
||||
traffic_discount_percent=data["new_group_traffic"],
|
||||
server_discount_percent=data["new_group_servers"],
|
||||
device_discount_percent=data["new_group_devices"],
|
||||
@@ -907,12 +862,6 @@ async def prompt_edit_promo_group_field(
|
||||
"ADMIN_PROMO_GROUP_EDIT_NAME_PROMPT",
|
||||
"Введите новое название промогруппы (текущее: {name}):",
|
||||
).format(name=group.name)
|
||||
elif field == "priority":
|
||||
await state.set_state(AdminStates.editing_promo_group_priority)
|
||||
prompt = texts.t(
|
||||
"ADMIN_PROMO_GROUP_EDIT_PRIORITY_PROMPT",
|
||||
"Введите новый приоритет (текущий: {current}):",
|
||||
).format(current=getattr(group, "priority", 0))
|
||||
elif field == "traffic":
|
||||
await state.set_state(AdminStates.editing_promo_group_traffic_discount)
|
||||
prompt = texts.t(
|
||||
@@ -986,48 +935,6 @@ async def process_edit_group_name(
|
||||
)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def process_edit_group_priority(
|
||||
message: types.Message,
|
||||
state: FSMContext,
|
||||
db_user,
|
||||
db: AsyncSession,
|
||||
):
|
||||
data = await state.get_data()
|
||||
texts = get_texts(data.get("language", db_user.language))
|
||||
|
||||
try:
|
||||
priority = int(message.text)
|
||||
if priority < 0:
|
||||
raise ValueError
|
||||
except (ValueError, TypeError):
|
||||
await message.answer(
|
||||
texts.t(
|
||||
"ADMIN_PROMO_GROUP_INVALID_PRIORITY",
|
||||
"❌ Приоритет должен быть неотрицательным целым числом",
|
||||
)
|
||||
)
|
||||
return
|
||||
|
||||
group = await get_promo_group_by_id(db, data.get("edit_group_id"))
|
||||
if not group:
|
||||
await message.answer("❌ Промогруппа не найдена")
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
group = await update_promo_group(db, group, priority=priority)
|
||||
await state.set_state(AdminStates.editing_promo_group_menu)
|
||||
|
||||
await _send_edit_menu_after_update(
|
||||
message,
|
||||
texts,
|
||||
group,
|
||||
data.get("language", db_user.language),
|
||||
texts.t("ADMIN_PROMO_GROUP_UPDATED", "Промогруппа «{name}» обновлена.").format(name=group.name),
|
||||
)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def process_edit_group_traffic(
|
||||
@@ -1251,9 +1158,8 @@ async def show_promo_group_members(
|
||||
lines = []
|
||||
for index, user in enumerate(members, start=offset + 1):
|
||||
username = f"@{user.username}" if user.username else "—"
|
||||
user_link = f'<a href="tg://user?id={user.telegram_id}">{user.full_name}</a>'
|
||||
lines.append(
|
||||
f"{index}. {user_link} (ID {user.id}, {username}, TG {user.telegram_id})"
|
||||
f"{index}. {user.full_name} (ID {user.id}, {username}, TG {user.telegram_id})"
|
||||
)
|
||||
body = "\n".join(lines)
|
||||
|
||||
@@ -1275,7 +1181,6 @@ async def show_promo_group_members(
|
||||
await callback.message.edit_text(
|
||||
f"{title}\n\n{body}",
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard),
|
||||
parse_mode="HTML",
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
@@ -1418,10 +1323,6 @@ def register_handlers(dp: Dispatcher):
|
||||
)
|
||||
|
||||
dp.message.register(process_create_group_name, AdminStates.creating_promo_group_name)
|
||||
dp.message.register(
|
||||
process_create_group_priority,
|
||||
AdminStates.creating_promo_group_priority,
|
||||
)
|
||||
dp.message.register(
|
||||
process_create_group_traffic,
|
||||
AdminStates.creating_promo_group_traffic_discount,
|
||||
@@ -1444,10 +1345,6 @@ def register_handlers(dp: Dispatcher):
|
||||
)
|
||||
|
||||
dp.message.register(process_edit_group_name, AdminStates.editing_promo_group_name)
|
||||
dp.message.register(
|
||||
process_edit_group_priority,
|
||||
AdminStates.editing_promo_group_priority,
|
||||
)
|
||||
dp.message.register(
|
||||
process_edit_group_traffic,
|
||||
AdminStates.editing_promo_group_traffic_discount,
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import html
|
||||
import logging
|
||||
import re
|
||||
@@ -1897,94 +1896,66 @@ async def _send_offer_to_users(
|
||||
squad_name: Optional[str],
|
||||
effect_type: str,
|
||||
) -> Tuple[int, int]:
|
||||
from app.database.database import AsyncSessionLocal
|
||||
|
||||
sent = 0
|
||||
failed = 0
|
||||
|
||||
# Ограничение на количество одновременных отправок
|
||||
semaphore = asyncio.Semaphore(20)
|
||||
for user in users:
|
||||
try:
|
||||
offer_record = await upsert_discount_offer(
|
||||
db,
|
||||
user_id=user.id,
|
||||
subscription_id=user.subscription.id if user.subscription else None,
|
||||
notification_type=f"promo_template_{template.id}",
|
||||
discount_percent=template.discount_percent,
|
||||
bonus_amount_kopeks=0,
|
||||
valid_hours=template.valid_hours,
|
||||
effect_type=effect_type,
|
||||
extra_data={
|
||||
"template_id": template.id,
|
||||
"offer_type": template.offer_type,
|
||||
"test_duration_hours": template.test_duration_hours,
|
||||
"test_squad_uuids": template.test_squad_uuids,
|
||||
"active_discount_hours": template.active_discount_hours,
|
||||
},
|
||||
)
|
||||
|
||||
async def send_single_offer(user):
|
||||
"""Отправляет одно предложение с семафором ограничения"""
|
||||
async with semaphore:
|
||||
try:
|
||||
# Используем отдельную сессию для изоляции транзакции
|
||||
async with AsyncSessionLocal() as new_db:
|
||||
offer_record = await upsert_discount_offer(
|
||||
new_db,
|
||||
user_id=user.id,
|
||||
subscription_id=getattr(user, "subscription", None).id if getattr(user, "subscription", None) else None,
|
||||
notification_type=f"promo_template_{template.id}",
|
||||
discount_percent=template.discount_percent,
|
||||
bonus_amount_kopeks=0,
|
||||
valid_hours=template.valid_hours,
|
||||
effect_type=effect_type,
|
||||
extra_data={
|
||||
"template_id": template.id,
|
||||
"offer_type": template.offer_type,
|
||||
"test_duration_hours": template.test_duration_hours,
|
||||
"test_squad_uuids": template.test_squad_uuids,
|
||||
"active_discount_hours": template.active_discount_hours,
|
||||
},
|
||||
user_texts = get_texts(user.language or db_user.language)
|
||||
keyboard_rows: List[List[InlineKeyboardButton]] = [
|
||||
[
|
||||
build_miniapp_or_callback_button(
|
||||
text=template.button_text,
|
||||
callback_data=f"claim_discount_{offer_record.id}",
|
||||
)
|
||||
]
|
||||
]
|
||||
|
||||
user_texts = get_texts(user.language or db_user.language)
|
||||
keyboard_rows: List[List[InlineKeyboardButton]] = [
|
||||
[
|
||||
build_miniapp_or_callback_button(
|
||||
text=template.button_text,
|
||||
callback_data=f"claim_discount_{offer_record.id}",
|
||||
)
|
||||
]
|
||||
]
|
||||
keyboard_rows.append([
|
||||
InlineKeyboardButton(
|
||||
text=user_texts.t("PROMO_OFFER_CLOSE", "❌ Закрыть"),
|
||||
callback_data="promo_offer_close",
|
||||
)
|
||||
])
|
||||
|
||||
keyboard_rows.append([
|
||||
InlineKeyboardButton(
|
||||
text=user_texts.t("PROMO_OFFER_CLOSE", "❌ Закрыть"),
|
||||
callback_data="promo_offer_close",
|
||||
)
|
||||
])
|
||||
keyboard = InlineKeyboardMarkup(inline_keyboard=keyboard_rows)
|
||||
|
||||
keyboard = InlineKeyboardMarkup(inline_keyboard=keyboard_rows)
|
||||
|
||||
message_text = _render_template_text(
|
||||
template,
|
||||
user.language or db_user.language,
|
||||
server_name=squad_name,
|
||||
)
|
||||
await bot.send_message(
|
||||
chat_id=user.telegram_id,
|
||||
text=message_text,
|
||||
reply_markup=keyboard,
|
||||
parse_mode="HTML",
|
||||
)
|
||||
return True
|
||||
except (TelegramForbiddenError, TelegramBadRequest) as exc:
|
||||
logger.warning("Не удалось отправить предложение пользователю %s: %s", user.telegram_id, exc)
|
||||
return False
|
||||
except Exception as exc: # pragma: no cover - defensive logging
|
||||
logger.error("Ошибка рассылки промо предложения пользователю %s: %s", user.telegram_id, exc)
|
||||
return False
|
||||
|
||||
# Отправляем предложения пакетами для эффективности
|
||||
batch_size = 100
|
||||
for i in range(0, len(users), batch_size):
|
||||
batch = users[i:i + batch_size]
|
||||
tasks = [send_single_offer(user) for user in batch]
|
||||
results = await asyncio.gather(*tasks, return_exceptions=True)
|
||||
|
||||
for result in results:
|
||||
if isinstance(result, bool): # Успешно или неуспешно
|
||||
if result:
|
||||
sent += 1
|
||||
else:
|
||||
failed += 1
|
||||
elif isinstance(result, Exception): # Ошибка выполнения задачи
|
||||
failed += 1
|
||||
|
||||
# Небольшая задержка между пакетами для снижения нагрузки на API
|
||||
await asyncio.sleep(0.1)
|
||||
message_text = _render_template_text(
|
||||
template,
|
||||
user.language or db_user.language,
|
||||
server_name=squad_name,
|
||||
)
|
||||
await bot.send_message(
|
||||
chat_id=user.telegram_id,
|
||||
text=message_text,
|
||||
reply_markup=keyboard,
|
||||
parse_mode="HTML",
|
||||
)
|
||||
sent += 1
|
||||
except (TelegramForbiddenError, TelegramBadRequest) as exc:
|
||||
logger.warning("Не удалось отправить предложение пользователю %s: %s", user.telegram_id, exc)
|
||||
failed += 1
|
||||
except Exception as exc: # pragma: no cover - defensive logging
|
||||
logger.error("Ошибка рассылки промо предложения пользователю %s: %s", user.telegram_id, exc)
|
||||
failed += 1
|
||||
|
||||
return sent, failed
|
||||
|
||||
|
||||
@@ -15,9 +15,8 @@ from app.localization.texts import get_texts
|
||||
from app.database.crud.promocode import (
|
||||
get_promocodes_list, get_promocodes_count, create_promocode,
|
||||
get_promocode_statistics, get_promocode_by_code, update_promocode,
|
||||
delete_promocode, get_promocode_by_id
|
||||
delete_promocode
|
||||
)
|
||||
from app.database.crud.promo_group import get_promo_group_by_id, get_promo_groups_with_counts
|
||||
from app.utils.decorators import admin_required, error_handler
|
||||
from app.utils.formatters import format_datetime
|
||||
|
||||
@@ -82,24 +81,16 @@ async def show_promocodes_list(
|
||||
|
||||
for promo in promocodes:
|
||||
status_emoji = "✅" if promo.is_active else "❌"
|
||||
type_emoji = {
|
||||
"balance": "💰",
|
||||
"subscription_days": "📅",
|
||||
"trial_subscription": "🎁",
|
||||
"promo_group": "🏷️"
|
||||
}.get(promo.type, "🎫")
|
||||
|
||||
type_emoji = {"balance": "💰", "subscription_days": "📅", "trial_subscription": "🎁"}.get(promo.type, "🎫")
|
||||
|
||||
text += f"{status_emoji} {type_emoji} <code>{promo.code}</code>\n"
|
||||
text += f"📊 Использований: {promo.current_uses}/{promo.max_uses}\n"
|
||||
|
||||
|
||||
if promo.type == PromoCodeType.BALANCE.value:
|
||||
text += f"💰 Бонус: {settings.format_price(promo.balance_bonus_kopeks)}\n"
|
||||
elif promo.type == PromoCodeType.SUBSCRIPTION_DAYS.value:
|
||||
text += f"📅 Дней: {promo.subscription_days}\n"
|
||||
elif promo.type == PromoCodeType.PROMO_GROUP.value:
|
||||
if promo.promo_group:
|
||||
text += f"🏷️ Промогруппа: {promo.promo_group.name}\n"
|
||||
|
||||
|
||||
if promo.valid_until:
|
||||
text += f"⏰ До: {format_datetime(promo.valid_until)}\n"
|
||||
|
||||
@@ -138,20 +129,15 @@ async def show_promocode_management(
|
||||
db: AsyncSession
|
||||
):
|
||||
promo_id = int(callback.data.split('_')[-1])
|
||||
|
||||
promo = await get_promocode_by_id(db, promo_id)
|
||||
|
||||
promo = await db.get(PromoCode, promo_id)
|
||||
if not promo:
|
||||
await callback.answer("❌ Промокод не найден", show_alert=True)
|
||||
return
|
||||
|
||||
status_emoji = "✅" if promo.is_active else "❌"
|
||||
type_emoji = {
|
||||
"balance": "💰",
|
||||
"subscription_days": "📅",
|
||||
"trial_subscription": "🎁",
|
||||
"promo_group": "🏷️"
|
||||
}.get(promo.type, "🎫")
|
||||
|
||||
type_emoji = {"balance": "💰", "subscription_days": "📅", "trial_subscription": "🎁"}.get(promo.type, "🎫")
|
||||
|
||||
text = f"""
|
||||
🎫 <b>Управление промокодом</b>
|
||||
|
||||
@@ -159,17 +145,12 @@ async def show_promocode_management(
|
||||
{status_emoji} <b>Статус:</b> {'Активен' if promo.is_active else 'Неактивен'}
|
||||
📊 <b>Использований:</b> {promo.current_uses}/{promo.max_uses}
|
||||
"""
|
||||
|
||||
|
||||
if promo.type == PromoCodeType.BALANCE.value:
|
||||
text += f"💰 <b>Бонус:</b> {settings.format_price(promo.balance_bonus_kopeks)}\n"
|
||||
elif promo.type == PromoCodeType.SUBSCRIPTION_DAYS.value:
|
||||
text += f"📅 <b>Дней:</b> {promo.subscription_days}\n"
|
||||
elif promo.type == PromoCodeType.PROMO_GROUP.value:
|
||||
if promo.promo_group:
|
||||
text += f"🏷️ <b>Промогруппа:</b> {promo.promo_group.name} (приоритет: {promo.promo_group.priority})\n"
|
||||
elif promo.promo_group_id:
|
||||
text += f"🏷️ <b>Промогруппа ID:</b> {promo.promo_group_id} (не найдена)\n"
|
||||
|
||||
|
||||
if promo.valid_until:
|
||||
text += f"⏰ <b>Действует до:</b> {format_datetime(promo.valid_until)}\n"
|
||||
|
||||
@@ -219,8 +200,8 @@ async def show_promocode_edit_menu(
|
||||
except (ValueError, IndexError):
|
||||
await callback.answer("❌ Ошибка получения ID промокода", show_alert=True)
|
||||
return
|
||||
|
||||
promo = await get_promocode_by_id(db, promo_id)
|
||||
|
||||
promo = await db.get(PromoCode, promo_id)
|
||||
if not promo:
|
||||
await callback.answer("❌ Промокод не найден", show_alert=True)
|
||||
return
|
||||
@@ -464,14 +445,13 @@ async def select_promocode_type(
|
||||
state: FSMContext
|
||||
):
|
||||
promo_type = callback.data.split('_')[-1]
|
||||
|
||||
|
||||
type_names = {
|
||||
"balance": "💰 Пополнение баланса",
|
||||
"days": "📅 Дни подписки",
|
||||
"trial": "🎁 Тестовая подписка",
|
||||
"group": "🏷️ Промогруппа"
|
||||
"days": "📅 Дни подписки",
|
||||
"trial": "🎁 Тестовая подписка"
|
||||
}
|
||||
|
||||
|
||||
await state.update_data(promocode_type=promo_type)
|
||||
|
||||
await callback.message.edit_text(
|
||||
@@ -529,77 +509,6 @@ async def process_promocode_code(
|
||||
f"Введите количество дней тестовой подписки:"
|
||||
)
|
||||
await state.set_state(AdminStates.setting_promocode_value)
|
||||
elif promo_type == "group":
|
||||
# Show promo group selection
|
||||
groups_with_counts = await get_promo_groups_with_counts(db, limit=50)
|
||||
|
||||
if not groups_with_counts:
|
||||
await message.answer(
|
||||
"❌ Промогруппы не найдены. Создайте хотя бы одну промогруппу.",
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_promocodes")]
|
||||
])
|
||||
)
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
keyboard = []
|
||||
text = f"🏷️ <b>Промокод:</b> <code>{code}</code>\n\nВыберите промогруппу для назначения:\n\n"
|
||||
|
||||
for promo_group, user_count in groups_with_counts:
|
||||
text += f"• {promo_group.name} (приоритет: {promo_group.priority}, пользователей: {user_count})\n"
|
||||
keyboard.append([
|
||||
types.InlineKeyboardButton(
|
||||
text=f"{promo_group.name} (↑{promo_group.priority})",
|
||||
callback_data=f"promo_select_group_{promo_group.id}"
|
||||
)
|
||||
])
|
||||
|
||||
keyboard.append([
|
||||
types.InlineKeyboardButton(text="❌ Отмена", callback_data="admin_promocodes")
|
||||
])
|
||||
|
||||
await message.answer(
|
||||
text,
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard)
|
||||
)
|
||||
await state.set_state(AdminStates.selecting_promo_group)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def process_promo_group_selection(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
state: FSMContext,
|
||||
db: AsyncSession
|
||||
):
|
||||
"""Handle promo group selection for promocode"""
|
||||
try:
|
||||
promo_group_id = int(callback.data.split('_')[-1])
|
||||
except (ValueError, IndexError):
|
||||
await callback.answer("❌ Ошибка получения ID промогруппы", show_alert=True)
|
||||
return
|
||||
|
||||
promo_group = await get_promo_group_by_id(db, promo_group_id)
|
||||
if not promo_group:
|
||||
await callback.answer("❌ Промогруппа не найдена", show_alert=True)
|
||||
return
|
||||
|
||||
await state.update_data(
|
||||
promo_group_id=promo_group_id,
|
||||
promo_group_name=promo_group.name
|
||||
)
|
||||
|
||||
await callback.message.edit_text(
|
||||
f"🏷️ <b>Промокод для промогруппы</b>\n\n"
|
||||
f"Промогруппа: {promo_group.name}\n"
|
||||
f"Приоритет: {promo_group.priority}\n\n"
|
||||
f"📊 Введите количество использований промокода (или 0 для безлимита):"
|
||||
)
|
||||
|
||||
await state.set_state(AdminStates.setting_promocode_uses)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@@ -648,8 +557,8 @@ async def handle_edit_value(
|
||||
data = await state.get_data()
|
||||
promo_id = data.get('editing_promo_id')
|
||||
edit_action = data.get('edit_action')
|
||||
|
||||
promo = await get_promocode_by_id(db, promo_id)
|
||||
|
||||
promo = await db.get(PromoCode, promo_id)
|
||||
if not promo:
|
||||
await message.answer("❌ Промокод не найден")
|
||||
await state.clear()
|
||||
@@ -734,8 +643,8 @@ async def handle_edit_uses(
|
||||
):
|
||||
data = await state.get_data()
|
||||
promo_id = data.get('editing_promo_id')
|
||||
|
||||
promo = await get_promocode_by_id(db, promo_id)
|
||||
|
||||
promo = await db.get(PromoCode, promo_id)
|
||||
if not promo:
|
||||
await message.answer("❌ Промокод не найден")
|
||||
await state.clear()
|
||||
@@ -799,20 +708,17 @@ async def process_promocode_expiry(
|
||||
promo_type = data.get('promocode_type')
|
||||
value = data.get('promocode_value', 0)
|
||||
max_uses = data.get('promocode_max_uses', 1)
|
||||
promo_group_id = data.get('promo_group_id')
|
||||
promo_group_name = data.get('promo_group_name')
|
||||
|
||||
|
||||
valid_until = None
|
||||
if expiry_days > 0:
|
||||
valid_until = datetime.utcnow() + timedelta(days=expiry_days)
|
||||
|
||||
|
||||
type_map = {
|
||||
"balance": PromoCodeType.BALANCE,
|
||||
"days": PromoCodeType.SUBSCRIPTION_DAYS,
|
||||
"trial": PromoCodeType.TRIAL_SUBSCRIPTION,
|
||||
"group": PromoCodeType.PROMO_GROUP
|
||||
"trial": PromoCodeType.TRIAL_SUBSCRIPTION
|
||||
}
|
||||
|
||||
|
||||
promocode = await create_promocode(
|
||||
db=db,
|
||||
code=code,
|
||||
@@ -821,31 +727,27 @@ async def process_promocode_expiry(
|
||||
subscription_days=value if promo_type in ["days", "trial"] else 0,
|
||||
max_uses=max_uses,
|
||||
valid_until=valid_until,
|
||||
created_by=db_user.id,
|
||||
promo_group_id=promo_group_id if promo_type == "group" else None
|
||||
created_by=db_user.id
|
||||
)
|
||||
|
||||
type_names = {
|
||||
"balance": "Пополнение баланса",
|
||||
"days": "Дни подписки",
|
||||
"trial": "Тестовая подписка",
|
||||
"group": "Промогруппа"
|
||||
"balance": "Пополнение баланса",
|
||||
"days": "Дни подписки",
|
||||
"trial": "Тестовая подписка"
|
||||
}
|
||||
|
||||
|
||||
summary_text = f"""
|
||||
✅ <b>Промокод создан!</b>
|
||||
|
||||
🎫 <b>Код:</b> <code>{promocode.code}</code>
|
||||
📝 <b>Тип:</b> {type_names.get(promo_type)}
|
||||
"""
|
||||
|
||||
|
||||
if promo_type == "balance":
|
||||
summary_text += f"💰 <b>Сумма:</b> {settings.format_price(promocode.balance_bonus_kopeks)}\n"
|
||||
elif promo_type in ["days", "trial"]:
|
||||
summary_text += f"📅 <b>Дней:</b> {promocode.subscription_days}\n"
|
||||
elif promo_type == "group" and promo_group_name:
|
||||
summary_text += f"🏷️ <b>Промогруппа:</b> {promo_group_name}\n"
|
||||
|
||||
|
||||
summary_text += f"📊 <b>Использований:</b> {promocode.max_uses}\n"
|
||||
|
||||
if promocode.valid_until:
|
||||
@@ -873,8 +775,8 @@ async def handle_edit_expiry(
|
||||
):
|
||||
data = await state.get_data()
|
||||
promo_id = data.get('editing_promo_id')
|
||||
|
||||
promo = await get_promocode_by_id(db, promo_id)
|
||||
|
||||
promo = await db.get(PromoCode, promo_id)
|
||||
if not promo:
|
||||
await message.answer("❌ Промокод не найден")
|
||||
await state.clear()
|
||||
@@ -920,8 +822,8 @@ async def toggle_promocode_status(
|
||||
db: AsyncSession
|
||||
):
|
||||
promo_id = int(callback.data.split('_')[-1])
|
||||
|
||||
promo = await get_promocode_by_id(db, promo_id)
|
||||
|
||||
promo = await db.get(PromoCode, promo_id)
|
||||
if not promo:
|
||||
await callback.answer("❌ Промокод не найден", show_alert=True)
|
||||
return
|
||||
@@ -947,8 +849,8 @@ async def confirm_delete_promocode(
|
||||
except (ValueError, IndexError):
|
||||
await callback.answer("❌ Ошибка получения ID промокода", show_alert=True)
|
||||
return
|
||||
|
||||
promo = await get_promocode_by_id(db, promo_id)
|
||||
|
||||
promo = await db.get(PromoCode, promo_id)
|
||||
if not promo:
|
||||
await callback.answer("❌ Промокод не найден", show_alert=True)
|
||||
return
|
||||
@@ -995,8 +897,8 @@ async def delete_promocode_confirmed(
|
||||
except (ValueError, IndexError):
|
||||
await callback.answer("❌ Ошибка получения ID промокода", show_alert=True)
|
||||
return
|
||||
|
||||
promo = await get_promocode_by_id(db, promo_id)
|
||||
|
||||
promo = await db.get(PromoCode, promo_id)
|
||||
if not promo:
|
||||
await callback.answer("❌ Промокод не найден", show_alert=True)
|
||||
return
|
||||
@@ -1019,8 +921,8 @@ async def show_promocode_stats(
|
||||
db: AsyncSession
|
||||
):
|
||||
promo_id = int(callback.data.split('_')[-1])
|
||||
|
||||
promo = await get_promocode_by_id(db, promo_id)
|
||||
|
||||
promo = await db.get(PromoCode, promo_id)
|
||||
if not promo:
|
||||
await callback.answer("❌ Промокод не найден", show_alert=True)
|
||||
return
|
||||
@@ -1105,7 +1007,6 @@ def register_handlers(dp: Dispatcher):
|
||||
dp.callback_query.register(show_promocodes_list, F.data == "admin_promo_list")
|
||||
dp.callback_query.register(start_promocode_creation, F.data == "admin_promo_create")
|
||||
dp.callback_query.register(select_promocode_type, F.data.startswith("promo_type_"))
|
||||
dp.callback_query.register(process_promo_group_selection, F.data.startswith("promo_select_group_"))
|
||||
|
||||
dp.callback_query.register(show_promocode_management, F.data.startswith("promo_manage_"))
|
||||
dp.callback_query.register(toggle_promocode_status, F.data.startswith("promo_toggle_"))
|
||||
|
||||
@@ -68,6 +68,7 @@ async def show_referral_statistics(
|
||||
- Минимальное пополнение: {settings.format_price(settings.REFERRAL_MINIMUM_TOPUP_KOPEKS)}
|
||||
- Бонус за первое пополнение: {settings.format_price(settings.REFERRAL_FIRST_TOPUP_BONUS_KOPEKS)}
|
||||
- Бонус пригласившему: {settings.format_price(settings.REFERRAL_INVITER_BONUS_KOPEKS)}
|
||||
- Бонус новому пользователю: {settings.format_price(settings.get_referred_user_reward_kopeks())}
|
||||
- Комиссия с покупок: {settings.REFERRAL_COMMISSION_PERCENT}%
|
||||
- Уведомления: {'✅ Включены' if settings.REFERRAL_NOTIFICATIONS_ENABLED else '❌ Отключены'}
|
||||
|
||||
@@ -104,6 +105,7 @@ async def show_referral_statistics(
|
||||
- Минимальное пополнение: {settings.format_price(settings.REFERRAL_MINIMUM_TOPUP_KOPEKS)}
|
||||
- Бонус за первое пополнение: {settings.format_price(settings.REFERRAL_FIRST_TOPUP_BONUS_KOPEKS)}
|
||||
- Бонус пригласившему: {settings.format_price(settings.REFERRAL_INVITER_BONUS_KOPEKS)}
|
||||
- Бонус новому пользователю: {settings.format_price(settings.get_referred_user_reward_kopeks())}
|
||||
- Комиссия с покупок: {settings.REFERRAL_COMMISSION_PERCENT}%
|
||||
|
||||
<i>🕐 Время: {current_time}</i>
|
||||
@@ -188,6 +190,7 @@ async def show_referral_settings(
|
||||
• Минимальная сумма пополнения для участия: {settings.format_price(settings.REFERRAL_MINIMUM_TOPUP_KOPEKS)}
|
||||
• Бонус за первое пополнение реферала: {settings.format_price(settings.REFERRAL_FIRST_TOPUP_BONUS_KOPEKS)}
|
||||
• Бонус пригласившему за первое пополнение: {settings.format_price(settings.REFERRAL_INVITER_BONUS_KOPEKS)}
|
||||
• Бонус новому пользователю при регистрации: {settings.format_price(settings.get_referred_user_reward_kopeks())}
|
||||
|
||||
<b>Комиссионные:</b>
|
||||
• Процент с каждой покупки реферала: {settings.REFERRAL_COMMISSION_PERCENT}%
|
||||
|
||||
@@ -421,8 +421,7 @@ async def show_server_users(
|
||||
lines = []
|
||||
for index, user in enumerate(page_users, start=start_index + 1):
|
||||
safe_user_name = html.escape(user.full_name)
|
||||
user_link = f'<a href="tg://user?id={user.telegram_id}">{safe_user_name}</a>'
|
||||
lines.append(f"{index}. {user_link}")
|
||||
lines.append(f"{index}. {safe_user_name}")
|
||||
|
||||
text += "\n" + "\n".join(lines)
|
||||
else:
|
||||
|
||||
@@ -7,7 +7,6 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, desc, and_
|
||||
from datetime import datetime, timedelta
|
||||
import time
|
||||
import html
|
||||
|
||||
from app.database.models import User, Ticket, TicketStatus
|
||||
from app.database.crud.ticket import TicketCRUD, TicketMessageCRUD
|
||||
@@ -175,24 +174,12 @@ async def view_admin_ticket(
|
||||
|
||||
user_name = ticket.user.full_name if ticket.user else "Unknown"
|
||||
telegram_id_display = ticket.user.telegram_id if ticket.user else "—"
|
||||
username_value = ticket.user.username if ticket.user else None
|
||||
username_display = (ticket.user.username or "отсутствует") if ticket.user else "отсутствует"
|
||||
|
||||
ticket_text = f"🎫 Тикет #{ticket.id}\n\n"
|
||||
ticket_text += f"👤 Пользователь: {user_name}\n"
|
||||
ticket_text += f"🆔 Telegram ID: <code>{telegram_id_display}</code>\n"
|
||||
if username_value:
|
||||
safe_username = html.escape(username_value)
|
||||
ticket_text += f"📱 Username: @{safe_username}\n"
|
||||
ticket_text += (
|
||||
f"🔗 ЛС: <a href=\"tg://resolve?domain={safe_username}\">"
|
||||
f"tg://resolve?domain={safe_username}</a>\n"
|
||||
)
|
||||
else:
|
||||
ticket_text += "📱 Username: отсутствует\n"
|
||||
if ticket.user and ticket.user.telegram_id:
|
||||
chat_link = f"tg://user?id={int(ticket.user.telegram_id)}"
|
||||
ticket_text += f"🔗 Чат по ID: <a href=\"{chat_link}\">{chat_link}</a>\n"
|
||||
ticket_text += "\n"
|
||||
ticket_text += f"📱 Username: @{username_display}\n"
|
||||
ticket_text += f"📝 Заголовок: {ticket.title}\n"
|
||||
ticket_text += f"📊 Статус: {ticket.status_emoji} {status_text}\n"
|
||||
ticket_text += f"📅 Создан: {ticket.created_at.strftime('%d.%m.%Y %H:%M')}\n"
|
||||
@@ -234,11 +221,15 @@ async def view_admin_ticket(
|
||||
pass
|
||||
# Кнопки ЛС и профиль
|
||||
try:
|
||||
if ticket.user and ticket.user.telegram_id and ticket.user.username:
|
||||
safe_username = html.escape(ticket.user.username)
|
||||
if ticket.user and ticket.user.telegram_id:
|
||||
buttons_row = []
|
||||
pm_url = f"tg://resolve?domain={safe_username}"
|
||||
# DM: при наличии username используем tg://resolve, иначе fallback по ID
|
||||
if ticket.user.username:
|
||||
pm_url = f"tg://resolve?domain={ticket.user.username}"
|
||||
else:
|
||||
pm_url = f"tg://user?id={ticket.user.telegram_id}"
|
||||
buttons_row.append(types.InlineKeyboardButton(text="✉ Написать в ЛС", url=pm_url))
|
||||
# Профиль: по ID
|
||||
profile_url = f"tg://user?id={ticket.user.telegram_id}"
|
||||
buttons_row.append(types.InlineKeyboardButton(text="👤 Профиль", url=profile_url))
|
||||
if buttons_row:
|
||||
@@ -759,21 +750,7 @@ async def handle_admin_block_duration_input(
|
||||
ticket_text += f"📝 Заголовок: {updated.title}\n"
|
||||
ticket_text += f"📊 Статус: {updated.status_emoji} {status_text}\n"
|
||||
ticket_text += f"📅 Создан: {updated.created_at.strftime('%d.%m.%Y %H:%M')}\n"
|
||||
ticket_text += f"🔄 Обновлен: {updated.updated_at.strftime('%d.%m.%Y %H:%M')}\n"
|
||||
if updated.user and updated.user.telegram_id:
|
||||
ticket_text += f"🆔 Telegram ID: <code>{updated.user.telegram_id}</code>\n"
|
||||
if updated.user.username:
|
||||
safe_username = html.escape(updated.user.username)
|
||||
ticket_text += f"📱 Username: @{safe_username}\n"
|
||||
ticket_text += (
|
||||
f"🔗 ЛС: <a href=\"tg://resolve?domain={safe_username}\">"
|
||||
f"tg://resolve?domain={safe_username}</a>\n"
|
||||
)
|
||||
else:
|
||||
ticket_text += "📱 Username: отсутствует\n"
|
||||
chat_link = f"tg://user?id={int(updated.user.telegram_id)}"
|
||||
ticket_text += f"🔗 Чат по ID: <a href=\"{chat_link}\">{chat_link}</a>\n"
|
||||
ticket_text += "\n"
|
||||
ticket_text += f"🔄 Обновлен: {updated.updated_at.strftime('%d.%m.%Y %H:%M')}\n\n"
|
||||
if updated.is_user_reply_blocked:
|
||||
if updated.user_reply_block_permanent:
|
||||
ticket_text += "🚫 Пользователь заблокирован навсегда для ответов в этом тикете\n"
|
||||
@@ -801,10 +778,12 @@ async def handle_admin_block_duration_input(
|
||||
pass
|
||||
# Кнопки ЛС и профиль при обновлении карточки
|
||||
try:
|
||||
if updated.user and updated.user.telegram_id and updated.user.username:
|
||||
safe_username = html.escape(updated.user.username)
|
||||
if updated.user and updated.user.telegram_id:
|
||||
buttons_row = []
|
||||
pm_url = f"tg://resolve?domain={safe_username}"
|
||||
if updated.user.username:
|
||||
pm_url = f"tg://resolve?domain={updated.user.username}"
|
||||
else:
|
||||
pm_url = f"tg://user?id={updated.user.telegram_id}"
|
||||
buttons_row.append(types.InlineKeyboardButton(text="✉ Написать в ЛС", url=pm_url))
|
||||
profile_url = f"tg://user?id={updated.user.telegram_id}"
|
||||
buttons_row.append(types.InlineKeyboardButton(text="👤 Профиль", url=profile_url))
|
||||
|
||||
+87
-596
@@ -1,7 +1,6 @@
|
||||
import logging
|
||||
import re
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional, List, Tuple
|
||||
from typing import Optional
|
||||
from aiogram import Dispatcher, types, F
|
||||
from aiogram.exceptions import TelegramForbiddenError, TelegramBadRequest
|
||||
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
|
||||
@@ -11,12 +10,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from app.config import settings
|
||||
from app.states import AdminStates
|
||||
from app.database.models import User, UserStatus, Subscription, SubscriptionStatus, TransactionType
|
||||
from app.database.crud.user import (
|
||||
get_user_by_id,
|
||||
get_user_by_telegram_id,
|
||||
get_user_by_username,
|
||||
get_referrals,
|
||||
)
|
||||
from app.database.crud.user import get_user_by_id
|
||||
from app.database.crud.campaign import (
|
||||
get_campaign_registration_by_user,
|
||||
get_campaign_statistics,
|
||||
@@ -41,9 +35,6 @@ from app.database.crud.server_squad import (
|
||||
get_server_ids_by_uuids,
|
||||
)
|
||||
from app.services.subscription_service import SubscriptionService
|
||||
from app.utils.subscription_utils import (
|
||||
resolve_hwid_device_limit_for_payload,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -1008,8 +999,7 @@ async def _render_user_subscription_overview(
|
||||
subscription = profile["subscription"]
|
||||
|
||||
text = "📱 <b>Подписка и настройки пользователя</b>\n\n"
|
||||
user_link = f'<a href="tg://user?id={user.telegram_id}">{user.full_name}</a>'
|
||||
text += f"👤 {user_link} (ID: <code>{user.telegram_id}</code>)\n\n"
|
||||
text += f"👤 {user.full_name} (ID: <code>{user.telegram_id}</code>)\n\n"
|
||||
|
||||
keyboard = []
|
||||
|
||||
@@ -1169,8 +1159,7 @@ async def show_user_transactions(
|
||||
transactions = await get_user_transactions(db, user_id, limit=10)
|
||||
|
||||
text = f"💳 <b>Транзакции пользователя</b>\n\n"
|
||||
user_link = f'<a href="tg://user?id={user.telegram_id}">{user.full_name}</a>'
|
||||
text += f"👤 {user_link} (ID: <code>{user.telegram_id}</code>)\n"
|
||||
text += f"👤 {user.full_name} (ID: <code>{user.telegram_id}</code>)\n"
|
||||
text += f"💰 Текущий баланс: {settings.format_price(user.balance_kopeks)}\n\n"
|
||||
|
||||
if transactions:
|
||||
@@ -1447,41 +1436,16 @@ async def show_user_management(
|
||||
else:
|
||||
sections.append(texts.ADMIN_USER_MANAGEMENT_SUBSCRIPTION_NONE)
|
||||
|
||||
# Display promo groups
|
||||
primary_group = user.get_primary_promo_group()
|
||||
if primary_group:
|
||||
sections.append(
|
||||
texts.t(
|
||||
"ADMIN_USER_PROMO_GROUPS_PRIMARY",
|
||||
"⭐ Основная: {name} (Priority: {priority})",
|
||||
).format(name=primary_group.name, priority=getattr(primary_group, "priority", 0))
|
||||
)
|
||||
if user.promo_group:
|
||||
promo_group = user.promo_group
|
||||
sections.append(
|
||||
texts.ADMIN_USER_MANAGEMENT_PROMO_GROUP.format(
|
||||
name=primary_group.name,
|
||||
server_discount=primary_group.server_discount_percent,
|
||||
traffic_discount=primary_group.traffic_discount_percent,
|
||||
device_discount=primary_group.device_discount_percent,
|
||||
name=promo_group.name,
|
||||
server_discount=promo_group.server_discount_percent,
|
||||
traffic_discount=promo_group.traffic_discount_percent,
|
||||
device_discount=promo_group.device_discount_percent,
|
||||
)
|
||||
)
|
||||
|
||||
# Show additional groups if any
|
||||
if user.user_promo_groups and len(user.user_promo_groups) > 1:
|
||||
additional_groups = [
|
||||
upg.promo_group for upg in user.user_promo_groups
|
||||
if upg.promo_group and upg.promo_group.id != primary_group.id
|
||||
]
|
||||
if additional_groups:
|
||||
sections.append(
|
||||
texts.t(
|
||||
"ADMIN_USER_PROMO_GROUPS_ADDITIONAL",
|
||||
"Дополнительные группы:",
|
||||
)
|
||||
)
|
||||
for group in additional_groups:
|
||||
sections.append(
|
||||
f" • {group.name} (Priority: {getattr(group, 'priority', 0)})"
|
||||
)
|
||||
else:
|
||||
sections.append(texts.ADMIN_USER_MANAGEMENT_PROMO_GROUP_NONE)
|
||||
|
||||
@@ -1522,396 +1486,6 @@ async def show_user_management(
|
||||
await callback.answer()
|
||||
|
||||
|
||||
async def _build_user_referrals_view(
|
||||
db: AsyncSession,
|
||||
language: str,
|
||||
user_id: int,
|
||||
limit: int = 30,
|
||||
) -> Optional[Tuple[str, InlineKeyboardMarkup]]:
|
||||
texts = get_texts(language)
|
||||
|
||||
user = await get_user_by_id(db, user_id)
|
||||
if not user:
|
||||
return None
|
||||
|
||||
referrals = await get_referrals(db, user_id)
|
||||
|
||||
header = texts.t(
|
||||
"ADMIN_USER_REFERRALS_TITLE",
|
||||
"🤝 <b>Рефералы пользователя</b>",
|
||||
)
|
||||
summary = texts.t(
|
||||
"ADMIN_USER_REFERRALS_SUMMARY",
|
||||
"👤 {name} (ID: <code>{telegram_id}</code>)\n👥 Всего рефералов: {count}",
|
||||
).format(
|
||||
name=user.full_name,
|
||||
telegram_id=user.telegram_id,
|
||||
count=len(referrals),
|
||||
)
|
||||
|
||||
lines: List[str] = [header, summary]
|
||||
|
||||
if referrals:
|
||||
lines.append(
|
||||
texts.t(
|
||||
"ADMIN_USER_REFERRALS_LIST_HEADER",
|
||||
"<b>Список рефералов:</b>",
|
||||
)
|
||||
)
|
||||
items = []
|
||||
for referral in referrals[:limit]:
|
||||
username_part = (
|
||||
f", @{referral.username}"
|
||||
if referral.username
|
||||
else ""
|
||||
)
|
||||
referral_link = f'<a href="tg://user?id={referral.telegram_id}">{referral.full_name}</a>'
|
||||
items.append(
|
||||
texts.t(
|
||||
"ADMIN_USER_REFERRALS_LIST_ITEM",
|
||||
"• {name} (ID: <code>{telegram_id}</code>{username_part})",
|
||||
).format(
|
||||
name=referral_link,
|
||||
telegram_id=referral.telegram_id,
|
||||
username_part=username_part,
|
||||
)
|
||||
)
|
||||
|
||||
lines.append("\n".join(items))
|
||||
|
||||
if len(referrals) > limit:
|
||||
remaining = len(referrals) - limit
|
||||
lines.append(
|
||||
texts.t(
|
||||
"ADMIN_USER_REFERRALS_LIST_TRUNCATED",
|
||||
"• … и ещё {count} рефералов",
|
||||
).format(count=remaining)
|
||||
)
|
||||
else:
|
||||
lines.append(
|
||||
texts.t(
|
||||
"ADMIN_USER_REFERRALS_EMPTY",
|
||||
"Рефералов пока нет.",
|
||||
)
|
||||
)
|
||||
|
||||
lines.append(
|
||||
texts.t(
|
||||
"ADMIN_USER_REFERRALS_EDIT_HINT",
|
||||
"✏️ Чтобы изменить список, нажмите «✏️ Редактировать» ниже.",
|
||||
)
|
||||
)
|
||||
|
||||
keyboard = InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t(
|
||||
"ADMIN_USER_REFERRALS_EDIT_BUTTON",
|
||||
"✏️ Редактировать",
|
||||
),
|
||||
callback_data=f"admin_user_referrals_edit_{user_id}",
|
||||
)
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.BACK,
|
||||
callback_data=f"admin_user_manage_{user_id}",
|
||||
)
|
||||
],
|
||||
]
|
||||
)
|
||||
|
||||
return "\n\n".join(lines), keyboard
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_user_referrals(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
state: FSMContext,
|
||||
):
|
||||
user_id = int(callback.data.split('_')[-1])
|
||||
|
||||
current_state = await state.get_state()
|
||||
if current_state == AdminStates.editing_user_referrals:
|
||||
data = await state.get_data()
|
||||
preserved_data = {
|
||||
key: value
|
||||
for key, value in data.items()
|
||||
if key not in {"editing_referrals_user_id", "referrals_message_id"}
|
||||
}
|
||||
await state.clear()
|
||||
if preserved_data:
|
||||
await state.update_data(**preserved_data)
|
||||
|
||||
view = await _build_user_referrals_view(db, db_user.language, user_id)
|
||||
if not view:
|
||||
await callback.answer("❌ Пользователь не найден", show_alert=True)
|
||||
return
|
||||
|
||||
text, keyboard = view
|
||||
|
||||
await callback.message.edit_text(
|
||||
text,
|
||||
reply_markup=keyboard,
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def start_edit_user_referrals(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
state: FSMContext,
|
||||
db: AsyncSession,
|
||||
):
|
||||
user_id = int(callback.data.split('_')[-1])
|
||||
|
||||
user = await get_user_by_id(db, user_id)
|
||||
if not user:
|
||||
await callback.answer("❌ Пользователь не найден", show_alert=True)
|
||||
return
|
||||
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
prompt = texts.t(
|
||||
"ADMIN_USER_REFERRALS_EDIT_PROMPT",
|
||||
(
|
||||
"✏️ <b>Редактирование рефералов</b>\n\n"
|
||||
"Отправьте список рефералов для пользователя <b>{name}</b> (ID: <code>{telegram_id}</code>):\n"
|
||||
"• Используйте TG ID или @username\n"
|
||||
"• Значения можно указывать через запятую, пробел или с новой строки\n"
|
||||
"• Чтобы очистить список, отправьте 0 или слово 'нет'\n\n"
|
||||
"Или нажмите кнопку ниже, чтобы отменить."
|
||||
),
|
||||
).format(
|
||||
name=user.full_name,
|
||||
telegram_id=user.telegram_id,
|
||||
)
|
||||
|
||||
await state.update_data(
|
||||
editing_referrals_user_id=user_id,
|
||||
referrals_message_id=callback.message.message_id,
|
||||
)
|
||||
|
||||
await callback.message.edit_text(
|
||||
prompt,
|
||||
reply_markup=InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.BACK,
|
||||
callback_data=f"admin_user_referrals_{user_id}",
|
||||
)
|
||||
]
|
||||
]
|
||||
),
|
||||
)
|
||||
|
||||
await state.set_state(AdminStates.editing_user_referrals)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def process_edit_user_referrals(
|
||||
message: types.Message,
|
||||
db_user: User,
|
||||
state: FSMContext,
|
||||
db: AsyncSession,
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
data = await state.get_data()
|
||||
|
||||
user_id = data.get("editing_referrals_user_id")
|
||||
if not user_id:
|
||||
await message.answer(
|
||||
texts.t(
|
||||
"ADMIN_USER_REFERRALS_STATE_LOST",
|
||||
"❌ Не удалось определить пользователя. Попробуйте начать сначала.",
|
||||
)
|
||||
)
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
raw_text = message.text.strip()
|
||||
lower_text = raw_text.lower()
|
||||
clear_keywords = {"0", "нет", "none", "пусто", "clear"}
|
||||
clear_requested = lower_text in clear_keywords
|
||||
|
||||
tokens: List[str] = []
|
||||
if not clear_requested:
|
||||
parts = re.split(r"[,\n]+", raw_text)
|
||||
for part in parts:
|
||||
for token in part.split():
|
||||
cleaned = token.strip()
|
||||
if cleaned and cleaned not in tokens:
|
||||
tokens.append(cleaned)
|
||||
|
||||
found_users: List[User] = []
|
||||
not_found: List[str] = []
|
||||
skipped_self: List[str] = []
|
||||
duplicate_tokens: List[str] = []
|
||||
|
||||
seen_ids = set()
|
||||
|
||||
for token in tokens:
|
||||
normalized = token.strip()
|
||||
if not normalized:
|
||||
continue
|
||||
|
||||
if normalized.startswith("@"):
|
||||
normalized = normalized[1:]
|
||||
|
||||
user = None
|
||||
if normalized.isdigit():
|
||||
try:
|
||||
user = await get_user_by_telegram_id(db, int(normalized))
|
||||
except ValueError:
|
||||
user = None
|
||||
else:
|
||||
user = await get_user_by_username(db, normalized)
|
||||
|
||||
if not user:
|
||||
not_found.append(token)
|
||||
continue
|
||||
|
||||
if user.id == user_id:
|
||||
skipped_self.append(token)
|
||||
continue
|
||||
|
||||
if user.id in seen_ids:
|
||||
duplicate_tokens.append(token)
|
||||
continue
|
||||
|
||||
seen_ids.add(user.id)
|
||||
found_users.append(user)
|
||||
|
||||
if not found_users and not clear_requested:
|
||||
error_lines = [
|
||||
texts.t(
|
||||
"ADMIN_USER_REFERRALS_NO_VALID",
|
||||
"❌ Не удалось найти ни одного пользователя по введённым данным.",
|
||||
)
|
||||
]
|
||||
if not_found:
|
||||
error_lines.append(
|
||||
texts.t(
|
||||
"ADMIN_USER_REFERRALS_INVALID_ENTRIES",
|
||||
"Не найдены: {values}",
|
||||
).format(values=", ".join(not_found))
|
||||
)
|
||||
if skipped_self:
|
||||
error_lines.append(
|
||||
texts.t(
|
||||
"ADMIN_USER_REFERRALS_SELF_SKIPPED",
|
||||
"Пропущены значения пользователя: {values}",
|
||||
).format(values=", ".join(skipped_self))
|
||||
)
|
||||
await message.answer("\n".join(error_lines))
|
||||
return
|
||||
|
||||
user_service = UserService()
|
||||
|
||||
new_referral_ids = [user.id for user in found_users] if not clear_requested else []
|
||||
|
||||
success, details = await user_service.update_user_referrals(
|
||||
db,
|
||||
user_id,
|
||||
new_referral_ids,
|
||||
db_user.id,
|
||||
)
|
||||
|
||||
if not success:
|
||||
await message.answer(
|
||||
texts.t(
|
||||
"ADMIN_USER_REFERRALS_UPDATE_ERROR",
|
||||
"❌ Не удалось обновить рефералов. Попробуйте позже.",
|
||||
)
|
||||
)
|
||||
return
|
||||
|
||||
response_lines = [
|
||||
texts.t(
|
||||
"ADMIN_USER_REFERRALS_UPDATED",
|
||||
"✅ Список рефералов обновлён.",
|
||||
)
|
||||
]
|
||||
|
||||
total_referrals = details.get("total", len(new_referral_ids))
|
||||
added = details.get("added", 0)
|
||||
removed = details.get("removed", 0)
|
||||
|
||||
response_lines.append(
|
||||
texts.t(
|
||||
"ADMIN_USER_REFERRALS_UPDATED_TOTAL",
|
||||
"• Текущий список: {total}",
|
||||
).format(total=total_referrals)
|
||||
)
|
||||
|
||||
if added > 0:
|
||||
response_lines.append(
|
||||
texts.t(
|
||||
"ADMIN_USER_REFERRALS_UPDATED_ADDED",
|
||||
"• Добавлено: {count}",
|
||||
).format(count=added)
|
||||
)
|
||||
|
||||
if removed > 0:
|
||||
response_lines.append(
|
||||
texts.t(
|
||||
"ADMIN_USER_REFERRALS_UPDATED_REMOVED",
|
||||
"• Удалено: {count}",
|
||||
).format(count=removed)
|
||||
)
|
||||
|
||||
if not_found:
|
||||
response_lines.append(
|
||||
texts.t(
|
||||
"ADMIN_USER_REFERRALS_INVALID_ENTRIES",
|
||||
"Не найдены: {values}",
|
||||
).format(values=", ".join(not_found))
|
||||
)
|
||||
|
||||
if skipped_self:
|
||||
response_lines.append(
|
||||
texts.t(
|
||||
"ADMIN_USER_REFERRALS_SELF_SKIPPED",
|
||||
"Пропущены значения пользователя: {values}",
|
||||
).format(values=", ".join(skipped_self))
|
||||
)
|
||||
|
||||
if duplicate_tokens:
|
||||
response_lines.append(
|
||||
texts.t(
|
||||
"ADMIN_USER_REFERRALS_DUPLICATES",
|
||||
"Игнорированы дубли: {values}",
|
||||
).format(values=", ".join(duplicate_tokens))
|
||||
)
|
||||
|
||||
view = await _build_user_referrals_view(db, db_user.language, user_id)
|
||||
message_id = data.get("referrals_message_id")
|
||||
|
||||
if view and message_id:
|
||||
try:
|
||||
await message.bot.edit_message_text(
|
||||
view[0],
|
||||
chat_id=message.chat.id,
|
||||
message_id=message_id,
|
||||
reply_markup=view[1],
|
||||
)
|
||||
except TelegramBadRequest:
|
||||
await message.answer(view[0], reply_markup=view[1])
|
||||
elif view:
|
||||
await message.answer(view[0], reply_markup=view[1])
|
||||
|
||||
await message.answer("\n".join(response_lines))
|
||||
await state.clear()
|
||||
|
||||
async def _render_user_promo_group(
|
||||
message: types.Message,
|
||||
language: str,
|
||||
@@ -1920,43 +1494,20 @@ async def _render_user_promo_group(
|
||||
) -> None:
|
||||
texts = get_texts(language)
|
||||
|
||||
# Get primary and all user groups
|
||||
primary_group = user.get_primary_promo_group()
|
||||
user_group_ids = [upg.promo_group_id for upg in user.user_promo_groups] if user.user_promo_groups else []
|
||||
|
||||
# Build current groups section
|
||||
if primary_group:
|
||||
current_line = texts.t(
|
||||
"ADMIN_USER_PROMO_GROUPS_PRIMARY",
|
||||
"⭐ Основная: {name} (Priority: {priority})",
|
||||
).format(name=primary_group.name, priority=getattr(primary_group, "priority", 0))
|
||||
current_group = user.promo_group
|
||||
|
||||
if current_group:
|
||||
current_line = texts.ADMIN_USER_PROMO_GROUP_CURRENT.format(name=current_group.name)
|
||||
discount_line = texts.ADMIN_USER_PROMO_GROUP_DISCOUNTS.format(
|
||||
servers=primary_group.server_discount_percent,
|
||||
traffic=primary_group.traffic_discount_percent,
|
||||
devices=primary_group.device_discount_percent,
|
||||
servers=current_group.server_discount_percent,
|
||||
traffic=current_group.traffic_discount_percent,
|
||||
devices=current_group.device_discount_percent,
|
||||
)
|
||||
|
||||
# Show additional groups if any
|
||||
if len(user_group_ids) > 1:
|
||||
additional_groups = [
|
||||
upg.promo_group for upg in user.user_promo_groups
|
||||
if upg.promo_group and upg.promo_group.id != primary_group.id
|
||||
]
|
||||
if additional_groups:
|
||||
additional_line = "\n" + texts.t(
|
||||
"ADMIN_USER_PROMO_GROUPS_ADDITIONAL",
|
||||
"Дополнительные группы:",
|
||||
) + "\n"
|
||||
for group in additional_groups:
|
||||
additional_line += f" • {group.name} (Priority: {getattr(group, 'priority', 0)})\n"
|
||||
discount_line += additional_line
|
||||
current_group_id = current_group.id
|
||||
else:
|
||||
current_line = texts.t(
|
||||
"ADMIN_USER_PROMO_GROUPS_NONE",
|
||||
"У пользователя нет промогрупп",
|
||||
)
|
||||
discount_line = ""
|
||||
current_line = texts.ADMIN_USER_PROMO_GROUP_CURRENT_NONE
|
||||
discount_line = texts.ADMIN_USER_PROMO_GROUP_DISCOUNTS_NONE
|
||||
current_group_id = None
|
||||
|
||||
text = (
|
||||
f"{texts.ADMIN_USER_PROMO_GROUP_TITLE}\n\n"
|
||||
@@ -1970,7 +1521,7 @@ async def _render_user_promo_group(
|
||||
reply_markup=get_user_promo_group_keyboard(
|
||||
promo_groups,
|
||||
user.id,
|
||||
user_group_ids, # Pass list of all group IDs
|
||||
current_group_id,
|
||||
language
|
||||
)
|
||||
)
|
||||
@@ -2008,13 +1559,6 @@ async def set_user_promo_group(
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
from app.database.crud.user_promo_group import (
|
||||
has_user_promo_group,
|
||||
add_user_to_promo_group,
|
||||
remove_user_from_promo_group,
|
||||
count_user_promo_groups
|
||||
)
|
||||
from app.database.crud.promo_group import get_promo_group_by_id
|
||||
|
||||
parts = callback.data.split('_')
|
||||
user_id = int(parts[-2])
|
||||
@@ -2027,52 +1571,49 @@ async def set_user_promo_group(
|
||||
await callback.answer("❌ Пользователь не найден", show_alert=True)
|
||||
return
|
||||
|
||||
# Check if user already has this group
|
||||
has_group = await has_user_promo_group(db, user_id, group_id)
|
||||
if user.promo_group_id == group_id:
|
||||
await callback.answer(texts.ADMIN_USER_PROMO_GROUP_ALREADY, show_alert=True)
|
||||
return
|
||||
|
||||
if has_group:
|
||||
# Remove group
|
||||
# Check if it's the last group
|
||||
groups_count = await count_user_promo_groups(db, user_id)
|
||||
if groups_count <= 1:
|
||||
await callback.answer(
|
||||
texts.t(
|
||||
"ADMIN_USER_PROMO_GROUP_CANNOT_REMOVE_LAST",
|
||||
"❌ Нельзя удалить последнюю промогруппу",
|
||||
),
|
||||
show_alert=True
|
||||
)
|
||||
return
|
||||
user_service = UserService()
|
||||
success, updated_user, new_group, old_group = await user_service.update_user_promo_group(
|
||||
db,
|
||||
user_id,
|
||||
group_id
|
||||
)
|
||||
|
||||
group = await get_promo_group_by_id(db, group_id)
|
||||
await remove_user_from_promo_group(db, user_id, group_id)
|
||||
await callback.answer(
|
||||
texts.t(
|
||||
"ADMIN_USER_PROMO_GROUP_REMOVED",
|
||||
"🗑 Группа «{name}» удалена",
|
||||
).format(name=group.name if group else ""),
|
||||
show_alert=True
|
||||
)
|
||||
else:
|
||||
# Add group
|
||||
group = await get_promo_group_by_id(db, group_id)
|
||||
if not group:
|
||||
await callback.answer(texts.ADMIN_USER_PROMO_GROUP_ERROR, show_alert=True)
|
||||
return
|
||||
if not success or not updated_user or not new_group:
|
||||
await callback.answer(texts.ADMIN_USER_PROMO_GROUP_ERROR, show_alert=True)
|
||||
return
|
||||
|
||||
await add_user_to_promo_group(db, user_id, group_id, assigned_by="admin")
|
||||
await callback.answer(
|
||||
texts.t(
|
||||
"ADMIN_USER_PROMO_GROUP_ADDED",
|
||||
"✅ Группа «{name}» добавлена",
|
||||
).format(name=group.name),
|
||||
show_alert=True
|
||||
)
|
||||
|
||||
# Refresh user data and show updated list
|
||||
user = await get_user_by_id(db, user_id)
|
||||
promo_groups = await get_promo_groups_with_counts(db)
|
||||
await _render_user_promo_group(callback.message, db_user.language, user, promo_groups)
|
||||
|
||||
await _render_user_promo_group(callback.message, db_user.language, updated_user, promo_groups)
|
||||
await callback.answer(
|
||||
texts.ADMIN_USER_PROMO_GROUP_UPDATED.format(name=new_group.name),
|
||||
show_alert=True
|
||||
)
|
||||
|
||||
try:
|
||||
notification_service = AdminNotificationService(callback.bot)
|
||||
reason = (
|
||||
f"Назначено администратором {db_user.full_name} (ID: {db_user.telegram_id})"
|
||||
)
|
||||
await notification_service.send_user_promo_group_change_notification(
|
||||
db,
|
||||
updated_user,
|
||||
old_group,
|
||||
new_group,
|
||||
reason=reason,
|
||||
initiator=db_user,
|
||||
automatic=False,
|
||||
)
|
||||
except Exception as notify_error:
|
||||
logger.error(
|
||||
"Ошибка отправки уведомления о смене промогруппы пользователя %s: %s",
|
||||
updated_user.telegram_id,
|
||||
notify_error,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -2341,10 +1882,9 @@ async def show_inactive_users(
|
||||
|
||||
text = f"🗑️ <b>Неактивные пользователи</b>\n"
|
||||
text += f"Без активности более {settings.INACTIVE_USER_DELETE_MONTHS} месяцев: {len(inactive_users)}\n\n"
|
||||
|
||||
for user in inactive_users[:10]:
|
||||
user_link = f'<a href="tg://user?id={user.telegram_id}">{user.full_name}</a>'
|
||||
text += f"👤 {user_link}\n"
|
||||
|
||||
for user in inactive_users[:10]:
|
||||
text += f"👤 {user.full_name}\n"
|
||||
text += f"🆔 <code>{user.telegram_id}</code>\n"
|
||||
last_activity_display = (
|
||||
format_time_ago(user.last_activity, db_user.language)
|
||||
@@ -2446,8 +1986,7 @@ async def show_user_statistics(
|
||||
campaign_stats = await get_campaign_statistics(db, campaign_registration.campaign_id)
|
||||
|
||||
text = f"📊 <b>Статистика пользователя</b>\n\n"
|
||||
user_link = f'<a href="tg://user?id={user.telegram_id}">{user.full_name}</a>'
|
||||
text += f"👤 {user_link} (ID: <code>{user.telegram_id}</code>)\n\n"
|
||||
text += f"👤 {user.full_name} (ID: <code>{user.telegram_id}</code>)\n\n"
|
||||
|
||||
text += f"<b>Основная информация:</b>\n"
|
||||
text += f"• Дней с регистрации: {profile['registration_days']}\n"
|
||||
@@ -3799,15 +3338,7 @@ async def _grant_trial_subscription(db: AsyncSession, user_id: int, admin_id: in
|
||||
logger.error(f"У пользователя {user_id} уже есть подписка")
|
||||
return False
|
||||
|
||||
forced_devices = None
|
||||
if not settings.is_devices_selection_enabled():
|
||||
forced_devices = settings.get_disabled_mode_device_limit()
|
||||
|
||||
subscription = await create_trial_subscription(
|
||||
db,
|
||||
user_id,
|
||||
device_limit=forced_devices,
|
||||
)
|
||||
subscription = await create_trial_subscription(db, user_id)
|
||||
|
||||
subscription_service = SubscriptionService()
|
||||
await subscription_service.create_remnawave_user(db, subscription)
|
||||
@@ -3836,7 +3367,10 @@ async def _grant_paid_subscription(db: AsyncSession, user_id: int, days: int, ad
|
||||
try:
|
||||
from app.database.crud.server_squad import get_random_trial_squad_uuid
|
||||
|
||||
trial_uuid = await get_random_trial_squad_uuid(db)
|
||||
trial_uuid = await get_random_trial_squad_uuid(
|
||||
db,
|
||||
getattr(settings, "TRIAL_SQUAD_UUID", None),
|
||||
)
|
||||
if trial_uuid:
|
||||
trial_squads = [trial_uuid]
|
||||
except Exception as error:
|
||||
@@ -3845,21 +3379,15 @@ async def _grant_paid_subscription(db: AsyncSession, user_id: int, days: int, ad
|
||||
admin_id,
|
||||
error,
|
||||
)
|
||||
|
||||
forced_devices = None
|
||||
if not settings.is_devices_selection_enabled():
|
||||
forced_devices = settings.get_disabled_mode_device_limit()
|
||||
|
||||
device_limit = settings.DEFAULT_DEVICE_LIMIT
|
||||
if forced_devices is not None:
|
||||
device_limit = forced_devices
|
||||
if getattr(settings, "TRIAL_SQUAD_UUID", None):
|
||||
trial_squads = [settings.TRIAL_SQUAD_UUID]
|
||||
|
||||
subscription = await create_paid_subscription(
|
||||
db=db,
|
||||
user_id=user_id,
|
||||
duration_days=days,
|
||||
traffic_limit_gb=settings.DEFAULT_TRAFFIC_LIMIT_GB,
|
||||
device_limit=device_limit,
|
||||
device_limit=settings.DEFAULT_DEVICE_LIMIT,
|
||||
connected_squads=trial_squads,
|
||||
update_server_counters=True,
|
||||
)
|
||||
@@ -4063,13 +3591,10 @@ async def admin_buy_subscription(
|
||||
])
|
||||
|
||||
text = f"💳 <b>Покупка подписки для пользователя</b>\n\n"
|
||||
target_user_link = f'<a href="tg://user?id={target_user.telegram_id}">{target_user.full_name}</a>'
|
||||
text += f"👤 {target_user_link} (ID: {target_user.telegram_id})\n"
|
||||
text += f"👤 {target_user.full_name} (ID: {target_user.telegram_id})\n"
|
||||
text += f"💰 Баланс пользователя: {settings.format_price(target_user.balance_kopeks)}\n\n"
|
||||
traffic_text = "Безлимит" if (subscription.traffic_limit_gb or 0) <= 0 else f"{subscription.traffic_limit_gb} ГБ"
|
||||
devices_limit = subscription.device_limit
|
||||
if devices_limit is None:
|
||||
devices_limit = settings.DEFAULT_DEVICE_LIMIT
|
||||
devices_limit = subscription.device_limit or settings.DEFAULT_DEVICE_LIMIT
|
||||
servers_count = len(subscription.connected_squads or [])
|
||||
text += f"📶 Трафик: {traffic_text}\n"
|
||||
text += f"📱 Устройства: {devices_limit}\n"
|
||||
@@ -4155,15 +3680,12 @@ async def admin_buy_subscription_confirm(
|
||||
return
|
||||
|
||||
text = f"💳 <b>Подтверждение покупки подписки</b>\n\n"
|
||||
target_user_link = f'<a href="tg://user?id={target_user.telegram_id}">{target_user.full_name}</a>'
|
||||
text += f"👤 {target_user_link} (ID: {target_user.telegram_id})\n"
|
||||
text += f"👤 {target_user.full_name} (ID: {target_user.telegram_id})\n"
|
||||
text += f"📅 Период подписки: {period_days} дней\n"
|
||||
text += f"💰 Стоимость: {settings.format_price(price_kopeks)}\n"
|
||||
text += f"💰 Баланс пользователя: {settings.format_price(target_user.balance_kopeks)}\n\n"
|
||||
traffic_text = "Безлимит" if (subscription.traffic_limit_gb or 0) <= 0 else f"{subscription.traffic_limit_gb} ГБ"
|
||||
devices_limit = subscription.device_limit
|
||||
if devices_limit is None:
|
||||
devices_limit = settings.DEFAULT_DEVICE_LIMIT
|
||||
devices_limit = subscription.device_limit or settings.DEFAULT_DEVICE_LIMIT
|
||||
servers_count = len(subscription.connected_squads or [])
|
||||
text += f"📶 Трафик: {traffic_text}\n"
|
||||
text += f"📱 Устройства: {devices_limit}\n"
|
||||
@@ -4310,54 +3832,40 @@ async def admin_buy_subscription_execute(
|
||||
from app.external.remnawave_api import UserStatus, TrafficLimitStrategy
|
||||
remnawave_service = RemnaWaveService()
|
||||
|
||||
hwid_limit = resolve_hwid_device_limit_for_payload(subscription)
|
||||
|
||||
if target_user.remnawave_uuid:
|
||||
async with remnawave_service.get_api_client() as api:
|
||||
update_kwargs = dict(
|
||||
remnawave_user = await api.update_user(
|
||||
uuid=target_user.remnawave_uuid,
|
||||
status=UserStatus.ACTIVE if subscription.is_active else UserStatus.EXPIRED,
|
||||
expire_at=subscription.end_date,
|
||||
traffic_limit_bytes=subscription.traffic_limit_gb * (1024**3) if subscription.traffic_limit_gb > 0 else 0,
|
||||
traffic_limit_strategy=TrafficLimitStrategy.MONTH,
|
||||
hwid_device_limit=subscription.device_limit,
|
||||
description=settings.format_remnawave_user_description(
|
||||
full_name=target_user.full_name,
|
||||
username=target_user.username,
|
||||
telegram_id=target_user.telegram_id
|
||||
),
|
||||
active_internal_squads=subscription.connected_squads,
|
||||
active_internal_squads=subscription.connected_squads
|
||||
)
|
||||
|
||||
if hwid_limit is not None:
|
||||
update_kwargs['hwid_device_limit'] = hwid_limit
|
||||
|
||||
remnawave_user = await api.update_user(**update_kwargs)
|
||||
else:
|
||||
username = settings.format_remnawave_username(
|
||||
full_name=target_user.full_name,
|
||||
username=target_user.username,
|
||||
telegram_id=target_user.telegram_id,
|
||||
)
|
||||
username = f"user_{target_user.telegram_id}"
|
||||
async with remnawave_service.get_api_client() as api:
|
||||
create_kwargs = dict(
|
||||
remnawave_user = await api.create_user(
|
||||
username=username,
|
||||
expire_at=subscription.end_date,
|
||||
status=UserStatus.ACTIVE if subscription.is_active else UserStatus.EXPIRED,
|
||||
traffic_limit_bytes=subscription.traffic_limit_gb * (1024**3) if subscription.traffic_limit_gb > 0 else 0,
|
||||
traffic_limit_strategy=TrafficLimitStrategy.MONTH,
|
||||
telegram_id=target_user.telegram_id,
|
||||
hwid_device_limit=subscription.device_limit,
|
||||
description=settings.format_remnawave_user_description(
|
||||
full_name=target_user.full_name,
|
||||
username=target_user.username,
|
||||
telegram_id=target_user.telegram_id
|
||||
),
|
||||
active_internal_squads=subscription.connected_squads,
|
||||
active_internal_squads=subscription.connected_squads
|
||||
)
|
||||
|
||||
if hwid_limit is not None:
|
||||
create_kwargs['hwid_device_limit'] = hwid_limit
|
||||
|
||||
remnawave_user = await api.create_user(**create_kwargs)
|
||||
|
||||
if remnawave_user and hasattr(remnawave_user, 'uuid'):
|
||||
target_user.remnawave_uuid = remnawave_user.uuid
|
||||
@@ -4374,10 +3882,9 @@ async def admin_buy_subscription_execute(
|
||||
else:
|
||||
message = "❌ Ошибка: у пользователя нет существующей подписки"
|
||||
|
||||
target_user_link = f'<a href="tg://user?id={target_user.telegram_id}">{target_user.full_name}</a>'
|
||||
await callback.message.edit_text(
|
||||
f"{message}\n\n"
|
||||
f"👤 {target_user_link} (ID: {target_user.telegram_id})\n"
|
||||
f"👤 {target_user.full_name} (ID: {target_user.telegram_id})\n"
|
||||
f"💰 Списано: {settings.format_price(price_kopeks)}\n"
|
||||
f"📅 Подписка действительна до: {format_datetime(subscription.end_date)}",
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
@@ -4385,8 +3892,7 @@ async def admin_buy_subscription_execute(
|
||||
text="⬅️ Назад к подписке",
|
||||
callback_data=f"admin_user_subscription_{user_id}"
|
||||
)]
|
||||
]),
|
||||
parse_mode="HTML"
|
||||
])
|
||||
)
|
||||
|
||||
try:
|
||||
@@ -4598,12 +4104,12 @@ def register_handlers(dp: Dispatcher):
|
||||
|
||||
dp.callback_query.register(
|
||||
show_user_promo_group,
|
||||
F.data.startswith("admin_user_promo_group_") & ~F.data.contains("_set_") & ~F.data.contains("_toggle_")
|
||||
F.data.startswith("admin_user_promo_group_") & ~F.data.contains("_set_")
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
set_user_promo_group,
|
||||
F.data.startswith("admin_user_promo_group_toggle_")
|
||||
F.data.startswith("admin_user_promo_group_set_")
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
@@ -4616,21 +4122,6 @@ def register_handlers(dp: Dispatcher):
|
||||
AdminStates.editing_user_balance
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
show_user_referrals,
|
||||
F.data.startswith("admin_user_referrals_") & ~F.data.contains("_edit")
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
start_edit_user_referrals,
|
||||
F.data.startswith("admin_user_referrals_edit_")
|
||||
)
|
||||
|
||||
dp.message.register(
|
||||
process_edit_user_referrals,
|
||||
AdminStates.editing_user_referrals
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
start_send_user_message,
|
||||
F.data.startswith("admin_user_send_message_")
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import logging
|
||||
import re
|
||||
from aiogram import Dispatcher, types, F
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
@@ -19,92 +18,6 @@ from app.database.crud.welcome_text import (
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def validate_html_tags(text: str) -> tuple[bool, str]:
|
||||
"""
|
||||
Проверяет HTML-теги в тексте на соответствие требованиям Telegram API.
|
||||
|
||||
Args:
|
||||
text: Текст для проверки
|
||||
|
||||
Returns:
|
||||
Кортеж из (валидно ли, сообщение об ошибке или None)
|
||||
"""
|
||||
# Поддерживаемые теги в parse_mode="HTML" для Telegram API
|
||||
allowed_tags = {
|
||||
'b', 'strong', # жирный
|
||||
'i', 'em', # курсив
|
||||
'u', 'ins', # подчеркнуто
|
||||
's', 'strike', 'del', # зачеркнуто
|
||||
'code', # моноширинный для коротких фрагментов
|
||||
'pre', # моноширинный блок кода
|
||||
'a' # ссылки
|
||||
}
|
||||
|
||||
# Убираем плейсхолдеры из строки перед проверкой тегов
|
||||
# Плейсхолдеры имеют формат {ключ}, и не являются тегами
|
||||
placeholder_pattern = r'\{[^{}]+\}'
|
||||
clean_text = re.sub(placeholder_pattern, '', text)
|
||||
|
||||
# Находим все открывающие и закрывающие теги
|
||||
tag_pattern = r'<(/?)([a-zA-Z]+)(\s[^>]*)?>'
|
||||
tags_with_pos = [(m.group(1), m.group(2), m.group(3), m.start(), m.end()) for m in re.finditer(tag_pattern, clean_text)]
|
||||
|
||||
for closing, tag, attrs, start_pos, end_pos in tags_with_pos:
|
||||
tag_lower = tag.lower()
|
||||
|
||||
# Проверяем, является ли тег поддерживаемым
|
||||
if tag_lower not in allowed_tags:
|
||||
return False, f"Неподдерживаемый HTML-тег: <{tag}>. Используйте только теги: {', '.join(sorted(allowed_tags))}"
|
||||
|
||||
# Проверяем атрибуты для тега <a>
|
||||
if tag_lower == 'a':
|
||||
if closing:
|
||||
continue # Для закрывающего тега не нужно проверять атрибуты
|
||||
if not attrs:
|
||||
return False, "Тег <a> должен содержать атрибут href, например: <a href='URL'>ссылка</a>"
|
||||
|
||||
# Проверяем, что есть атрибут href
|
||||
if 'href=' not in attrs.lower():
|
||||
return False, "Тег <a> должен содержать атрибут href, например: <a href='URL'>ссылка</a>"
|
||||
|
||||
# Проверяем формат URL
|
||||
href_match = re.search(r'href\s*=\s*[\'"]([^\'"]+)[\'"]', attrs, re.IGNORECASE)
|
||||
if href_match:
|
||||
url = href_match.group(1)
|
||||
# Проверяем, что URL начинается с поддерживаемой схемы
|
||||
if not re.match(r'^https?://|^tg://', url, re.IGNORECASE):
|
||||
return False, f"URL в теге <a> должен начинаться с http://, https:// или tg://. Найдено: {url}"
|
||||
else:
|
||||
return False, "Не удалось извлечь URL из атрибута href тега <a>"
|
||||
|
||||
# Проверяем парность тегов с использованием стека
|
||||
stack = []
|
||||
for closing, tag, attrs, start_pos, end_pos in tags_with_pos:
|
||||
tag_lower = tag.lower()
|
||||
|
||||
if tag_lower not in allowed_tags:
|
||||
continue
|
||||
|
||||
if closing:
|
||||
# Это закрывающий тег
|
||||
if not stack:
|
||||
return False, f"Лишний закрывающий тег: </{tag}>"
|
||||
|
||||
last_opening_tag = stack.pop()
|
||||
if last_opening_tag.lower() != tag_lower:
|
||||
return False, f"Тег </{tag}> не соответствует открывающему тегу <{last_opening_tag}>"
|
||||
else:
|
||||
# Это открывающий тег
|
||||
stack.append(tag)
|
||||
|
||||
# Если остались незакрытые теги
|
||||
if stack:
|
||||
unclosed_tags = ", ".join([f"<{tag}>" for tag in stack])
|
||||
return False, f"Незакрытые теги: {unclosed_tags}"
|
||||
|
||||
return True, None
|
||||
|
||||
def get_telegram_formatting_info() -> str:
|
||||
return """
|
||||
📝 <b>Поддерживаемые теги форматирования:</b>
|
||||
@@ -289,12 +202,6 @@ async def process_welcome_text_edit(
|
||||
await message.answer("❌ Текст слишком длинный! Максимум 4000 символов.")
|
||||
return
|
||||
|
||||
# Проверяем HTML-теги на валидность
|
||||
is_valid, error_msg = validate_html_tags(new_text)
|
||||
if not is_valid:
|
||||
await message.answer(f"❌ Ошибка в HTML-разметке:\n\n{error_msg}")
|
||||
return
|
||||
|
||||
success = await set_welcome_text(db, new_text, db_user.id)
|
||||
|
||||
if success:
|
||||
|
||||
@@ -67,7 +67,7 @@ async def start_cryptobot_payment(
|
||||
# Если включен быстрый выбор суммы и не отключены кнопки, добавляем кнопки
|
||||
if settings.YOOKASSA_QUICK_AMOUNT_SELECTION_ENABLED and not settings.DISABLE_TOPUP_BUTTONS:
|
||||
from .main import get_quick_amount_buttons
|
||||
quick_amount_buttons = get_quick_amount_buttons(db_user.language, db_user)
|
||||
quick_amount_buttons = get_quick_amount_buttons(db_user.language)
|
||||
if quick_amount_buttons:
|
||||
# Вставляем кнопки быстрого выбора перед кнопкой "Назад"
|
||||
keyboard.inline_keyboard = quick_amount_buttons + keyboard.inline_keyboard
|
||||
|
||||
@@ -55,7 +55,7 @@ async def start_heleket_payment(
|
||||
if settings.YOOKASSA_QUICK_AMOUNT_SELECTION_ENABLED and not settings.DISABLE_TOPUP_BUTTONS:
|
||||
from .main import get_quick_amount_buttons
|
||||
|
||||
quick_buttons = get_quick_amount_buttons(db_user.language, db_user)
|
||||
quick_buttons = get_quick_amount_buttons(db_user.language)
|
||||
if quick_buttons:
|
||||
keyboard.inline_keyboard = quick_buttons + keyboard.inline_keyboard
|
||||
|
||||
|
||||
+17
-183
@@ -8,8 +8,6 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from app.config import settings
|
||||
from app.states import BalanceStates
|
||||
from app.database.crud.user import add_user_balance
|
||||
from app.utils.price_display import calculate_user_price, format_price_button
|
||||
from app.utils.pricing_utils import format_period_description
|
||||
from app.database.crud.transaction import (
|
||||
get_user_transactions, get_user_transactions_count,
|
||||
create_transaction
|
||||
@@ -29,61 +27,34 @@ logger = logging.getLogger(__name__)
|
||||
TRANSACTIONS_PER_PAGE = 10
|
||||
|
||||
|
||||
def get_quick_amount_buttons(language: str, user: User) -> list:
|
||||
"""
|
||||
Generate quick amount buttons with user-specific pricing and discounts.
|
||||
|
||||
Args:
|
||||
language: User's language for formatting
|
||||
user: User object to calculate personalized discounts
|
||||
|
||||
Returns:
|
||||
List of button rows for inline keyboard
|
||||
"""
|
||||
def get_quick_amount_buttons(language: str) -> list:
|
||||
if not settings.YOOKASSA_QUICK_AMOUNT_SELECTION_ENABLED or settings.DISABLE_TOPUP_BUTTONS:
|
||||
return []
|
||||
|
||||
from app.localization.texts import get_texts
|
||||
texts = get_texts(language)
|
||||
|
||||
|
||||
buttons = []
|
||||
periods = settings.get_available_subscription_periods()
|
||||
periods = periods[:6] # Limit to 6 periods
|
||||
|
||||
|
||||
periods = periods[:6]
|
||||
|
||||
for period in periods:
|
||||
price_attr = f"PRICE_{period}_DAYS"
|
||||
if hasattr(settings, price_attr):
|
||||
base_price_kopeks = getattr(settings, price_attr)
|
||||
|
||||
# Calculate price with user's promo group discount using unified system
|
||||
price_info = calculate_user_price(user, base_price_kopeks, period, "period")
|
||||
|
||||
callback_data = f"quick_amount_{price_info.final_price}"
|
||||
|
||||
# Format button text with discount display
|
||||
period_label = f"{period} дней"
|
||||
|
||||
# For balance buttons, use simpler format without emoji and period label prefix
|
||||
if price_info.has_discount:
|
||||
button_text = (
|
||||
f"{texts.format_price(price_info.base_price)} ➜ "
|
||||
f"{texts.format_price(price_info.final_price)} "
|
||||
f"(-{price_info.discount_percent}%) • {period_label}"
|
||||
)
|
||||
else:
|
||||
button_text = f"{texts.format_price(price_info.final_price)} • {period_label}"
|
||||
|
||||
price_kopeks = getattr(settings, price_attr)
|
||||
price_rubles = price_kopeks // 100
|
||||
|
||||
callback_data = f"quick_amount_{price_kopeks}"
|
||||
|
||||
buttons.append(
|
||||
types.InlineKeyboardButton(
|
||||
text=button_text,
|
||||
text=f"{price_rubles} ₽ ({period} дней)",
|
||||
callback_data=callback_data
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
keyboard_rows = []
|
||||
for i in range(0, len(buttons), 2):
|
||||
keyboard_rows.append(buttons[i:i + 2])
|
||||
|
||||
|
||||
return keyboard_rows
|
||||
|
||||
|
||||
@@ -209,112 +180,25 @@ async def handle_balance_history_pagination(
|
||||
async def show_payment_methods(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
state: FSMContext
|
||||
):
|
||||
from app.utils.payment_utils import get_payment_methods_text
|
||||
from app.database.crud.subscription import get_subscription_by_user_id
|
||||
from app.utils.pricing_utils import calculate_months_from_days, apply_percentage_discount
|
||||
from app.config import settings
|
||||
from app.services.subscription_service import SubscriptionService
|
||||
|
||||
texts = get_texts(db_user.language)
|
||||
payment_text = get_payment_methods_text(db_user.language)
|
||||
|
||||
# Добавляем информацию о текущем тарифе пользователя
|
||||
subscription = await get_subscription_by_user_id(db, db_user.id)
|
||||
tariff_info = ""
|
||||
if subscription and not subscription.is_trial:
|
||||
# Рассчитываем приблизительную стоимость продления на 30 дней
|
||||
duration_days = 30 # Берем для примера 30 дней
|
||||
current_traffic = subscription.traffic_limit_gb
|
||||
current_connected_squads = subscription.connected_squads or []
|
||||
current_device_limit = subscription.device_limit or settings.DEFAULT_DEVICE_LIMIT
|
||||
|
||||
try:
|
||||
# Получаем цены для текущих параметров
|
||||
from app.config import PERIOD_PRICES
|
||||
base_price_original = PERIOD_PRICES.get(duration_days, 0)
|
||||
period_discount_percent = db_user.get_promo_discount("period", duration_days)
|
||||
base_price, base_discount_total = apply_percentage_discount(
|
||||
base_price_original,
|
||||
period_discount_percent,
|
||||
)
|
||||
|
||||
# Рассчитываем стоимость серверов
|
||||
from app.services.subscription_service import SubscriptionService
|
||||
subscription_service = SubscriptionService()
|
||||
servers_price_per_month, per_server_monthly_prices = await subscription_service.get_countries_price_by_uuids(
|
||||
current_connected_squads,
|
||||
db,
|
||||
promo_group_id=db_user.promo_group_id,
|
||||
)
|
||||
servers_discount_percent = db_user.get_promo_discount("servers", duration_days)
|
||||
total_servers_price = 0
|
||||
for server_price in per_server_monthly_prices:
|
||||
discounted_per_month, discount_per_month = apply_percentage_discount(
|
||||
server_price,
|
||||
servers_discount_percent,
|
||||
)
|
||||
total_servers_price += discounted_per_month
|
||||
|
||||
# Рассчитываем стоимость трафика
|
||||
traffic_price_per_month = settings.get_traffic_price(current_traffic)
|
||||
traffic_discount_percent = db_user.get_promo_discount("traffic", duration_days)
|
||||
traffic_discounted_per_month, traffic_discount_per_month = apply_percentage_discount(
|
||||
traffic_price_per_month,
|
||||
traffic_discount_percent,
|
||||
)
|
||||
|
||||
# Рассчитываем стоимость устройств
|
||||
additional_devices = max(0, (current_device_limit or 0) - settings.DEFAULT_DEVICE_LIMIT)
|
||||
devices_price_per_month = additional_devices * settings.PRICE_PER_DEVICE
|
||||
devices_discount_percent = db_user.get_promo_discount("devices", duration_days)
|
||||
devices_discounted_per_month, devices_discount_per_month = apply_percentage_discount(
|
||||
devices_price_per_month,
|
||||
devices_discount_percent,
|
||||
)
|
||||
|
||||
# Общая стоимость
|
||||
months_in_period = calculate_months_from_days(duration_days)
|
||||
total_price = (
|
||||
base_price +
|
||||
total_servers_price * months_in_period +
|
||||
traffic_discounted_per_month * months_in_period +
|
||||
devices_discounted_per_month * months_in_period
|
||||
)
|
||||
|
||||
traffic_value = current_traffic or 0
|
||||
if traffic_value <= 0:
|
||||
traffic_display = texts.t("TRAFFIC_UNLIMITED_SHORT", "Безлимит")
|
||||
else:
|
||||
traffic_display = texts.format_traffic(traffic_value)
|
||||
|
||||
current_tariff_desc = (
|
||||
f"📱 Подписка: {len(current_connected_squads)} серверов, "
|
||||
f"{traffic_display}, {current_device_limit} устр."
|
||||
)
|
||||
estimated_price_info = f"💰 Стоимость продления (примерно): {texts.format_price(total_price)} за {duration_days} дней"
|
||||
|
||||
tariff_info = f"\n\n📋 <b>Ваш текущий тариф:</b>\n{current_tariff_desc}\n{estimated_price_info}"
|
||||
except Exception as e:
|
||||
logger.warning(f"Не удалось рассчитать стоимость текущей подписки для пользователя {db_user.id}: {e}")
|
||||
tariff_info = ""
|
||||
|
||||
full_text = payment_text + tariff_info
|
||||
|
||||
keyboard = get_payment_methods_keyboard(0, db_user.language)
|
||||
|
||||
try:
|
||||
await callback.message.edit_text(
|
||||
full_text,
|
||||
payment_text,
|
||||
reply_markup=keyboard,
|
||||
parse_mode="HTML"
|
||||
)
|
||||
except TelegramBadRequest:
|
||||
try:
|
||||
await callback.message.edit_caption(
|
||||
full_text,
|
||||
payment_text,
|
||||
reply_markup=keyboard,
|
||||
parse_mode="HTML"
|
||||
)
|
||||
@@ -324,7 +208,7 @@ async def show_payment_methods(
|
||||
except TelegramBadRequest:
|
||||
pass
|
||||
await callback.message.answer(
|
||||
full_text,
|
||||
payment_text,
|
||||
reply_markup=keyboard,
|
||||
parse_mode="HTML"
|
||||
)
|
||||
@@ -395,10 +279,6 @@ async def handle_successful_topup_with_cart(
|
||||
success_text = (
|
||||
f"✅ Баланс пополнен на {texts.format_price(amount_kopeks)}!\n\n"
|
||||
f"💰 Текущий баланс: {texts.format_price(user.balance_kopeks)}\n\n"
|
||||
f"⚠️ <b>Важно:</b> Пополнение баланса не активирует подписку автоматически. "
|
||||
f"Обязательно активируйте подписку отдельно!\n\n"
|
||||
f"🔄 При наличии сохранённой корзины подписки и включенной автопокупке, "
|
||||
f"подписка будет приобретена автоматически после пополнения баланса.\n\n"
|
||||
f"🛒 У вас есть сохраненная корзина подписки\n"
|
||||
f"Стоимость: {texts.format_price(total_price)}\n\n"
|
||||
f"Хотите продолжить оформление?"
|
||||
@@ -532,14 +412,6 @@ async def process_topup_amount(
|
||||
from .mulenpay import process_mulenpay_payment_amount
|
||||
async with AsyncSessionLocal() as db:
|
||||
await process_mulenpay_payment_amount(message, db_user, db, amount_kopeks, state)
|
||||
elif payment_method == "platega":
|
||||
from app.database.database import AsyncSessionLocal
|
||||
from .platega import process_platega_payment_amount
|
||||
|
||||
async with AsyncSessionLocal() as db:
|
||||
await process_platega_payment_amount(
|
||||
message, db_user, db, amount_kopeks, state
|
||||
)
|
||||
elif payment_method == "wata":
|
||||
from app.database.database import AsyncSessionLocal
|
||||
from .wata import process_wata_payment_amount
|
||||
@@ -651,14 +523,6 @@ async def handle_quick_amount_selection(
|
||||
await process_mulenpay_payment_amount(
|
||||
callback.message, db_user, db, amount_kopeks, state
|
||||
)
|
||||
elif payment_method == "platega":
|
||||
from app.database.database import AsyncSessionLocal
|
||||
from .platega import process_platega_payment_amount
|
||||
|
||||
async with AsyncSessionLocal() as db:
|
||||
await process_platega_payment_amount(
|
||||
callback.message, db_user, db, amount_kopeks, state
|
||||
)
|
||||
elif payment_method == "wata":
|
||||
from app.database.database import AsyncSessionLocal
|
||||
from .wata import process_wata_payment_amount
|
||||
@@ -746,13 +610,6 @@ async def handle_topup_amount_callback(
|
||||
await process_mulenpay_payment_amount(
|
||||
callback.message, db_user, db, amount_kopeks, state
|
||||
)
|
||||
elif method == "platega":
|
||||
from app.database.database import AsyncSessionLocal
|
||||
from .platega import process_platega_payment_amount
|
||||
async with AsyncSessionLocal() as db:
|
||||
await process_platega_payment_amount(
|
||||
callback.message, db_user, db, amount_kopeks, state
|
||||
)
|
||||
elif method == "pal24":
|
||||
from app.database.database import AsyncSessionLocal
|
||||
from .pal24 import process_pal24_payment_amount
|
||||
@@ -774,13 +631,6 @@ async def handle_topup_amount_callback(
|
||||
await process_heleket_payment_amount(
|
||||
callback.message, db_user, db, amount_kopeks, state
|
||||
)
|
||||
elif method == "wata":
|
||||
from app.database.database import AsyncSessionLocal
|
||||
from .wata import process_wata_payment_amount
|
||||
async with AsyncSessionLocal() as db:
|
||||
await process_wata_payment_amount(
|
||||
callback.message, db_user, db, amount_kopeks, state
|
||||
)
|
||||
elif method == "stars":
|
||||
from .stars import process_stars_payment_amount
|
||||
await process_stars_payment_amount(
|
||||
@@ -864,16 +714,6 @@ def register_balance_handlers(dp: Dispatcher):
|
||||
F.data.startswith("pal24_method_"),
|
||||
)
|
||||
|
||||
from .platega import start_platega_payment, handle_platega_method_selection
|
||||
dp.callback_query.register(
|
||||
start_platega_payment,
|
||||
F.data == "topup_platega"
|
||||
)
|
||||
dp.callback_query.register(
|
||||
handle_platega_method_selection,
|
||||
F.data.startswith("platega_method_"),
|
||||
)
|
||||
|
||||
from .yookassa import check_yookassa_payment_status
|
||||
dp.callback_query.register(
|
||||
check_yookassa_payment_status,
|
||||
@@ -942,12 +782,6 @@ def register_balance_handlers(dp: Dispatcher):
|
||||
F.data.startswith("check_pal24_")
|
||||
)
|
||||
|
||||
from .platega import check_platega_payment_status
|
||||
dp.callback_query.register(
|
||||
check_platega_payment_status,
|
||||
F.data.startswith("check_platega_")
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
handle_payment_methods_unavailable,
|
||||
F.data == "payment_methods_unavailable"
|
||||
@@ -962,4 +796,4 @@ def register_balance_handlers(dp: Dispatcher):
|
||||
dp.callback_query.register(
|
||||
handle_topup_amount_callback,
|
||||
F.data.startswith("topup_amount|")
|
||||
)
|
||||
)
|
||||
@@ -48,7 +48,7 @@ async def start_mulenpay_payment(
|
||||
|
||||
if settings.YOOKASSA_QUICK_AMOUNT_SELECTION_ENABLED and not settings.DISABLE_TOPUP_BUTTONS:
|
||||
from .main import get_quick_amount_buttons
|
||||
quick_amount_buttons = get_quick_amount_buttons(db_user.language, db_user)
|
||||
quick_amount_buttons = get_quick_amount_buttons(db_user.language)
|
||||
if quick_amount_buttons:
|
||||
keyboard.inline_keyboard = quick_amount_buttons + keyboard.inline_keyboard
|
||||
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import html
|
||||
import logging
|
||||
from typing import Any, Optional
|
||||
|
||||
from aiogram import types
|
||||
from aiogram.exceptions import TelegramBadRequest
|
||||
from aiogram.fsm.context import FSMContext
|
||||
@@ -266,7 +264,7 @@ async def start_pal24_payment(
|
||||
|
||||
if settings.YOOKASSA_QUICK_AMOUNT_SELECTION_ENABLED and not settings.DISABLE_TOPUP_BUTTONS:
|
||||
from .main import get_quick_amount_buttons
|
||||
quick_amount_buttons = get_quick_amount_buttons(db_user.language, db_user)
|
||||
quick_amount_buttons = get_quick_amount_buttons(db_user.language)
|
||||
if quick_amount_buttons:
|
||||
keyboard.inline_keyboard = quick_amount_buttons + keyboard.inline_keyboard
|
||||
|
||||
@@ -425,77 +423,8 @@ async def check_pal24_payment_status(
|
||||
if not isinstance(links_meta, dict):
|
||||
links_meta = {}
|
||||
|
||||
links_info = status_info.get("links") or {}
|
||||
|
||||
def _extract_link(source: Any, keys: tuple[str, ...]) -> Optional[str]:
|
||||
stack: list[Any] = [source]
|
||||
while stack:
|
||||
current = stack.pop()
|
||||
if isinstance(current, dict):
|
||||
for key in keys:
|
||||
value = current.get(key)
|
||||
if value:
|
||||
return str(value)
|
||||
stack.extend(current.values())
|
||||
elif isinstance(current, list):
|
||||
stack.extend(current)
|
||||
return None
|
||||
|
||||
raw_response = metadata.get("raw_response") if isinstance(metadata, dict) else None
|
||||
remote_data = status_info.get("remote_data")
|
||||
transfer_keys = (
|
||||
"transfer_url",
|
||||
"transferUrl",
|
||||
"transfer_link",
|
||||
"transferLink",
|
||||
"transfer",
|
||||
"sbp_url",
|
||||
"sbpUrl",
|
||||
"sbp_link",
|
||||
"sbpLink",
|
||||
)
|
||||
card_keys = (
|
||||
"link_url",
|
||||
"linkUrl",
|
||||
"link",
|
||||
"card_url",
|
||||
"cardUrl",
|
||||
"card_link",
|
||||
"cardLink",
|
||||
"payment_url",
|
||||
"paymentUrl",
|
||||
"url",
|
||||
)
|
||||
|
||||
extra_sbp_link = (
|
||||
_extract_link(raw_response, transfer_keys)
|
||||
if raw_response
|
||||
else None
|
||||
)
|
||||
if not extra_sbp_link and remote_data:
|
||||
extra_sbp_link = _extract_link(remote_data, transfer_keys)
|
||||
|
||||
extra_card_link = (
|
||||
_extract_link(raw_response, card_keys)
|
||||
if raw_response
|
||||
else None
|
||||
)
|
||||
if not extra_card_link and remote_data:
|
||||
extra_card_link = _extract_link(remote_data, card_keys)
|
||||
|
||||
sbp_link = (
|
||||
links_info.get("sbp")
|
||||
or links_meta.get("sbp")
|
||||
or status_info.get("sbp_url")
|
||||
or extra_sbp_link
|
||||
or payment.link_url
|
||||
)
|
||||
card_link = (
|
||||
links_info.get("card")
|
||||
or links_meta.get("card")
|
||||
or status_info.get("card_url")
|
||||
or extra_card_link
|
||||
)
|
||||
sbp_link = links_meta.get("sbp") or payment.link_url
|
||||
card_link = links_meta.get("card")
|
||||
|
||||
if not card_link and payment.link_page_url and payment.link_page_url != sbp_link:
|
||||
card_link = payment.link_page_url
|
||||
@@ -530,53 +459,10 @@ async def check_pal24_payment_status(
|
||||
db_user = getattr(callback, 'db_user', None)
|
||||
texts = get_texts(db_user.language if db_user else 'ru') if db_user else get_texts('ru')
|
||||
|
||||
pay_rows: list[list[types.InlineKeyboardButton]] = []
|
||||
|
||||
if not payment.is_paid and payment.status in {"NEW", "PROCESS"}:
|
||||
default_sbp_text = texts.t(
|
||||
"PAL24_SBP_PAY_BUTTON",
|
||||
"🏦 Оплатить через PayPalych (СБП)",
|
||||
)
|
||||
sbp_button_text = settings.get_pal24_sbp_button_text(default_sbp_text)
|
||||
|
||||
if sbp_link and settings.is_pal24_sbp_button_visible():
|
||||
pay_rows.append(
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=sbp_button_text,
|
||||
url=sbp_link,
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
default_card_text = texts.t(
|
||||
"PAL24_CARD_PAY_BUTTON",
|
||||
"💳 Оплатить банковской картой (PayPalych)",
|
||||
)
|
||||
card_button_text = settings.get_pal24_card_button_text(default_card_text)
|
||||
|
||||
if card_link and settings.is_pal24_card_button_visible():
|
||||
if not pay_rows or pay_rows[-1][0].url != card_link:
|
||||
pay_rows.append(
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=card_button_text,
|
||||
url=card_link,
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
keyboard_rows = pay_rows + [
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.t("CHECK_STATUS_BUTTON", "📊 Проверить статус"),
|
||||
callback_data=f"check_pal24_{local_payment_id}",
|
||||
)
|
||||
],
|
||||
[types.InlineKeyboardButton(text=texts.BACK, callback_data="balance_topup")],
|
||||
]
|
||||
|
||||
keyboard = types.InlineKeyboardMarkup(inline_keyboard=keyboard_rows)
|
||||
keyboard = types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text=texts.t("CHECK_STATUS_BUTTON", "📊 Проверить статус"), callback_data=f"check_pal24_{local_payment_id}")],
|
||||
[types.InlineKeyboardButton(text=texts.BACK, callback_data="balance_topup")]
|
||||
])
|
||||
|
||||
await callback.answer()
|
||||
try:
|
||||
|
||||
@@ -1,333 +0,0 @@
|
||||
"""Handlers for Platega balance interactions."""
|
||||
|
||||
import logging
|
||||
from typing import List
|
||||
|
||||
from aiogram import types
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.models import User
|
||||
from app.keyboards.inline import get_back_keyboard
|
||||
from app.localization.texts import get_texts
|
||||
from app.services.payment_service import PaymentService
|
||||
from app.states import BalanceStates
|
||||
from app.utils.decorators import error_handler
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _get_active_methods() -> List[int]:
|
||||
methods = settings.get_platega_active_methods()
|
||||
return [code for code in methods if code in {2, 10, 11, 12, 13}]
|
||||
|
||||
|
||||
async def _prompt_amount(
|
||||
message: types.Message,
|
||||
db_user: User,
|
||||
state: FSMContext,
|
||||
method_code: int,
|
||||
) -> None:
|
||||
texts = get_texts(db_user.language)
|
||||
method_name = settings.get_platega_method_display_title(method_code)
|
||||
|
||||
min_amount_label = settings.format_price(settings.PLATEGA_MIN_AMOUNT_KOPEKS)
|
||||
max_amount_kopeks = settings.PLATEGA_MAX_AMOUNT_KOPEKS
|
||||
max_amount_label = (
|
||||
settings.format_price(max_amount_kopeks)
|
||||
if max_amount_kopeks and max_amount_kopeks > 0
|
||||
else ""
|
||||
)
|
||||
|
||||
default_prompt_body = (
|
||||
"Введите сумму для пополнения от {min_amount} до {max_amount}.\n"
|
||||
if max_amount_kopeks and max_amount_kopeks > 0
|
||||
else "Введите сумму для пополнения от {min_amount}.\n"
|
||||
)
|
||||
|
||||
prompt_template = texts.t(
|
||||
"PLATEGA_TOPUP_PROMPT",
|
||||
(
|
||||
"💳 <b>Оплата через Platega ({method_name})</b>\n\n"
|
||||
f"{default_prompt_body}"
|
||||
"Оплата происходит через Platega."
|
||||
),
|
||||
)
|
||||
|
||||
keyboard = get_back_keyboard(db_user.language)
|
||||
|
||||
if settings.YOOKASSA_QUICK_AMOUNT_SELECTION_ENABLED and not settings.DISABLE_TOPUP_BUTTONS:
|
||||
from .main import get_quick_amount_buttons
|
||||
|
||||
quick_amount_buttons = get_quick_amount_buttons(db_user.language, db_user)
|
||||
if quick_amount_buttons:
|
||||
keyboard.inline_keyboard = quick_amount_buttons + keyboard.inline_keyboard
|
||||
|
||||
await message.edit_text(
|
||||
prompt_template.format(
|
||||
method_name=method_name,
|
||||
min_amount=min_amount_label,
|
||||
max_amount=max_amount_label,
|
||||
),
|
||||
reply_markup=keyboard,
|
||||
parse_mode="HTML",
|
||||
)
|
||||
|
||||
await state.set_state(BalanceStates.waiting_for_amount)
|
||||
await state.update_data(payment_method="platega", platega_method=method_code)
|
||||
|
||||
|
||||
@error_handler
|
||||
async def start_platega_payment(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
state: FSMContext,
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
if not settings.is_platega_enabled():
|
||||
await callback.answer(
|
||||
texts.t(
|
||||
"PLATEGA_TEMPORARILY_UNAVAILABLE",
|
||||
"❌ Оплата через Platega временно недоступна",
|
||||
),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
active_methods = _get_active_methods()
|
||||
if not active_methods:
|
||||
await callback.answer(
|
||||
texts.t(
|
||||
"PLATEGA_METHODS_NOT_CONFIGURED",
|
||||
"⚠️ На стороне Platega нет доступных методов оплаты",
|
||||
),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
await state.update_data(payment_method="platega")
|
||||
|
||||
if len(active_methods) == 1:
|
||||
await _prompt_amount(callback.message, db_user, state, active_methods[0])
|
||||
await callback.answer()
|
||||
return
|
||||
|
||||
method_buttons: list[list[types.InlineKeyboardButton]] = []
|
||||
for method_code in active_methods:
|
||||
label = settings.get_platega_method_display_title(method_code)
|
||||
method_buttons.append(
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=label,
|
||||
callback_data=f"platega_method_{method_code}",
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
method_buttons.append(
|
||||
[types.InlineKeyboardButton(text=texts.BACK, callback_data="balance_topup")]
|
||||
)
|
||||
|
||||
await callback.message.edit_text(
|
||||
texts.t(
|
||||
"PLATEGA_SELECT_PAYMENT_METHOD",
|
||||
"Выберите способ оплаты Platega:",
|
||||
),
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=method_buttons),
|
||||
)
|
||||
await state.set_state(BalanceStates.waiting_for_platega_method)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@error_handler
|
||||
async def handle_platega_method_selection(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
state: FSMContext,
|
||||
):
|
||||
try:
|
||||
method_code = int(callback.data.rsplit("_", 1)[-1])
|
||||
except ValueError:
|
||||
await callback.answer("❌ Некорректный способ оплаты", show_alert=True)
|
||||
return
|
||||
|
||||
if method_code not in _get_active_methods():
|
||||
await callback.answer("⚠️ Этот способ сейчас недоступен", show_alert=True)
|
||||
return
|
||||
|
||||
await _prompt_amount(callback.message, db_user, state, method_code)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@error_handler
|
||||
async def process_platega_payment_amount(
|
||||
message: types.Message,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
amount_kopeks: int,
|
||||
state: FSMContext,
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
if not settings.is_platega_enabled():
|
||||
await message.answer(
|
||||
texts.t(
|
||||
"PLATEGA_TEMPORARILY_UNAVAILABLE",
|
||||
"❌ Оплата через Platega временно недоступна",
|
||||
)
|
||||
)
|
||||
return
|
||||
|
||||
data = await state.get_data()
|
||||
method_code = int(data.get("platega_method", 0))
|
||||
if method_code not in _get_active_methods():
|
||||
await message.answer(
|
||||
texts.t(
|
||||
"PLATEGA_METHOD_SELECTION_REQUIRED",
|
||||
"⚠️ Выберите способ оплаты Platega перед вводом суммы",
|
||||
)
|
||||
)
|
||||
await state.set_state(BalanceStates.waiting_for_platega_method)
|
||||
return
|
||||
|
||||
if amount_kopeks < settings.PLATEGA_MIN_AMOUNT_KOPEKS:
|
||||
await message.answer(
|
||||
texts.t(
|
||||
"PLATEGA_AMOUNT_TOO_LOW",
|
||||
"Минимальная сумма для оплаты через Platega: {amount}",
|
||||
).format(amount=settings.format_price(settings.PLATEGA_MIN_AMOUNT_KOPEKS))
|
||||
)
|
||||
return
|
||||
|
||||
if amount_kopeks > settings.PLATEGA_MAX_AMOUNT_KOPEKS:
|
||||
await message.answer(
|
||||
texts.t(
|
||||
"PLATEGA_AMOUNT_TOO_HIGH",
|
||||
"Максимальная сумма для оплаты через Platega: {amount}",
|
||||
).format(amount=settings.format_price(settings.PLATEGA_MAX_AMOUNT_KOPEKS))
|
||||
)
|
||||
return
|
||||
|
||||
try:
|
||||
payment_service = PaymentService(message.bot)
|
||||
payment_result = await payment_service.create_platega_payment(
|
||||
db=db,
|
||||
user_id=db_user.id,
|
||||
amount_kopeks=amount_kopeks,
|
||||
description=settings.get_balance_payment_description(amount_kopeks),
|
||||
language=db_user.language,
|
||||
payment_method_code=method_code,
|
||||
)
|
||||
except Exception as error:
|
||||
logger.exception("Ошибка создания платежа Platega: %s", error)
|
||||
payment_result = None
|
||||
|
||||
if not payment_result or not payment_result.get("redirect_url"):
|
||||
await message.answer(
|
||||
texts.t(
|
||||
"PLATEGA_PAYMENT_ERROR",
|
||||
"❌ Ошибка создания платежа Platega. Попробуйте позже или обратитесь в поддержку.",
|
||||
)
|
||||
)
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
redirect_url = payment_result.get("redirect_url")
|
||||
local_payment_id = payment_result.get("local_payment_id")
|
||||
transaction_id = payment_result.get("transaction_id")
|
||||
method_title = settings.get_platega_method_display_title(method_code)
|
||||
|
||||
keyboard = types.InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.t(
|
||||
"PLATEGA_PAY_BUTTON",
|
||||
"💳 Оплатить через {method}",
|
||||
).format(method=method_title),
|
||||
url=redirect_url,
|
||||
)
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.t("CHECK_STATUS_BUTTON", "📊 Проверить статус"),
|
||||
callback_data=f"check_platega_{local_payment_id}",
|
||||
)
|
||||
],
|
||||
[types.InlineKeyboardButton(text=texts.BACK, callback_data="balance_topup")],
|
||||
]
|
||||
)
|
||||
|
||||
instructions_template = texts.t(
|
||||
"PLATEGA_PAYMENT_INSTRUCTIONS",
|
||||
(
|
||||
"💳 <b>Оплата через Platega ({method})</b>\n\n"
|
||||
"💰 Сумма: {amount}\n"
|
||||
"🆔 ID транзакции: {transaction}\n\n"
|
||||
"📱 <b>Инструкция:</b>\n"
|
||||
"1. Нажмите кнопку «Оплатить»\n"
|
||||
"2. Следуйте подсказкам платёжной системы\n"
|
||||
"3. Подтвердите перевод\n"
|
||||
"4. Средства зачислятся автоматически\n\n"
|
||||
"❓ Если возникнут проблемы, обратитесь в {support}"
|
||||
),
|
||||
)
|
||||
|
||||
await message.answer(
|
||||
instructions_template.format(
|
||||
method=method_title,
|
||||
amount=settings.format_price(amount_kopeks),
|
||||
transaction=transaction_id or local_payment_id,
|
||||
support=settings.get_support_contact_display_html(),
|
||||
),
|
||||
reply_markup=keyboard,
|
||||
parse_mode="HTML",
|
||||
)
|
||||
|
||||
await state.clear()
|
||||
|
||||
|
||||
@error_handler
|
||||
async def check_platega_payment_status(
|
||||
callback: types.CallbackQuery,
|
||||
db: AsyncSession,
|
||||
):
|
||||
try:
|
||||
local_payment_id = int(callback.data.split("_")[-1])
|
||||
except ValueError:
|
||||
await callback.answer("❌ Некорректный идентификатор платежа", show_alert=True)
|
||||
return
|
||||
|
||||
payment_service = PaymentService(callback.bot)
|
||||
|
||||
try:
|
||||
status_info = await payment_service.get_platega_payment_status(db, local_payment_id)
|
||||
except Exception as error:
|
||||
logger.exception("Ошибка проверки статуса Platega: %s", error)
|
||||
await callback.answer("⚠️ Ошибка проверки статуса", show_alert=True)
|
||||
return
|
||||
|
||||
if not status_info:
|
||||
await callback.answer("⚠️ Платёж не найден", show_alert=True)
|
||||
return
|
||||
|
||||
payment = status_info.get("payment")
|
||||
status = status_info.get("status")
|
||||
is_paid = status_info.get("is_paid")
|
||||
|
||||
language = "ru"
|
||||
user = getattr(payment, "user", None)
|
||||
if user and getattr(user, "language", None):
|
||||
language = user.language
|
||||
|
||||
texts = get_texts(language)
|
||||
|
||||
if is_paid:
|
||||
await callback.answer(texts.t("PLATEGA_PAYMENT_ALREADY_CONFIRMED", "✅ Платёж уже зачислен"), show_alert=True)
|
||||
else:
|
||||
await callback.answer(
|
||||
texts.t("PLATEGA_PAYMENT_STATUS", "Текущий статус платежа: {status}").format(status=status),
|
||||
show_alert=True,
|
||||
)
|
||||
@@ -42,7 +42,7 @@ async def start_stars_payment(
|
||||
# Если включен быстрый выбор суммы и не отключены кнопки, добавляем кнопки
|
||||
if settings.YOOKASSA_QUICK_AMOUNT_SELECTION_ENABLED and not settings.DISABLE_TOPUP_BUTTONS:
|
||||
from .main import get_quick_amount_buttons
|
||||
quick_amount_buttons = get_quick_amount_buttons(db_user.language, db_user)
|
||||
quick_amount_buttons = get_quick_amount_buttons(db_user.language)
|
||||
if quick_amount_buttons:
|
||||
# Вставляем кнопки быстрого выбора перед кнопкой "Назад"
|
||||
keyboard.inline_keyboard = quick_amount_buttons + keyboard.inline_keyboard
|
||||
|
||||
@@ -45,7 +45,7 @@ async def start_wata_payment(
|
||||
if settings.YOOKASSA_QUICK_AMOUNT_SELECTION_ENABLED and not settings.DISABLE_TOPUP_BUTTONS:
|
||||
from .main import get_quick_amount_buttons
|
||||
|
||||
quick_amount_buttons = get_quick_amount_buttons(db_user.language, db_user)
|
||||
quick_amount_buttons = get_quick_amount_buttons(db_user.language)
|
||||
if quick_amount_buttons:
|
||||
keyboard.inline_keyboard = quick_amount_buttons + keyboard.inline_keyboard
|
||||
|
||||
|
||||
@@ -48,7 +48,7 @@ async def start_yookassa_payment(
|
||||
# Если включен быстрый выбор суммы и не отключены кнопки, добавляем кнопки
|
||||
if settings.YOOKASSA_QUICK_AMOUNT_SELECTION_ENABLED and not settings.DISABLE_TOPUP_BUTTONS:
|
||||
from .main import get_quick_amount_buttons
|
||||
quick_amount_buttons = get_quick_amount_buttons(db_user.language, db_user)
|
||||
quick_amount_buttons = get_quick_amount_buttons(db_user.language)
|
||||
if quick_amount_buttons:
|
||||
# Вставляем кнопки быстрого выбора перед кнопкой "Назад"
|
||||
keyboard.inline_keyboard = quick_amount_buttons + keyboard.inline_keyboard
|
||||
@@ -98,7 +98,7 @@ async def start_yookassa_sbp_payment(
|
||||
# Если включен быстрый выбор суммы и не отключены кнопки, добавляем кнопки
|
||||
if settings.YOOKASSA_QUICK_AMOUNT_SELECTION_ENABLED and not settings.DISABLE_TOPUP_BUTTONS:
|
||||
from .main import get_quick_amount_buttons
|
||||
quick_amount_buttons = get_quick_amount_buttons(db_user.language, db_user)
|
||||
quick_amount_buttons = get_quick_amount_buttons(db_user.language)
|
||||
if quick_amount_buttons:
|
||||
# Вставляем кнопки быстрого выбора перед кнопкой "Назад"
|
||||
keyboard.inline_keyboard = quick_amount_buttons + keyboard.inline_keyboard
|
||||
|
||||
@@ -38,7 +38,6 @@ from app.utils.promo_offer import (
|
||||
from app.services.privacy_policy_service import PrivacyPolicyService
|
||||
from app.services.public_offer_service import PublicOfferService
|
||||
from app.services.faq_service import FaqService
|
||||
from app.utils.timezone import format_local_datetime
|
||||
from app.utils.pricing_utils import format_period_description
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -953,8 +952,7 @@ def _get_subscription_status(user: User, texts) -> str:
|
||||
|
||||
current_time = datetime.utcnow()
|
||||
actual_status = (subscription.actual_status or "").lower()
|
||||
end_date = getattr(subscription, "end_date", None)
|
||||
end_date_text = format_local_datetime(end_date, "%d.%m.%Y") if end_date else None
|
||||
end_date_text = subscription.end_date.strftime("%d.%m.%Y")
|
||||
days_left = 0
|
||||
|
||||
if subscription.end_date > current_time:
|
||||
@@ -970,10 +968,10 @@ def _get_subscription_status(user: User, texts) -> str:
|
||||
return texts.t(
|
||||
"SUB_STATUS_EXPIRED",
|
||||
"🔴 Истекла\n📅 {end_date}",
|
||||
).format(end_date=end_date_text or "—")
|
||||
).format(end_date=end_date_text)
|
||||
|
||||
if actual_status == "trial":
|
||||
if days_left > 1 and end_date_text:
|
||||
if days_left > 1:
|
||||
return texts.t(
|
||||
"SUB_STATUS_TRIAL_ACTIVE",
|
||||
"🎁 Тестовая подписка\n📅 до {end_date} ({days} дн.)",
|
||||
@@ -992,7 +990,7 @@ def _get_subscription_status(user: User, texts) -> str:
|
||||
)
|
||||
|
||||
if actual_status == "active":
|
||||
if days_left > 7 and end_date_text:
|
||||
if days_left > 7:
|
||||
return texts.t(
|
||||
"SUB_STATUS_ACTIVE_LONG",
|
||||
"💎 Активна\n📅 до {end_date} ({days} дн.)",
|
||||
|
||||
+26
-47
@@ -1,8 +1,9 @@
|
||||
import logging
|
||||
from aiogram import Dispatcher, types, F, Bot
|
||||
from aiogram import Dispatcher, types, F
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.states import PromoCodeStates
|
||||
from app.database.models import User
|
||||
from app.keyboards.inline import get_back_keyboard
|
||||
@@ -31,45 +32,6 @@ async def show_promocode_menu(
|
||||
await callback.answer()
|
||||
|
||||
|
||||
async def activate_promocode_for_registration(
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
code: str,
|
||||
bot: Bot = None
|
||||
) -> dict:
|
||||
"""
|
||||
Активирует промокод для пользователя во время регистрации.
|
||||
Возвращает результат активации без отправки сообщений.
|
||||
"""
|
||||
promocode_service = PromoCodeService()
|
||||
result = await promocode_service.activate_promocode(db, user_id, code)
|
||||
|
||||
if result["success"]:
|
||||
logger.info(f"✅ Пользователь {user_id} активировал промокод {code} при регистрации")
|
||||
|
||||
# Отправляем уведомление админу, если бот доступен
|
||||
if bot:
|
||||
try:
|
||||
from app.database.crud.user import get_user_by_id
|
||||
user = await get_user_by_id(db, user_id)
|
||||
if user:
|
||||
notification_service = AdminNotificationService(bot)
|
||||
await notification_service.send_promocode_activation_notification(
|
||||
db,
|
||||
user,
|
||||
result.get("promocode", {"code": code}),
|
||||
result["description"],
|
||||
)
|
||||
except Exception as notify_error:
|
||||
logger.error(
|
||||
"Ошибка отправки админ уведомления об активации промокода %s: %s",
|
||||
code,
|
||||
notify_error,
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
@error_handler
|
||||
async def process_promocode(
|
||||
message: types.Message,
|
||||
@@ -78,9 +40,9 @@ async def process_promocode(
|
||||
db: AsyncSession
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
|
||||
code = message.text.strip()
|
||||
|
||||
|
||||
if not code:
|
||||
await message.answer(
|
||||
texts.t(
|
||||
@@ -90,14 +52,31 @@ async def process_promocode(
|
||||
reply_markup=get_back_keyboard(db_user.language)
|
||||
)
|
||||
return
|
||||
|
||||
result = await activate_promocode_for_registration(db, db_user.id, code, message.bot)
|
||||
|
||||
|
||||
promocode_service = PromoCodeService()
|
||||
result = await promocode_service.activate_promocode(db, db_user.id, code)
|
||||
|
||||
if result["success"]:
|
||||
await message.answer(
|
||||
texts.PROMOCODE_SUCCESS.format(description=result["description"]),
|
||||
reply_markup=get_back_keyboard(db_user.language)
|
||||
)
|
||||
logger.info(f"✅ Пользователь {db_user.telegram_id} активировал промокод {code}")
|
||||
|
||||
try:
|
||||
notification_service = AdminNotificationService(message.bot)
|
||||
await notification_service.send_promocode_activation_notification(
|
||||
db,
|
||||
db_user,
|
||||
result.get("promocode", {"code": code}),
|
||||
result["description"],
|
||||
)
|
||||
except Exception as notify_error:
|
||||
logger.error(
|
||||
"Ошибка отправки админ уведомления об активации промокода %s: %s",
|
||||
code,
|
||||
notify_error,
|
||||
)
|
||||
else:
|
||||
error_messages = {
|
||||
"not_found": texts.PROMOCODE_INVALID,
|
||||
@@ -106,13 +85,13 @@ async def process_promocode(
|
||||
"already_used_by_user": texts.PROMOCODE_USED,
|
||||
"server_error": texts.ERROR
|
||||
}
|
||||
|
||||
|
||||
error_text = error_messages.get(result["error"], texts.PROMOCODE_INVALID)
|
||||
await message.answer(
|
||||
error_text,
|
||||
reply_markup=get_back_keyboard(db_user.language)
|
||||
)
|
||||
|
||||
|
||||
await state.clear()
|
||||
|
||||
|
||||
|
||||
@@ -16,10 +16,7 @@ from app.services.payment_service import PaymentService
|
||||
from app.services.subscription_purchase_service import SubscriptionPurchaseService
|
||||
from app.utils.decorators import error_handler
|
||||
from app.states import SubscriptionStates
|
||||
from app.utils.subscription_utils import (
|
||||
get_display_subscription_link,
|
||||
resolve_simple_subscription_device_limit,
|
||||
)
|
||||
from app.utils.subscription_utils import get_display_subscription_link
|
||||
from app.utils.pricing_utils import compute_simple_subscription_price
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -38,17 +35,15 @@ async def start_simple_subscription_purchase(
|
||||
if not settings.SIMPLE_SUBSCRIPTION_ENABLED:
|
||||
await callback.answer("❌ Простая покупка подписки временно недоступна", show_alert=True)
|
||||
return
|
||||
|
||||
# Проверяем, есть ли у пользователя подписка
|
||||
|
||||
# Проверяем, есть ли у пользователя подписка (информируем, но не блокируем покупку)
|
||||
from app.database.crud.subscription import get_subscription_by_user_id
|
||||
current_subscription = await get_subscription_by_user_id(db, db_user.id)
|
||||
|
||||
device_limit = resolve_simple_subscription_device_limit()
|
||||
|
||||
|
||||
# Подготовим параметры простой подписки
|
||||
subscription_params = {
|
||||
"period_days": settings.SIMPLE_SUBSCRIPTION_PERIOD_DAYS,
|
||||
"device_limit": device_limit,
|
||||
"device_limit": settings.SIMPLE_SUBSCRIPTION_DEVICE_LIMIT,
|
||||
"traffic_limit_gb": settings.SIMPLE_SUBSCRIPTION_TRAFFIC_GB,
|
||||
"squad_uuid": settings.SIMPLE_SUBSCRIPTION_SQUAD_UUID
|
||||
}
|
||||
@@ -99,115 +94,63 @@ async def start_simple_subscription_purchase(
|
||||
can_pay_from_balance,
|
||||
)
|
||||
|
||||
# Проверяем, является ли у пользователя текущая подписка активной платной подпиской
|
||||
has_active_paid_subscription = False
|
||||
trial_notice = ""
|
||||
if current_subscription:
|
||||
if not getattr(current_subscription, "is_trial", False) and current_subscription.is_active:
|
||||
# Это платная активная подписка - требуем подтверждение
|
||||
has_active_paid_subscription = True
|
||||
elif getattr(current_subscription, "is_trial", False):
|
||||
# Это тестовая подписка
|
||||
try:
|
||||
days_left = max(0, (current_subscription.end_date - datetime.utcnow()).days)
|
||||
except Exception:
|
||||
days_left = 0
|
||||
key = "SIMPLE_SUBSCRIPTION_TRIAL_NOTICE_ACTIVE" if current_subscription.is_active else "SIMPLE_SUBSCRIPTION_TRIAL_NOTICE_TRIAL"
|
||||
trial_notice = texts.t(
|
||||
key,
|
||||
"ℹ️ У вас уже есть триальная подписка. Она истекает через {days} дн.",
|
||||
).format(days=days_left)
|
||||
if current_subscription and getattr(current_subscription, "is_trial", False):
|
||||
try:
|
||||
days_left = max(0, (current_subscription.end_date - datetime.utcnow()).days)
|
||||
except Exception:
|
||||
days_left = 0
|
||||
key = "SIMPLE_SUBSCRIPTION_TRIAL_NOTICE_ACTIVE" if current_subscription.is_active else "SIMPLE_SUBSCRIPTION_TRIAL_NOTICE_TRIAL"
|
||||
trial_notice = texts.t(
|
||||
key,
|
||||
"ℹ️ У вас уже есть триальная подписка. Она истекает через {days} дн.",
|
||||
).format(days=days_left)
|
||||
|
||||
server_label = _get_simple_subscription_server_label(
|
||||
texts,
|
||||
subscription_params,
|
||||
resolved_squad_uuid,
|
||||
)
|
||||
show_devices = settings.is_devices_selection_enabled()
|
||||
|
||||
message_lines = [
|
||||
"⚡ <b>Простая покупка подписки</b>",
|
||||
"",
|
||||
f"📅 Период: {subscription_params['period_days']} дней",
|
||||
]
|
||||
|
||||
if show_devices:
|
||||
message_lines.append(f"📱 Устройства: {subscription_params['device_limit']}")
|
||||
|
||||
message_lines.extend([
|
||||
f"📊 Трафик: {'Безлимит' if subscription_params['traffic_limit_gb'] == 0 else f'{subscription_params['traffic_limit_gb']} ГБ'}",
|
||||
f"🌍 Сервер: {server_label}",
|
||||
"",
|
||||
f"💰 Стоимость: {settings.format_price(price_kopeks)}",
|
||||
f"💳 Ваш баланс: {settings.format_price(user_balance_kopeks)}",
|
||||
"",
|
||||
])
|
||||
|
||||
# Если у пользователя уже есть активная платная подписка, требуем подтверждение
|
||||
if has_active_paid_subscription:
|
||||
# У пользователя уже есть активная платная подписка
|
||||
message_lines.append(
|
||||
"⚠️ У вас уже есть активная платная подписка. "
|
||||
"Покупка простой подписки изменит параметры вашей текущей подписки. "
|
||||
"Требуется подтверждение."
|
||||
message_text = (
|
||||
f"⚡ <b>Простая покупка подписки</b>\n\n"
|
||||
f"📅 Период: {subscription_params['period_days']} дней\n"
|
||||
f"📱 Устройства: {subscription_params['device_limit']}\n"
|
||||
f"📊 Трафик: {'Безлимит' if subscription_params['traffic_limit_gb'] == 0 else f'{subscription_params['traffic_limit_gb']} ГБ'}\n"
|
||||
f"🌍 Сервер: {server_label}\n\n"
|
||||
f"💰 Стоимость: {settings.format_price(price_kopeks)}\n"
|
||||
f"💳 Ваш баланс: {settings.format_price(user_balance_kopeks)}\n\n"
|
||||
+ (
|
||||
"Вы можете оплатить подписку с баланса или выбрать другой способ оплаты."
|
||||
if can_pay_from_balance
|
||||
else "Баланс пока недостаточный для мгновенной оплаты. Выберите подходящий способ оплаты:"
|
||||
)
|
||||
message_text = "\n".join(message_lines)
|
||||
)
|
||||
|
||||
# Клавиатура с подтверждением
|
||||
keyboard_rows = [
|
||||
[types.InlineKeyboardButton(
|
||||
text="✅ Подтвердить покупку",
|
||||
callback_data="simple_subscription_confirm_purchase"
|
||||
)],
|
||||
[types.InlineKeyboardButton(
|
||||
text=texts.BACK,
|
||||
callback_data="subscription_purchase"
|
||||
)]
|
||||
]
|
||||
keyboard = types.InlineKeyboardMarkup(inline_keyboard=keyboard_rows)
|
||||
else:
|
||||
# У пользователя нет активной платной подписки (или есть только пробная)
|
||||
# Показываем стандартный выбор метода оплаты
|
||||
if can_pay_from_balance:
|
||||
message_lines.append(
|
||||
"Вы можете оплатить подписку с баланса или выбрать другой способ оплаты."
|
||||
if trial_notice:
|
||||
message_text = f"{trial_notice}\n\n{message_text}"
|
||||
|
||||
methods_keyboard = _get_simple_subscription_payment_keyboard(db_user.language)
|
||||
keyboard_rows = []
|
||||
|
||||
if can_pay_from_balance:
|
||||
keyboard_rows.append([
|
||||
types.InlineKeyboardButton(
|
||||
text="✅ Оплатить с баланса",
|
||||
callback_data="simple_subscription_pay_with_balance",
|
||||
)
|
||||
else:
|
||||
message_lines.append(
|
||||
"Баланс пока недостаточный для мгновенной оплаты. Выберите подходящий способ оплаты:"
|
||||
)
|
||||
|
||||
message_text = "\n".join(message_lines)
|
||||
|
||||
if trial_notice:
|
||||
message_text = f"{trial_notice}\n\n{message_text}"
|
||||
])
|
||||
|
||||
methods_keyboard = _get_simple_subscription_payment_keyboard(db_user.language)
|
||||
keyboard_rows = []
|
||||
|
||||
if can_pay_from_balance:
|
||||
keyboard_rows.append([
|
||||
types.InlineKeyboardButton(
|
||||
text="✅ Оплатить с баланса",
|
||||
callback_data="simple_subscription_pay_with_balance",
|
||||
)
|
||||
])
|
||||
|
||||
keyboard_rows.extend(methods_keyboard.inline_keyboard)
|
||||
|
||||
keyboard = types.InlineKeyboardMarkup(inline_keyboard=keyboard_rows)
|
||||
keyboard_rows.extend(methods_keyboard.inline_keyboard)
|
||||
|
||||
keyboard = types.InlineKeyboardMarkup(inline_keyboard=keyboard_rows)
|
||||
|
||||
await callback.message.edit_text(
|
||||
message_text,
|
||||
reply_markup=keyboard,
|
||||
parse_mode="HTML"
|
||||
)
|
||||
|
||||
# Устанавливаем соответствующее состояние
|
||||
if has_active_paid_subscription:
|
||||
await state.set_state(SubscriptionStates.waiting_for_simple_subscription_confirmation)
|
||||
else:
|
||||
await state.set_state(SubscriptionStates.waiting_for_simple_subscription_payment_method)
|
||||
await state.set_state(SubscriptionStates.waiting_for_simple_subscription_payment_method)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@@ -375,15 +318,6 @@ async def handle_simple_subscription_pay_with_balance(
|
||||
await callback.answer("❌ Данные подписки устарели. Пожалуйста, начните сначала.", show_alert=True)
|
||||
return
|
||||
|
||||
# Проверяем, имеет ли пользователь активную платную подписку
|
||||
from app.database.crud.subscription import get_subscription_by_user_id
|
||||
current_subscription = await get_subscription_by_user_id(db, db_user.id)
|
||||
|
||||
if current_subscription and not getattr(current_subscription, "is_trial", False) and current_subscription.is_active:
|
||||
# У пользователя есть активная платная подписка - требуем подтверждение
|
||||
await callback.answer("⚠️ У вас уже есть активная платная подписка. Пожалуйста, подтвердите покупку.", show_alert=True)
|
||||
return
|
||||
|
||||
resolved_squad_uuid = await _ensure_simple_subscription_squad_uuid(
|
||||
db,
|
||||
state,
|
||||
@@ -441,10 +375,7 @@ async def handle_simple_subscription_pay_with_balance(
|
||||
existing_subscription = await get_subscription_by_user_id(db, db_user.id)
|
||||
|
||||
if existing_subscription:
|
||||
# Если подписка уже существует (платная или тестовая), продлеваем её
|
||||
# Сохраняем информацию о текущей подписке, особенно является ли она пробной
|
||||
was_trial = getattr(existing_subscription, "is_trial", False)
|
||||
|
||||
# Если подписка уже существует, продлеваем её
|
||||
subscription = await extend_subscription(
|
||||
db=db,
|
||||
subscription=existing_subscription,
|
||||
@@ -453,16 +384,6 @@ async def handle_simple_subscription_pay_with_balance(
|
||||
# Обновляем параметры подписки
|
||||
subscription.traffic_limit_gb = subscription_params["traffic_limit_gb"]
|
||||
subscription.device_limit = subscription_params["device_limit"]
|
||||
|
||||
# Если текущая подписка была пробной, и мы обновляем её
|
||||
# нужно изменить статус подписки
|
||||
if was_trial:
|
||||
from app.database.models import SubscriptionStatus
|
||||
# Переводим подписку из пробной в активную платную
|
||||
subscription.status = SubscriptionStatus.ACTIVE.value
|
||||
subscription.is_trial = False
|
||||
|
||||
# Устанавливаем новый выбранный сквад
|
||||
if resolved_squad_uuid:
|
||||
subscription.connected_squads = [resolved_squad_uuid]
|
||||
|
||||
@@ -512,28 +433,16 @@ async def handle_simple_subscription_pay_with_balance(
|
||||
subscription_params,
|
||||
resolved_squad_uuid,
|
||||
)
|
||||
show_devices = settings.is_devices_selection_enabled()
|
||||
|
||||
success_lines = [
|
||||
"✅ <b>Подписка успешно активирована!</b>",
|
||||
"",
|
||||
f"📅 Период: {subscription_params['period_days']} дней",
|
||||
]
|
||||
|
||||
if show_devices:
|
||||
success_lines.append(f"📱 Устройства: {subscription_params['device_limit']}")
|
||||
|
||||
success_lines.extend([
|
||||
f"📊 Трафик: {'Безлимит' if subscription_params['traffic_limit_gb'] == 0 else f'{subscription_params['traffic_limit_gb']} ГБ'}",
|
||||
f"🌍 Сервер: {server_label}",
|
||||
"",
|
||||
f"💰 Списано с баланса: {settings.format_price(price_kopeks)}",
|
||||
f"💳 Ваш баланс: {settings.format_price(db_user.balance_kopeks)}",
|
||||
"",
|
||||
"🔗 Для подключения перейдите в раздел 'Подключиться'",
|
||||
])
|
||||
|
||||
success_message = "\n".join(success_lines)
|
||||
success_message = (
|
||||
f"✅ <b>Подписка успешно активирована!</b>\n\n"
|
||||
f"📅 Период: {subscription_params['period_days']} дней\n"
|
||||
f"📱 Устройства: {subscription_params['device_limit']}\n"
|
||||
f"📊 Трафик: {'Безлимит' if subscription_params['traffic_limit_gb'] == 0 else f'{subscription_params['traffic_limit_gb']} ГБ'}\n"
|
||||
f"🌍 Сервер: {server_label}\n\n"
|
||||
f"💰 Списано с баланса: {settings.format_price(price_kopeks)}\n"
|
||||
f"💳 Ваш баланс: {settings.format_price(db_user.balance_kopeks)}\n\n"
|
||||
f"🔗 Для подключения перейдите в раздел 'Подключиться'"
|
||||
)
|
||||
|
||||
connect_mode = settings.CONNECT_BUTTON_MODE
|
||||
subscription_link = get_display_subscription_link(subscription)
|
||||
@@ -710,31 +619,19 @@ async def handle_simple_subscription_other_payment_methods(
|
||||
subscription_params,
|
||||
resolved_squad_uuid,
|
||||
)
|
||||
show_devices = settings.is_devices_selection_enabled()
|
||||
|
||||
message_lines = [
|
||||
"💳 <b>Оплата подписки</b>",
|
||||
"",
|
||||
f"📅 Период: {subscription_params['period_days']} дней",
|
||||
]
|
||||
|
||||
if show_devices:
|
||||
message_lines.append(f"📱 Устройства: {subscription_params['device_limit']}")
|
||||
|
||||
message_lines.extend([
|
||||
f"📊 Трафик: {'Безлимит' if subscription_params['traffic_limit_gb'] == 0 else f'{subscription_params['traffic_limit_gb']} ГБ'}",
|
||||
f"🌍 Сервер: {server_label}",
|
||||
"",
|
||||
f"💰 Стоимость: {settings.format_price(price_kopeks)}",
|
||||
"",
|
||||
(
|
||||
message_text = (
|
||||
f"💳 <b>Оплата подписки</b>\n\n"
|
||||
f"📅 Период: {subscription_params['period_days']} дней\n"
|
||||
f"📱 Устройства: {subscription_params['device_limit']}\n"
|
||||
f"📊 Трафик: {'Безлимит' if subscription_params['traffic_limit_gb'] == 0 else f'{subscription_params['traffic_limit_gb']} ГБ'}\n"
|
||||
f"🌍 Сервер: {server_label}\n\n"
|
||||
f"💰 Стоимость: {settings.format_price(price_kopeks)}\n\n"
|
||||
+ (
|
||||
"Вы можете оплатить подписку с баланса или выбрать другой способ оплаты:"
|
||||
if can_pay_from_balance
|
||||
else "Выберите подходящий способ оплаты:"
|
||||
),
|
||||
])
|
||||
|
||||
message_text = "\n".join(message_lines)
|
||||
)
|
||||
)
|
||||
|
||||
base_keyboard = _get_simple_subscription_payment_keyboard(db_user.language)
|
||||
keyboard_rows = []
|
||||
@@ -776,15 +673,6 @@ async def handle_simple_subscription_payment_method(
|
||||
await callback.answer("❌ Данные подписки устарели. Пожалуйста, начните сначала.", show_alert=True)
|
||||
return
|
||||
|
||||
# Проверяем, имеет ли пользователь активную платную подписку
|
||||
from app.database.crud.subscription import get_subscription_by_user_id
|
||||
current_subscription = await get_subscription_by_user_id(db, db_user.id)
|
||||
|
||||
if current_subscription and not getattr(current_subscription, "is_trial", False) and current_subscription.is_active:
|
||||
# У пользователя есть активная платная подписка - показываем сообщение
|
||||
await callback.answer("⚠️ У вас уже есть активная платная подписка. Пожалуйста, подтвердите покупку через главное меню.", show_alert=True)
|
||||
return
|
||||
|
||||
payment_method = callback.data.replace("simple_subscription_", "")
|
||||
|
||||
try:
|
||||
@@ -966,25 +854,14 @@ async def handle_simple_subscription_payment_method(
|
||||
keyboard = types.InlineKeyboardMarkup(inline_keyboard=keyboard_buttons)
|
||||
|
||||
# Подготавливаем текст сообщения
|
||||
show_devices = settings.is_devices_selection_enabled()
|
||||
|
||||
message_lines = [
|
||||
"💳 <b>Оплата подписки через YooKassa</b>",
|
||||
"",
|
||||
f"📅 Период: {subscription_params['period_days']} дней",
|
||||
]
|
||||
|
||||
if show_devices:
|
||||
message_lines.append(f"📱 Устройства: {subscription_params['device_limit']}")
|
||||
|
||||
message_lines.extend([
|
||||
f"📊 Трафик: {'Безлимит' if subscription_params['traffic_limit_gb'] == 0 else f'{subscription_params['traffic_limit_gb']} ГБ'}",
|
||||
f"💰 Сумма: {settings.format_price(price_kopeks)}",
|
||||
f"🆔 ID платежа: {payment_result['yookassa_payment_id'][:8]}...",
|
||||
"",
|
||||
])
|
||||
|
||||
message_text = "\n".join(message_lines)
|
||||
message_text = (
|
||||
f"💳 <b>Оплата подписки через YooKassa</b>\n\n"
|
||||
f"📅 Период: {subscription_params['period_days']} дней\n"
|
||||
f"📱 Устройства: {subscription_params['device_limit']}\n"
|
||||
f"📊 Трафик: {'Безлимит' if subscription_params['traffic_limit_gb'] == 0 else f'{subscription_params['traffic_limit_gb']} ГБ'}\n"
|
||||
f"💰 Сумма: {settings.format_price(price_kopeks)}\n"
|
||||
f"🆔 ID платежа: {payment_result['yookassa_payment_id'][:8]}...\n\n"
|
||||
)
|
||||
|
||||
# Добавляем инструкции в зависимости от доступных способов оплаты
|
||||
if not confirmation_url:
|
||||
@@ -1655,18 +1532,8 @@ async def check_simple_pal24_payment_status(
|
||||
if not isinstance(links_meta, dict):
|
||||
links_meta = {}
|
||||
|
||||
links_info = status_info.get("links") or {}
|
||||
sbp_link = (
|
||||
links_info.get("sbp")
|
||||
or links_meta.get("sbp")
|
||||
or status_info.get("sbp_url")
|
||||
or payment.link_url
|
||||
)
|
||||
card_link = (
|
||||
links_info.get("card")
|
||||
or links_meta.get("card")
|
||||
or status_info.get("card_url")
|
||||
)
|
||||
sbp_link = links_meta.get("sbp") or payment.link_url
|
||||
card_link = links_meta.get("card")
|
||||
if not card_link and payment.link_page_url and payment.link_page_url != sbp_link:
|
||||
card_link = payment.link_page_url
|
||||
|
||||
@@ -1699,45 +1566,8 @@ async def check_simple_pal24_payment_status(
|
||||
f"❌ Платеж не завершен корректно. Обратитесь в {settings.get_support_contact_display()}",
|
||||
]
|
||||
|
||||
pay_rows: list[list[types.InlineKeyboardButton]] = []
|
||||
|
||||
if not payment.is_paid and payment.status in {"NEW", "PROCESS"}:
|
||||
default_sbp_text = texts.t(
|
||||
"PAL24_SBP_PAY_BUTTON",
|
||||
"🏦 Оплатить через PayPalych (СБП)",
|
||||
)
|
||||
sbp_button_text = settings.get_pal24_sbp_button_text(default_sbp_text)
|
||||
|
||||
if sbp_link and settings.is_pal24_sbp_button_visible():
|
||||
pay_rows.append(
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=sbp_button_text,
|
||||
url=sbp_link,
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
default_card_text = texts.t(
|
||||
"PAL24_CARD_PAY_BUTTON",
|
||||
"💳 Оплатить банковской картой (PayPalych)",
|
||||
)
|
||||
card_button_text = settings.get_pal24_card_button_text(default_card_text)
|
||||
|
||||
if card_link and settings.is_pal24_card_button_visible():
|
||||
if not pay_rows or pay_rows[-1][0].url != card_link:
|
||||
pay_rows.append(
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=card_button_text,
|
||||
url=card_link,
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
keyboard = types.InlineKeyboardMarkup(
|
||||
inline_keyboard=pay_rows
|
||||
+ [
|
||||
inline_keyboard=[
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.t("CHECK_STATUS_BUTTON", "📊 Проверить статус"),
|
||||
@@ -2063,281 +1893,6 @@ async def check_simple_wata_payment_status(
|
||||
parse_mode="HTML",
|
||||
)
|
||||
|
||||
|
||||
@error_handler
|
||||
async def confirm_simple_subscription_purchase(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
state: FSMContext,
|
||||
db: AsyncSession,
|
||||
):
|
||||
"""Обрабатывает подтверждение простой покупки подписки при наличии активной платной подписки."""
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
data = await state.get_data()
|
||||
subscription_params = data.get("subscription_params", {})
|
||||
|
||||
if not subscription_params:
|
||||
await callback.answer("❌ Данные подписки устарели. Пожалуйста, начните сначала.", show_alert=True)
|
||||
return
|
||||
|
||||
resolved_squad_uuid = await _ensure_simple_subscription_squad_uuid(
|
||||
db,
|
||||
state,
|
||||
subscription_params,
|
||||
user_id=db_user.id,
|
||||
state_data=data,
|
||||
)
|
||||
|
||||
# Рассчитываем цену подписки
|
||||
price_kopeks, price_breakdown = await _calculate_simple_subscription_price(
|
||||
db,
|
||||
subscription_params,
|
||||
user=db_user,
|
||||
resolved_squad_uuid=resolved_squad_uuid,
|
||||
)
|
||||
total_required = price_kopeks
|
||||
logger.warning(
|
||||
"SIMPLE_SUBSCRIPTION_DEBUG_CONFIRM | user=%s | period=%s | base=%s | traffic=%s | devices=%s | servers=%s | discount=%s | total_required=%s | balance=%s",
|
||||
db_user.id,
|
||||
subscription_params["period_days"],
|
||||
price_breakdown.get("base_price", 0),
|
||||
price_breakdown.get("traffic_price", 0),
|
||||
price_breakdown.get("devices_price", 0),
|
||||
price_breakdown.get("servers_price", 0),
|
||||
price_breakdown.get("total_discount", 0),
|
||||
total_required,
|
||||
getattr(db_user, "balance_kopeks", 0),
|
||||
)
|
||||
|
||||
# Проверяем баланс пользователя
|
||||
user_balance_kopeks = getattr(db_user, "balance_kopeks", 0)
|
||||
|
||||
if user_balance_kopeks < total_required:
|
||||
await callback.answer("❌ Недостаточно средств на балансе для оплаты подписки", show_alert=True)
|
||||
return
|
||||
|
||||
try:
|
||||
# Списываем средства с баланса пользователя
|
||||
from app.database.crud.user import subtract_user_balance
|
||||
success = await subtract_user_balance(
|
||||
db,
|
||||
db_user,
|
||||
price_kopeks,
|
||||
f"Оплата подписки на {subscription_params['period_days']} дней",
|
||||
consume_promo_offer=False,
|
||||
)
|
||||
|
||||
if not success:
|
||||
await callback.answer("❌ Ошибка списания средств с баланса", show_alert=True)
|
||||
return
|
||||
|
||||
# Проверяем, есть ли у пользователя уже подписка
|
||||
from app.database.crud.subscription import get_subscription_by_user_id, extend_subscription
|
||||
|
||||
existing_subscription = await get_subscription_by_user_id(db, db_user.id)
|
||||
|
||||
if existing_subscription:
|
||||
# Если подписка уже существует, продлеваем её
|
||||
# Сохраняем информацию о текущей подписке, особенно является ли она пробной
|
||||
was_trial = getattr(existing_subscription, "is_trial", False)
|
||||
|
||||
subscription = await extend_subscription(
|
||||
db=db,
|
||||
subscription=existing_subscription,
|
||||
days=subscription_params["period_days"]
|
||||
)
|
||||
# Обновляем параметры подписки
|
||||
subscription.traffic_limit_gb = subscription_params["traffic_limit_gb"]
|
||||
subscription.device_limit = subscription_params["device_limit"]
|
||||
|
||||
# Если текущая подписка была пробной, и мы обновляем её
|
||||
# нужно изменить статус подписки
|
||||
if was_trial:
|
||||
from app.database.models import SubscriptionStatus
|
||||
# Переводим подписку из пробной в активную платную
|
||||
subscription.status = SubscriptionStatus.ACTIVE.value
|
||||
subscription.is_trial = False
|
||||
|
||||
# Устанавливаем новый выбранный сквад
|
||||
if resolved_squad_uuid:
|
||||
subscription.connected_squads = [resolved_squad_uuid]
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(subscription)
|
||||
else:
|
||||
# Если подписки нет, создаём новую
|
||||
from app.database.crud.subscription import create_paid_subscription
|
||||
subscription = await create_paid_subscription(
|
||||
db=db,
|
||||
user_id=db_user.id,
|
||||
duration_days=subscription_params["period_days"],
|
||||
traffic_limit_gb=subscription_params["traffic_limit_gb"],
|
||||
device_limit=subscription_params["device_limit"],
|
||||
connected_squads=[resolved_squad_uuid] if resolved_squad_uuid else [],
|
||||
update_server_counters=True,
|
||||
)
|
||||
|
||||
if not subscription:
|
||||
# Возвращаем средства на баланс в случае ошибки
|
||||
from app.services.payment_service import add_user_balance
|
||||
await add_user_balance(
|
||||
db,
|
||||
db_user.id,
|
||||
price_kopeks,
|
||||
f"Возврат средств за неудавшуюся подписку на {subscription_params['period_days']} дней",
|
||||
)
|
||||
await callback.answer("❌ Ошибка создания подписки. Средства возвращены на баланс.", show_alert=True)
|
||||
return
|
||||
|
||||
# Обновляем баланс пользователя
|
||||
await db.refresh(db_user)
|
||||
|
||||
# Обновляем или создаём ссылку подписки в RemnaWave
|
||||
try:
|
||||
from app.services.subscription_service import SubscriptionService
|
||||
subscription_service = SubscriptionService()
|
||||
remnawave_user = await subscription_service.create_remnawave_user(db, subscription)
|
||||
if remnawave_user:
|
||||
await db.refresh(subscription)
|
||||
except Exception as sync_error:
|
||||
logger.error(f"Ошибка синхронизации подписки с RemnaWave для пользователя {db_user.id}: {sync_error}", exc_info=True)
|
||||
|
||||
# Отправляем уведомление об успешной покупке
|
||||
server_label = _get_simple_subscription_server_label(
|
||||
texts,
|
||||
subscription_params,
|
||||
resolved_squad_uuid,
|
||||
)
|
||||
show_devices = settings.is_devices_selection_enabled()
|
||||
|
||||
success_lines = [
|
||||
"✅ <b>Подписка успешно активирована!</b>",
|
||||
"",
|
||||
f"📅 Период: {subscription_params['period_days']} дней",
|
||||
]
|
||||
|
||||
if show_devices:
|
||||
success_lines.append(f"📱 Устройства: {subscription_params['device_limit']}")
|
||||
|
||||
success_lines.extend([
|
||||
f"📊 Трафик: {'Безлимит' if subscription_params['traffic_limit_gb'] == 0 else f'{subscription_params['traffic_limit_gb']} ГБ'}",
|
||||
f"🌍 Сервер: {server_label}",
|
||||
"",
|
||||
f"💰 Списано с баланса: {settings.format_price(price_kopeks)}",
|
||||
f"💳 Ваш баланс: {settings.format_price(db_user.balance_kopeks)}",
|
||||
"",
|
||||
"🔗 Для подключения перейдите в раздел 'Подключиться'",
|
||||
])
|
||||
|
||||
success_message = "\n".join(success_lines)
|
||||
|
||||
connect_mode = settings.CONNECT_BUTTON_MODE
|
||||
subscription_link = get_display_subscription_link(subscription)
|
||||
connect_button_text = texts.t("CONNECT_BUTTON", "🔗 Подключиться")
|
||||
|
||||
def _fallback_connect_button() -> types.InlineKeyboardButton:
|
||||
return types.InlineKeyboardButton(
|
||||
text=connect_button_text,
|
||||
callback_data="subscription_connect",
|
||||
)
|
||||
|
||||
if connect_mode == "miniapp_subscription":
|
||||
if subscription_link:
|
||||
connect_row = [
|
||||
types.InlineKeyboardButton(
|
||||
text=connect_button_text,
|
||||
web_app=types.WebAppInfo(url=subscription_link),
|
||||
)
|
||||
]
|
||||
else:
|
||||
connect_row = [_fallback_connect_button()]
|
||||
elif connect_mode == "miniapp_custom":
|
||||
custom_url = settings.MINIAPP_CUSTOM_URL
|
||||
if custom_url:
|
||||
connect_row = [
|
||||
types.InlineKeyboardButton(
|
||||
text=connect_button_text,
|
||||
web_app=types.WebAppInfo(url=custom_url),
|
||||
)
|
||||
]
|
||||
else:
|
||||
connect_row = [_fallback_connect_button()]
|
||||
elif connect_mode == "link":
|
||||
if subscription_link:
|
||||
connect_row = [
|
||||
types.InlineKeyboardButton(
|
||||
text=connect_button_text,
|
||||
url=subscription_link,
|
||||
)
|
||||
]
|
||||
else:
|
||||
connect_row = [_fallback_connect_button()]
|
||||
elif connect_mode == "happ_cryptolink":
|
||||
if subscription_link:
|
||||
connect_row = [
|
||||
types.InlineKeyboardButton(
|
||||
text=connect_button_text,
|
||||
callback_data="open_subscription_link",
|
||||
)
|
||||
]
|
||||
else:
|
||||
connect_row = [_fallback_connect_button()]
|
||||
else:
|
||||
connect_row = [_fallback_connect_button()]
|
||||
|
||||
keyboard_rows = [connect_row]
|
||||
|
||||
happ_row = get_happ_download_button_row(texts)
|
||||
if happ_row:
|
||||
keyboard_rows.append(happ_row)
|
||||
|
||||
keyboard_rows.append(
|
||||
[types.InlineKeyboardButton(text="🏠 Главное меню", callback_data="back_to_menu")]
|
||||
)
|
||||
|
||||
keyboard = types.InlineKeyboardMarkup(inline_keyboard=keyboard_rows)
|
||||
|
||||
await callback.message.edit_text(
|
||||
success_message,
|
||||
reply_markup=keyboard,
|
||||
parse_mode="HTML"
|
||||
)
|
||||
|
||||
# Отправляем уведомление админам
|
||||
try:
|
||||
from app.services.admin_notification_service import AdminNotificationService
|
||||
notification_service = AdminNotificationService(callback.bot)
|
||||
await notification_service.send_subscription_purchase_notification(
|
||||
db,
|
||||
db_user,
|
||||
subscription,
|
||||
None, # transaction
|
||||
subscription_params["period_days"],
|
||||
False, # was_trial_conversion
|
||||
amount_kopeks=price_kopeks,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка отправки уведомления админам о покупке: {e}")
|
||||
|
||||
await state.clear()
|
||||
await callback.answer()
|
||||
|
||||
logger.info(f"Пользователь {db_user.telegram_id} успешно купил подписку с баланса на {price_kopeks/100}₽")
|
||||
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
"Ошибка подтверждения простой подписки с баланса для пользователя %s: %s",
|
||||
db_user.id,
|
||||
error,
|
||||
exc_info=True,
|
||||
)
|
||||
await callback.answer(
|
||||
"❌ Ошибка оплаты подписки. Попробуйте позже или обратитесь в поддержку.",
|
||||
show_alert=True,
|
||||
)
|
||||
await state.clear()
|
||||
|
||||
def register_simple_subscription_handlers(dp):
|
||||
"""Регистрирует обработчики простой покупки подписки."""
|
||||
|
||||
@@ -2346,11 +1901,6 @@ def register_simple_subscription_handlers(dp):
|
||||
F.data == "simple_subscription_purchase"
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
confirm_simple_subscription_purchase,
|
||||
F.data == "simple_subscription_confirm_purchase"
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
handle_simple_subscription_pay_with_balance,
|
||||
F.data == "simple_subscription_pay_with_balance"
|
||||
|
||||
@@ -130,10 +130,6 @@ async def handle_successful_payment(
|
||||
"⭐ Потрачено звезд: {stars_spent}\n"
|
||||
"💰 Зачислено на баланс: {amount} ₽\n"
|
||||
"🆔 ID транзакции: {transaction_id}...\n\n"
|
||||
"⚠️ <b>Важно:</b> Пополнение баланса не активирует подписку автоматически. "
|
||||
"Обязательно активируйте подписку отдельно!\n\n"
|
||||
"🔄 При наличии сохранённой корзины подписки и включенной автопокупке, "
|
||||
"подписка будет приобретена автоматически после пополнения баланса.\n\n"
|
||||
"Спасибо за пополнение! 🚀",
|
||||
).format(
|
||||
stars_spent=payment.total_amount,
|
||||
|
||||
+81
-186
@@ -37,7 +37,6 @@ from app.utils.promo_offer import (
|
||||
build_promo_offer_hint,
|
||||
build_test_access_hint,
|
||||
)
|
||||
from app.utils.timezone import format_local_datetime
|
||||
from app.database.crud.user_message import get_random_active_message
|
||||
from app.database.crud.subscription import decrement_subscription_server_counts
|
||||
|
||||
@@ -45,17 +44,6 @@ from app.database.crud.subscription import decrement_subscription_server_counts
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _calculate_subscription_flags(subscription):
|
||||
if not subscription:
|
||||
return False, False
|
||||
|
||||
actual_status = getattr(subscription, "actual_status", None)
|
||||
has_active_subscription = actual_status in {"active", "trial"}
|
||||
subscription_is_active = bool(getattr(subscription, "is_active", False))
|
||||
|
||||
return has_active_subscription, subscription_is_active
|
||||
|
||||
|
||||
async def _apply_campaign_bonus_if_needed(
|
||||
db: AsyncSession,
|
||||
user,
|
||||
@@ -100,15 +88,15 @@ async def handle_potential_referral_code(
|
||||
db: AsyncSession
|
||||
):
|
||||
current_state = await state.get_state()
|
||||
logger.info(f"🔍 REFERRAL/PROMO CHECK: Проверка сообщения '{message.text}' в состоянии {current_state}")
|
||||
|
||||
logger.info(f"🔍 REFERRAL CHECK: Проверка сообщения '{message.text}' в состоянии {current_state}")
|
||||
|
||||
if current_state not in [
|
||||
RegistrationStates.waiting_for_rules_accept.state,
|
||||
RegistrationStates.waiting_for_referral_code.state,
|
||||
None
|
||||
None
|
||||
]:
|
||||
return False
|
||||
|
||||
|
||||
user = await get_user_by_telegram_id(db, message.from_user.id)
|
||||
if user and user.status == UserStatus.ACTIVE.value:
|
||||
return False
|
||||
@@ -125,73 +113,37 @@ async def handle_potential_referral_code(
|
||||
if len(potential_code) < 4 or len(potential_code) > 20:
|
||||
return False
|
||||
|
||||
# Сначала проверяем реферальный код
|
||||
referrer = await get_user_by_referral_code(db, potential_code)
|
||||
if referrer:
|
||||
data['referral_code'] = potential_code
|
||||
data['referrer_id'] = referrer.id
|
||||
await state.set_data(data)
|
||||
|
||||
await message.answer(texts.t("REFERRAL_CODE_ACCEPTED", "✅ Реферальный код принят!"))
|
||||
logger.info(f"✅ Реферальный код {potential_code} применен для пользователя {message.from_user.id}")
|
||||
|
||||
if current_state != RegistrationStates.waiting_for_referral_code.state:
|
||||
language = data.get('language', DEFAULT_LANGUAGE)
|
||||
texts = get_texts(language)
|
||||
|
||||
rules_text = await get_rules(language)
|
||||
await message.answer(
|
||||
rules_text,
|
||||
reply_markup=get_rules_keyboard(language)
|
||||
)
|
||||
await state.set_state(RegistrationStates.waiting_for_rules_accept)
|
||||
logger.info("📋 Правила отправлены после ввода реферального кода")
|
||||
else:
|
||||
await complete_registration(message, state, db)
|
||||
|
||||
if not referrer:
|
||||
await message.answer(texts.t(
|
||||
"REFERRAL_CODE_INVALID_HELP",
|
||||
"❌ Неверный реферальный код.\n\n"
|
||||
"💡 Если у вас есть реферальный код, убедитесь что он введен правильно.\n"
|
||||
"⏭️ Для продолжения регистрации без реферального кода используйте команду /start",
|
||||
))
|
||||
return True
|
||||
|
||||
# Если реферальный код не найден, проверяем промокод
|
||||
from app.database.crud.promocode import check_promocode_validity
|
||||
|
||||
promocode_check = await check_promocode_validity(db, potential_code)
|
||||
|
||||
if promocode_check["valid"]:
|
||||
# Промокод валиден - сохраняем его в state для активации после создания пользователя
|
||||
data['promocode'] = potential_code
|
||||
await state.set_data(data)
|
||||
data['referral_code'] = potential_code
|
||||
data['referrer_id'] = referrer.id
|
||||
await state.set_data(data)
|
||||
|
||||
await message.answer(texts.t("REFERRAL_CODE_ACCEPTED", "✅ Реферальный код принят!"))
|
||||
logger.info(f"✅ Реферальный код {potential_code} применен для пользователя {message.from_user.id}")
|
||||
|
||||
if current_state != RegistrationStates.waiting_for_referral_code.state:
|
||||
language = data.get('language', DEFAULT_LANGUAGE)
|
||||
texts = get_texts(language)
|
||||
|
||||
rules_text = await get_rules(language)
|
||||
await message.answer(
|
||||
texts.t(
|
||||
"PROMOCODE_ACCEPTED_WILL_ACTIVATE",
|
||||
"✅ Промокод принят! Он будет активирован после завершения регистрации."
|
||||
)
|
||||
rules_text,
|
||||
reply_markup=get_rules_keyboard(language)
|
||||
)
|
||||
logger.info(f"✅ Промокод {potential_code} сохранен для активации для пользователя {message.from_user.id}")
|
||||
|
||||
if current_state != RegistrationStates.waiting_for_referral_code.state:
|
||||
language = data.get('language', DEFAULT_LANGUAGE)
|
||||
texts = get_texts(language)
|
||||
|
||||
rules_text = await get_rules(language)
|
||||
await message.answer(
|
||||
rules_text,
|
||||
reply_markup=get_rules_keyboard(language)
|
||||
)
|
||||
await state.set_state(RegistrationStates.waiting_for_rules_accept)
|
||||
logger.info("📋 Правила отправлены после принятия промокода")
|
||||
else:
|
||||
await complete_registration(message, state, db)
|
||||
|
||||
return True
|
||||
|
||||
# Ни реферальный код, ни промокод не найдены
|
||||
await message.answer(texts.t(
|
||||
"REFERRAL_OR_PROMO_CODE_INVALID_HELP",
|
||||
"❌ Неверный реферальный код или промокод.\n\n"
|
||||
"💡 Если у вас есть реферальный код или промокод, убедитесь что он введен правильно.\n"
|
||||
"⏭️ Для продолжения регистрации без кода используйте команду /start",
|
||||
))
|
||||
await state.set_state(RegistrationStates.waiting_for_rules_accept)
|
||||
logger.info("📋 Правила отправлены после ввода реферального кода")
|
||||
else:
|
||||
await complete_registration(message, state, db)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
@@ -272,11 +224,7 @@ async def cmd_start(message: types.Message, state: FSMContext, db: AsyncSession,
|
||||
logger.info(f"🚀 START: Обработка /start от {message.from_user.id}")
|
||||
|
||||
data = await state.get_data() or {}
|
||||
had_pending_payload = "pending_start_payload" in data
|
||||
pending_start_payload = data.pop("pending_start_payload", None)
|
||||
had_campaign_notification_flag = "campaign_notification_sent" in data
|
||||
campaign_notification_sent = data.pop("campaign_notification_sent", False)
|
||||
state_needs_update = had_pending_payload or had_campaign_notification_flag
|
||||
|
||||
referral_code = None
|
||||
campaign = None
|
||||
@@ -292,7 +240,7 @@ async def cmd_start(message: types.Message, state: FSMContext, db: AsyncSession,
|
||||
pending_start_payload,
|
||||
)
|
||||
|
||||
if state_needs_update:
|
||||
if pending_start_payload is not None:
|
||||
await state.set_data(data)
|
||||
|
||||
if start_parameter:
|
||||
@@ -318,7 +266,7 @@ async def cmd_start(message: types.Message, state: FSMContext, db: AsyncSession,
|
||||
|
||||
user = db_user if db_user else await get_user_by_telegram_id(db, message.from_user.id)
|
||||
|
||||
if campaign and not campaign_notification_sent:
|
||||
if campaign:
|
||||
try:
|
||||
notification_service = AdminNotificationService(message.bot)
|
||||
await notification_service.send_campaign_link_visit_notification(
|
||||
@@ -390,9 +338,11 @@ async def cmd_start(message: types.Message, state: FSMContext, db: AsyncSession,
|
||||
f"Ошибка отправки уведомления о рекламной кампании: {e}"
|
||||
)
|
||||
|
||||
has_active_subscription, subscription_is_active = _calculate_subscription_flags(
|
||||
user.subscription
|
||||
)
|
||||
has_active_subscription = user.subscription is not None
|
||||
subscription_is_active = False
|
||||
|
||||
if user.subscription:
|
||||
subscription_is_active = user.subscription.is_active
|
||||
|
||||
menu_text = await get_main_menu_text(user, texts, db)
|
||||
|
||||
@@ -728,57 +678,31 @@ async def process_rules_accept(
|
||||
|
||||
|
||||
async def process_referral_code_input(
|
||||
message: types.Message,
|
||||
state: FSMContext,
|
||||
message: types.Message,
|
||||
state: FSMContext,
|
||||
db: AsyncSession
|
||||
):
|
||||
|
||||
logger.info(f"🎫 REFERRAL/PROMO: Обработка кода: {message.text}")
|
||||
|
||||
|
||||
logger.info(f"🎫 REFERRAL: Обработка реферального кода: {message.text}")
|
||||
|
||||
data = await state.get_data() or {}
|
||||
language = data.get('language', DEFAULT_LANGUAGE)
|
||||
texts = get_texts(language)
|
||||
|
||||
code = message.text.strip()
|
||||
referral_code = message.text.strip()
|
||||
|
||||
# Сначала проверяем, является ли это реферальным кодом
|
||||
referrer = await get_user_by_referral_code(db, code)
|
||||
referrer = await get_user_by_referral_code(db, referral_code)
|
||||
if referrer:
|
||||
data['referrer_id'] = referrer.id
|
||||
await state.set_data(data)
|
||||
await message.answer(texts.t("REFERRAL_CODE_ACCEPTED", "✅ Реферальный код принят!"))
|
||||
logger.info(f"✅ Реферальный код применен: {code}")
|
||||
await complete_registration(message, state, db)
|
||||
logger.info(f"✅ Реферальный код применен")
|
||||
else:
|
||||
await message.answer(texts.t("REFERRAL_CODE_INVALID", "❌ Неверный реферальный код"))
|
||||
logger.info(f"❌ Неверный реферальный код")
|
||||
return
|
||||
|
||||
# Если реферальный код не найден, проверяем промокод
|
||||
from app.database.crud.promocode import check_promocode_validity
|
||||
|
||||
promocode_check = await check_promocode_validity(db, code)
|
||||
|
||||
if promocode_check["valid"]:
|
||||
# Промокод валиден - сохраняем его в state для активации после создания пользователя
|
||||
data['promocode'] = code
|
||||
await state.set_data(data)
|
||||
await message.answer(
|
||||
texts.t(
|
||||
"PROMOCODE_ACCEPTED_WILL_ACTIVATE",
|
||||
"✅ Промокод принят! Он будет активирован после завершения регистрации."
|
||||
)
|
||||
)
|
||||
logger.info(f"✅ Промокод сохранен для активации: {code}")
|
||||
await complete_registration(message, state, db)
|
||||
return
|
||||
|
||||
# Ни реферальный код, ни промокод не найдены
|
||||
await message.answer(
|
||||
texts.t(
|
||||
"REFERRAL_OR_PROMO_CODE_INVALID",
|
||||
"❌ Неверный реферальный код или промокод"
|
||||
)
|
||||
)
|
||||
logger.info(f"❌ Неверный код (ни реферальный, ни промокод): {code}")
|
||||
return
|
||||
|
||||
await complete_registration(message, state, db)
|
||||
|
||||
|
||||
async def process_referral_code_skip(
|
||||
@@ -837,9 +761,11 @@ async def complete_registration_from_callback(
|
||||
|
||||
await db.refresh(existing_user, ['subscription'])
|
||||
|
||||
has_active_subscription, subscription_is_active = _calculate_subscription_flags(
|
||||
existing_user.subscription
|
||||
)
|
||||
has_active_subscription = existing_user.subscription is not None
|
||||
subscription_is_active = False
|
||||
|
||||
if existing_user.subscription:
|
||||
subscription_is_active = existing_user.subscription.is_active
|
||||
|
||||
menu_text = await get_main_menu_text(existing_user, texts, db)
|
||||
|
||||
@@ -1017,9 +943,11 @@ async def complete_registration_from_callback(
|
||||
else:
|
||||
logger.info(f"ℹ️ Приветственные сообщения отключены, показываем главное меню для пользователя {user.telegram_id}")
|
||||
|
||||
has_active_subscription, subscription_is_active = _calculate_subscription_flags(
|
||||
getattr(user, "subscription", None)
|
||||
)
|
||||
has_active_subscription = bool(getattr(user, "subscription", None))
|
||||
subscription_is_active = False
|
||||
|
||||
if getattr(user, "subscription", None):
|
||||
subscription_is_active = user.subscription.is_active
|
||||
|
||||
menu_text = await get_main_menu_text(user, texts, db)
|
||||
|
||||
@@ -1091,9 +1019,11 @@ async def complete_registration(
|
||||
|
||||
await db.refresh(existing_user, ['subscription'])
|
||||
|
||||
has_active_subscription, subscription_is_active = _calculate_subscription_flags(
|
||||
existing_user.subscription
|
||||
)
|
||||
has_active_subscription = existing_user.subscription is not None
|
||||
subscription_is_active = False
|
||||
|
||||
if existing_user.subscription:
|
||||
subscription_is_active = existing_user.subscription.is_active
|
||||
|
||||
menu_text = await get_main_menu_text(existing_user, texts, db)
|
||||
|
||||
@@ -1219,29 +1149,6 @@ async def complete_registration(
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при обработке реферальной регистрации: {e}")
|
||||
|
||||
# Активируем промокод если был сохранен в state
|
||||
promocode_to_activate = data.get('promocode')
|
||||
if promocode_to_activate:
|
||||
try:
|
||||
from app.handlers.promocode import activate_promocode_for_registration
|
||||
|
||||
promocode_result = await activate_promocode_for_registration(
|
||||
db, user.id, promocode_to_activate, message.bot
|
||||
)
|
||||
|
||||
if promocode_result["success"]:
|
||||
await message.answer(
|
||||
texts.t(
|
||||
"PROMOCODE_ACTIVATED_AT_REGISTRATION",
|
||||
"✅ Промокод активирован!\n\n{description}"
|
||||
).format(description=promocode_result["description"])
|
||||
)
|
||||
logger.info(f"✅ Промокод {promocode_to_activate} активирован для пользователя {user.id}")
|
||||
else:
|
||||
logger.warning(f"⚠️ Не удалось активировать промокод {promocode_to_activate}: {promocode_result.get('error')}")
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Ошибка при активации промокода {promocode_to_activate}: {e}")
|
||||
|
||||
campaign_message = await _apply_campaign_bonus_if_needed(db, user, data, texts)
|
||||
|
||||
try:
|
||||
@@ -1294,9 +1201,11 @@ async def complete_registration(
|
||||
else:
|
||||
logger.info(f"ℹ️ Приветственные сообщения отключены, показываем главное меню для пользователя {user.telegram_id}")
|
||||
|
||||
has_active_subscription, subscription_is_active = _calculate_subscription_flags(
|
||||
getattr(user, "subscription", None)
|
||||
)
|
||||
has_active_subscription = bool(getattr(user, "subscription", None))
|
||||
subscription_is_active = False
|
||||
|
||||
if getattr(user, "subscription", None):
|
||||
subscription_is_active = user.subscription.is_active
|
||||
|
||||
menu_text = await get_main_menu_text(user, texts, db)
|
||||
|
||||
@@ -1349,43 +1258,30 @@ def _get_subscription_status(user, texts):
|
||||
return texts.t("SUBSCRIPTION_NONE", "Нет активной подписки")
|
||||
|
||||
subscription = user.subscription
|
||||
actual_status = getattr(subscription, "actual_status", None)
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
end_date = getattr(subscription, "end_date", None)
|
||||
end_date_display = format_local_datetime(end_date, "%d.%m.%Y") if end_date else None
|
||||
current_time = datetime.utcnow()
|
||||
|
||||
if actual_status == "disabled":
|
||||
return texts.t("SUB_STATUS_DISABLED", "⚫ Отключена")
|
||||
|
||||
if actual_status == "pending":
|
||||
return texts.t("SUB_STATUS_PENDING", "⏳ Ожидает активации")
|
||||
|
||||
if actual_status == "expired" or (end_date and end_date <= current_time):
|
||||
if end_date_display:
|
||||
return texts.t(
|
||||
"SUB_STATUS_EXPIRED",
|
||||
"🔴 Истекла\n📅 {end_date}",
|
||||
).format(end_date=end_date_display)
|
||||
return texts.t("SUBSCRIPTION_STATUS_EXPIRED", "🔴 Истекла")
|
||||
if end_date and end_date <= current_time:
|
||||
return texts.t(
|
||||
"SUB_STATUS_EXPIRED",
|
||||
"🔴 Истекла\n📅 {end_date}",
|
||||
).format(end_date=end_date.strftime('%d.%m.%Y'))
|
||||
|
||||
if not end_date:
|
||||
return texts.t("SUBSCRIPTION_ACTIVE", "✅ Активна")
|
||||
|
||||
days_left = (end_date - current_time).days
|
||||
is_trial = actual_status == "trial" or getattr(subscription, "is_trial", False)
|
||||
|
||||
if actual_status not in {"active", "trial", None} and not is_trial:
|
||||
return texts.t("SUBSCRIPTION_STATUS_UNKNOWN", "❓ Статус неизвестен")
|
||||
is_trial = getattr(subscription, "is_trial", False)
|
||||
|
||||
if is_trial:
|
||||
if days_left > 1 and end_date_display:
|
||||
if days_left > 1:
|
||||
return texts.t(
|
||||
"SUB_STATUS_TRIAL_ACTIVE",
|
||||
"🎁 Тестовая подписка\n📅 до {end_date} ({days} дн.)",
|
||||
).format(end_date=end_date_display, days=days_left)
|
||||
).format(end_date=end_date.strftime('%d.%m.%Y'), days=days_left)
|
||||
if days_left == 1:
|
||||
return texts.t(
|
||||
"SUB_STATUS_TRIAL_TOMORROW",
|
||||
@@ -1396,11 +1292,11 @@ def _get_subscription_status(user, texts):
|
||||
"🎁 Тестовая подписка\n⚠️ истекает сегодня!",
|
||||
)
|
||||
|
||||
if days_left > 7 and end_date_display:
|
||||
if days_left > 7:
|
||||
return texts.t(
|
||||
"SUB_STATUS_ACTIVE_LONG",
|
||||
"💎 Активна\n📅 до {end_date} ({days} дн.)",
|
||||
).format(end_date=end_date_display, days=days_left)
|
||||
).format(end_date=end_date.strftime('%d.%m.%Y'), days=days_left)
|
||||
if days_left > 1:
|
||||
return texts.t(
|
||||
"SUB_STATUS_ACTIVE_FEW_DAYS",
|
||||
@@ -1625,9 +1521,8 @@ async def required_sub_channel_check(
|
||||
logger.warning(f"Не удалось удалить сообщение: {e}")
|
||||
|
||||
if user and user.status != UserStatus.DELETED.value:
|
||||
has_active_subscription, subscription_is_active = _calculate_subscription_flags(
|
||||
user.subscription
|
||||
)
|
||||
has_active_subscription = bool(user.subscription)
|
||||
subscription_is_active = bool(user.subscription and user.subscription.is_active)
|
||||
|
||||
menu_text = await get_main_menu_text(user, texts, db)
|
||||
|
||||
|
||||
@@ -187,7 +187,7 @@ async def handle_subscription_config_back(
|
||||
if current_state == SubscriptionStates.selecting_traffic.state:
|
||||
await callback.message.edit_text(
|
||||
await _build_subscription_period_prompt(db_user, texts, db),
|
||||
reply_markup=get_subscription_period_keyboard(db_user.language, db_user),
|
||||
reply_markup=get_subscription_period_keyboard(db_user.language),
|
||||
parse_mode="HTML",
|
||||
)
|
||||
await state.set_state(SubscriptionStates.selecting_period)
|
||||
@@ -202,26 +202,45 @@ async def handle_subscription_config_back(
|
||||
else:
|
||||
await callback.message.edit_text(
|
||||
await _build_subscription_period_prompt(db_user, texts, db),
|
||||
reply_markup=get_subscription_period_keyboard(db_user.language, db_user),
|
||||
reply_markup=get_subscription_period_keyboard(db_user.language),
|
||||
parse_mode="HTML",
|
||||
)
|
||||
await state.set_state(SubscriptionStates.selecting_period)
|
||||
|
||||
elif current_state == SubscriptionStates.selecting_devices.state:
|
||||
await _show_previous_configuration_step(callback, state, db_user, texts, db)
|
||||
|
||||
elif current_state == SubscriptionStates.confirming_purchase.state:
|
||||
if settings.is_devices_selection_enabled():
|
||||
if await _should_show_countries_management(db_user):
|
||||
countries = await _get_available_countries(db_user.promo_group_id)
|
||||
data = await state.get_data()
|
||||
selected_devices = data.get('devices', settings.DEFAULT_DEVICE_LIMIT)
|
||||
selected_countries = data.get('countries', [])
|
||||
|
||||
await callback.message.edit_text(
|
||||
texts.SELECT_DEVICES,
|
||||
reply_markup=get_devices_keyboard(selected_devices, db_user.language)
|
||||
texts.SELECT_COUNTRIES,
|
||||
reply_markup=get_countries_keyboard(countries, selected_countries, db_user.language)
|
||||
)
|
||||
await state.set_state(SubscriptionStates.selecting_devices)
|
||||
await state.set_state(SubscriptionStates.selecting_countries)
|
||||
elif settings.is_traffic_selectable():
|
||||
await callback.message.edit_text(
|
||||
texts.SELECT_TRAFFIC,
|
||||
reply_markup=get_traffic_packages_keyboard(db_user.language)
|
||||
)
|
||||
await state.set_state(SubscriptionStates.selecting_traffic)
|
||||
else:
|
||||
await _show_previous_configuration_step(callback, state, db_user, texts, db)
|
||||
await callback.message.edit_text(
|
||||
await _build_subscription_period_prompt(db_user, texts, db),
|
||||
reply_markup=get_subscription_period_keyboard(db_user.language),
|
||||
parse_mode="HTML",
|
||||
)
|
||||
await state.set_state(SubscriptionStates.selecting_period)
|
||||
|
||||
elif current_state == SubscriptionStates.confirming_purchase.state:
|
||||
data = await state.get_data()
|
||||
selected_devices = data.get('devices', settings.DEFAULT_DEVICE_LIMIT)
|
||||
|
||||
await callback.message.edit_text(
|
||||
texts.SELECT_DEVICES,
|
||||
reply_markup=get_devices_keyboard(selected_devices, db_user.language)
|
||||
)
|
||||
await state.set_state(SubscriptionStates.selecting_devices)
|
||||
|
||||
else:
|
||||
from app.handlers.menu import show_main_menu
|
||||
@@ -248,37 +267,3 @@ async def handle_subscription_cancel(
|
||||
await show_main_menu(callback, db_user, db)
|
||||
|
||||
await callback.answer("❌ Покупка отменена")
|
||||
async def _show_previous_configuration_step(
|
||||
callback: types.CallbackQuery,
|
||||
state: FSMContext,
|
||||
db_user: User,
|
||||
texts,
|
||||
db: AsyncSession,
|
||||
):
|
||||
if await _should_show_countries_management(db_user):
|
||||
countries = await _get_available_countries(db_user.promo_group_id)
|
||||
data = await state.get_data()
|
||||
selected_countries = data.get('countries', [])
|
||||
|
||||
await callback.message.edit_text(
|
||||
texts.SELECT_COUNTRIES,
|
||||
reply_markup=get_countries_keyboard(countries, selected_countries, db_user.language)
|
||||
)
|
||||
await state.set_state(SubscriptionStates.selecting_countries)
|
||||
return
|
||||
|
||||
if settings.is_traffic_selectable():
|
||||
await callback.message.edit_text(
|
||||
texts.SELECT_TRAFFIC,
|
||||
reply_markup=get_traffic_packages_keyboard(db_user.language)
|
||||
)
|
||||
await state.set_state(SubscriptionStates.selecting_traffic)
|
||||
return
|
||||
|
||||
await callback.message.edit_text(
|
||||
await _build_subscription_period_prompt(db_user, texts, db),
|
||||
reply_markup=get_subscription_period_keyboard(db_user.language, db_user),
|
||||
parse_mode="HTML",
|
||||
)
|
||||
await state.set_state(SubscriptionStates.selecting_period)
|
||||
|
||||
|
||||
@@ -107,7 +107,7 @@ def _get_addon_discount_percent_for_user(
|
||||
if user is None:
|
||||
return 0
|
||||
|
||||
promo_group = user.get_primary_promo_group()
|
||||
promo_group = getattr(user, "promo_group", None)
|
||||
if promo_group is None:
|
||||
return 0
|
||||
|
||||
|
||||
@@ -79,7 +79,6 @@ from app.utils.promo_offer import (
|
||||
)
|
||||
|
||||
from .common import _get_addon_discount_percent_for_user, _get_period_hint_from_subscription, logger
|
||||
from .summary import present_subscription_summary
|
||||
|
||||
async def handle_add_countries(
|
||||
callback: types.CallbackQuery,
|
||||
@@ -589,11 +588,6 @@ async def countries_continue(
|
||||
await callback.answer("⚠️ Выберите хотя бы одну страну!", show_alert=True)
|
||||
return
|
||||
|
||||
if not settings.is_devices_selection_enabled():
|
||||
if await present_subscription_summary(callback, state, db_user, texts):
|
||||
await callback.answer()
|
||||
return
|
||||
|
||||
selected_devices = data.get('devices', settings.DEFAULT_DEVICE_LIMIT)
|
||||
|
||||
await callback.message.edit_text(
|
||||
|
||||
@@ -183,13 +183,6 @@ async def handle_change_devices(
|
||||
texts = get_texts(db_user.language)
|
||||
subscription = db_user.subscription
|
||||
|
||||
if not settings.is_devices_selection_enabled():
|
||||
await callback.answer(
|
||||
texts.t("DEVICES_SELECTION_DISABLED", "⚠️ Изменение количества устройств недоступно"),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
if not subscription or subscription.is_trial:
|
||||
await callback.answer(
|
||||
texts.t("PAID_FEATURE_ONLY", "⚠️ Эта функция доступна только для платных подписок"),
|
||||
@@ -240,13 +233,6 @@ async def confirm_change_devices(
|
||||
texts = get_texts(db_user.language)
|
||||
subscription = db_user.subscription
|
||||
|
||||
if not settings.is_devices_selection_enabled():
|
||||
await callback.answer(
|
||||
texts.t("DEVICES_SELECTION_DISABLED", "⚠️ Изменение количества устройств недоступно"),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
current_devices = subscription.device_limit
|
||||
|
||||
if new_devices_count == current_devices:
|
||||
@@ -393,13 +379,6 @@ async def execute_change_devices(
|
||||
subscription = db_user.subscription
|
||||
current_devices = subscription.device_limit
|
||||
|
||||
if not settings.is_devices_selection_enabled():
|
||||
await callback.answer(
|
||||
texts.t("DEVICES_SELECTION_DISABLED", "⚠️ Изменение количества устройств недоступно"),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
try:
|
||||
if price > 0:
|
||||
success = await subtract_user_balance(
|
||||
@@ -884,13 +863,6 @@ async def confirm_add_devices(
|
||||
texts = get_texts(db_user.language)
|
||||
subscription = db_user.subscription
|
||||
|
||||
if not settings.is_devices_selection_enabled():
|
||||
await callback.answer(
|
||||
texts.t("DEVICES_SELECTION_DISABLED", "⚠️ Изменение количества устройств недоступно"),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
resume_callback = None
|
||||
|
||||
new_total_devices = subscription.device_limit + devices_count
|
||||
|
||||
@@ -77,7 +77,6 @@ from app.utils.promo_offer import (
|
||||
build_promo_offer_hint,
|
||||
get_user_active_promo_discount_percent,
|
||||
)
|
||||
from app.utils.timezone import format_local_datetime
|
||||
|
||||
from .common import _apply_discount_to_monthly_component, _apply_promo_offer_discount, logger
|
||||
from .countries import _get_available_countries, _get_countries_info, get_countries_price_by_uuids_fallback
|
||||
@@ -159,18 +158,7 @@ async def _prepare_subscription_summary(
|
||||
total_servers_discount += total_discount_for_server
|
||||
selected_server_prices.append(total_price_for_server)
|
||||
|
||||
devices_selection_enabled = settings.is_devices_selection_enabled()
|
||||
forced_disabled_limit: Optional[int] = None
|
||||
if devices_selection_enabled:
|
||||
devices_selected = summary_data.get('devices', settings.DEFAULT_DEVICE_LIMIT)
|
||||
else:
|
||||
forced_disabled_limit = settings.get_disabled_mode_device_limit()
|
||||
if forced_disabled_limit is None:
|
||||
devices_selected = settings.DEFAULT_DEVICE_LIMIT
|
||||
else:
|
||||
devices_selected = forced_disabled_limit
|
||||
|
||||
summary_data['devices'] = devices_selected
|
||||
devices_selected = summary_data.get('devices', settings.DEFAULT_DEVICE_LIMIT)
|
||||
additional_devices = max(0, devices_selected - settings.DEFAULT_DEVICE_LIMIT)
|
||||
devices_price_per_month = additional_devices * settings.PRICE_PER_DEVICE
|
||||
devices_discount_percent = db_user.get_promo_discount(
|
||||
@@ -253,20 +241,17 @@ async def _prepare_subscription_summary(
|
||||
else:
|
||||
traffic_display = f"{summary_data.get('traffic_gb', 0)} ГБ"
|
||||
|
||||
details_lines = []
|
||||
|
||||
# Добавляем строку базового периода только если цена не равна 0
|
||||
if base_discount_total > 0 and base_price > 0:
|
||||
if base_discount_total > 0:
|
||||
base_line = (
|
||||
f"- Базовый период: <s>{texts.format_price(base_price_original)}</s> "
|
||||
f"{texts.format_price(base_price)}"
|
||||
f" (скидка {period_discount_percent}%:"
|
||||
f" -{texts.format_price(base_discount_total)})"
|
||||
)
|
||||
details_lines.append(base_line)
|
||||
elif base_price_original > 0:
|
||||
else:
|
||||
base_line = f"- Базовый период: {texts.format_price(base_price_original)}"
|
||||
details_lines.append(base_line)
|
||||
|
||||
details_lines = [base_line]
|
||||
|
||||
if total_traffic_price > 0:
|
||||
traffic_line = (
|
||||
@@ -290,7 +275,7 @@ async def _prepare_subscription_summary(
|
||||
f" -{texts.format_price(total_servers_discount)})"
|
||||
)
|
||||
details_lines.append(servers_line)
|
||||
if devices_selection_enabled and total_devices_price > 0:
|
||||
if total_devices_price > 0:
|
||||
devices_line = (
|
||||
f"- Доп. устройства: {texts.format_price(devices_price_per_month)}/мес × {months_in_period}"
|
||||
f" = {texts.format_price(total_devices_price)}"
|
||||
@@ -315,28 +300,17 @@ async def _prepare_subscription_summary(
|
||||
|
||||
details_text = "\n".join(details_lines)
|
||||
|
||||
summary_lines = [
|
||||
"📋 <b>Сводка заказа</b>",
|
||||
"",
|
||||
f"📅 <b>Период:</b> {period_display}",
|
||||
f"📊 <b>Трафик:</b> {traffic_display}",
|
||||
f"🌍 <b>Страны:</b> {', '.join(selected_countries_names)}",
|
||||
]
|
||||
|
||||
if devices_selection_enabled:
|
||||
summary_lines.append(f"📱 <b>Устройства:</b> {devices_selected}")
|
||||
|
||||
summary_lines.extend([
|
||||
"",
|
||||
"💰 <b>Детализация стоимости:</b>",
|
||||
details_text,
|
||||
"",
|
||||
f"💎 <b>Общая стоимость:</b> {texts.format_price(total_price)}",
|
||||
"",
|
||||
"Подтверждаете покупку?",
|
||||
])
|
||||
|
||||
summary_text = "\n".join(summary_lines)
|
||||
summary_text = (
|
||||
"📋 <b>Сводка заказа</b>\n\n"
|
||||
f"📅 <b>Период:</b> {period_display}\n"
|
||||
f"📊 <b>Трафик:</b> {traffic_display}\n"
|
||||
f"🌍 <b>Страны:</b> {', '.join(selected_countries_names)}\n"
|
||||
f"📱 <b>Устройства:</b> {devices_selected}\n\n"
|
||||
"💰 <b>Детализация стоимости:</b>\n"
|
||||
f"{details_text}\n\n"
|
||||
f"💎 <b>Общая стоимость:</b> {texts.format_price(total_price)}\n\n"
|
||||
"Подтверждаете покупку?"
|
||||
)
|
||||
|
||||
return summary_text, summary_data
|
||||
|
||||
@@ -353,7 +327,7 @@ async def _build_subscription_period_prompt(
|
||||
if promo_offer_hint:
|
||||
lines.extend(["", promo_offer_hint])
|
||||
|
||||
promo_text = await _build_promo_group_discount_text(
|
||||
promo_text = _build_promo_group_discount_text(
|
||||
db_user,
|
||||
settings.get_available_subscription_periods(),
|
||||
texts=texts,
|
||||
@@ -408,18 +382,7 @@ async def get_subscription_cost(subscription, db: AsyncSession) -> int:
|
||||
)
|
||||
|
||||
traffic_cost = settings.get_traffic_price(subscription.traffic_limit_gb)
|
||||
device_limit = subscription.device_limit
|
||||
if device_limit is None:
|
||||
if settings.is_devices_selection_enabled():
|
||||
device_limit = settings.DEFAULT_DEVICE_LIMIT
|
||||
else:
|
||||
forced_limit = settings.get_disabled_mode_device_limit()
|
||||
if forced_limit is None:
|
||||
device_limit = settings.DEFAULT_DEVICE_LIMIT
|
||||
else:
|
||||
device_limit = forced_limit
|
||||
|
||||
devices_cost = max(0, (device_limit or 0) - settings.DEFAULT_DEVICE_LIMIT) * settings.PRICE_PER_DEVICE
|
||||
devices_cost = max(0, subscription.device_limit - settings.DEFAULT_DEVICE_LIMIT) * settings.PRICE_PER_DEVICE
|
||||
|
||||
total_cost = base_cost + servers_cost + traffic_cost + devices_cost
|
||||
|
||||
@@ -447,12 +410,7 @@ async def get_subscription_cost(subscription, db: AsyncSession) -> int:
|
||||
return 0
|
||||
|
||||
async def get_subscription_info_text(subscription, texts, db_user, db: AsyncSession):
|
||||
devices_selection_enabled = settings.is_devices_selection_enabled()
|
||||
|
||||
if devices_selection_enabled:
|
||||
devices_used = await get_current_devices_count(db_user)
|
||||
else:
|
||||
devices_used = 0
|
||||
devices_used = await get_current_devices_count(db_user)
|
||||
countries_info = await _get_countries_info(subscription.connected_squads)
|
||||
countries_text = ", ".join([c['name'] for c in countries_info]) if countries_info else "Нет"
|
||||
|
||||
@@ -481,21 +439,10 @@ async def get_subscription_info_text(subscription, texts, db_user, db: AsyncSess
|
||||
|
||||
subscription_cost = await get_subscription_cost(subscription, db)
|
||||
|
||||
info_template = texts.SUBSCRIPTION_INFO
|
||||
|
||||
if not devices_selection_enabled:
|
||||
info_template = info_template.replace(
|
||||
"\n📱 <b>Устройства:</b> {devices_used} / {devices_limit}",
|
||||
"",
|
||||
).replace(
|
||||
"\n📱 <b>Devices:</b> {devices_used} / {devices_limit}",
|
||||
"",
|
||||
)
|
||||
|
||||
info_text = info_template.format(
|
||||
info_text = texts.SUBSCRIPTION_INFO.format(
|
||||
status=status_text,
|
||||
type=type_text,
|
||||
end_date=format_local_datetime(subscription.end_date, "%d.%m.%Y %H:%M"),
|
||||
end_date=subscription.end_date.strftime("%d.%m.%Y %H:%M"),
|
||||
days_left=max(0, subscription.days_left),
|
||||
traffic_used=texts.format_traffic(subscription.traffic_used_gb),
|
||||
traffic_limit=traffic_text,
|
||||
|
||||
@@ -88,12 +88,12 @@ async def _get_promo_offer_hint(
|
||||
) -> Optional[str]:
|
||||
return await build_promo_offer_hint(db, db_user, texts, percent)
|
||||
|
||||
async def _build_promo_group_discount_text(
|
||||
def _build_promo_group_discount_text(
|
||||
db_user: User,
|
||||
periods: Optional[List[int]] = None,
|
||||
texts=None,
|
||||
) -> str:
|
||||
promo_group = db_user.get_primary_promo_group()
|
||||
promo_group = getattr(db_user, "promo_group", None)
|
||||
|
||||
if not promo_group:
|
||||
return ""
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,59 +0,0 @@
|
||||
import logging
|
||||
from typing import Optional, TYPE_CHECKING
|
||||
|
||||
from aiogram import types
|
||||
from aiogram.fsm.context import FSMContext
|
||||
|
||||
from app.localization.texts import get_texts
|
||||
from app.services.subscription_checkout_service import save_subscription_checkout_draft
|
||||
from app.states import SubscriptionStates
|
||||
from app.keyboards.inline import get_subscription_confirm_keyboard
|
||||
|
||||
if TYPE_CHECKING: # pragma: no cover - only for type checking
|
||||
from .pricing import _prepare_subscription_summary
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def present_subscription_summary(
|
||||
callback: types.CallbackQuery,
|
||||
state: FSMContext,
|
||||
db_user,
|
||||
texts: Optional = None,
|
||||
) -> bool:
|
||||
"""Render the subscription purchase summary and switch to the confirmation state.
|
||||
|
||||
Returns ``True`` when the summary is shown successfully and ``False`` if
|
||||
calculation failed (an error is shown to the user in this case).
|
||||
"""
|
||||
|
||||
if texts is None:
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
data = await state.get_data()
|
||||
|
||||
from .pricing import _prepare_subscription_summary
|
||||
|
||||
try:
|
||||
summary_text, prepared_data = await _prepare_subscription_summary(db_user, data, texts)
|
||||
except ValueError as exc:
|
||||
logger.error(
|
||||
"Ошибка в расчете цены подписки для пользователя %s: %s",
|
||||
db_user.telegram_id,
|
||||
exc,
|
||||
)
|
||||
await callback.answer("Ошибка расчета цены. Обратитесь в поддержку.", show_alert=True)
|
||||
return False
|
||||
|
||||
await state.set_data(prepared_data)
|
||||
await save_subscription_checkout_draft(db_user.id, prepared_data)
|
||||
|
||||
await callback.message.edit_text(
|
||||
summary_text,
|
||||
reply_markup=get_subscription_confirm_keyboard(db_user.language),
|
||||
parse_mode="HTML",
|
||||
)
|
||||
|
||||
await state.set_state(SubscriptionStates.confirming_purchase)
|
||||
return True
|
||||
@@ -80,7 +80,6 @@ from app.utils.promo_offer import (
|
||||
|
||||
from .common import _apply_addon_discount, _get_addon_discount_percent_for_user, _get_period_hint_from_subscription, get_confirm_switch_traffic_keyboard, get_traffic_switch_keyboard, logger
|
||||
from .countries import _get_available_countries, _should_show_countries_management
|
||||
from .summary import present_subscription_summary
|
||||
|
||||
async def handle_add_traffic(
|
||||
callback: types.CallbackQuery,
|
||||
@@ -353,15 +352,12 @@ async def select_traffic(
|
||||
reply_markup=get_countries_keyboard(countries, [], db_user.language)
|
||||
)
|
||||
await state.set_state(SubscriptionStates.selecting_countries)
|
||||
await callback.answer()
|
||||
return
|
||||
else:
|
||||
countries = await _get_available_countries(db_user.promo_group_id)
|
||||
available_countries = [c for c in countries if c.get('is_available', True)]
|
||||
data['countries'] = [available_countries[0]['uuid']] if available_countries else []
|
||||
await state.set_data(data)
|
||||
|
||||
countries = await _get_available_countries(db_user.promo_group_id)
|
||||
available_countries = [c for c in countries if c.get('is_available', True)]
|
||||
data['countries'] = [available_countries[0]['uuid']] if available_countries else []
|
||||
await state.set_data(data)
|
||||
|
||||
if settings.is_devices_selection_enabled():
|
||||
selected_devices = data.get('devices', settings.DEFAULT_DEVICE_LIMIT)
|
||||
|
||||
await callback.message.edit_text(
|
||||
@@ -369,11 +365,8 @@ async def select_traffic(
|
||||
reply_markup=get_devices_keyboard(selected_devices, db_user.language)
|
||||
)
|
||||
await state.set_state(SubscriptionStates.selecting_devices)
|
||||
await callback.answer()
|
||||
return
|
||||
|
||||
if await present_subscription_summary(callback, state, db_user, texts):
|
||||
await callback.answer()
|
||||
await callback.answer()
|
||||
|
||||
async def add_traffic(
|
||||
callback: types.CallbackQuery,
|
||||
|
||||
@@ -114,11 +114,7 @@ async def handle_successful_payment(message: types.Message):
|
||||
)
|
||||
|
||||
await message.answer(
|
||||
f"✅ Баланс успешно пополнен на {settings.format_price(amount_kopeks)}!\n\n"
|
||||
"⚠️ <b>Важно:</b> Пополнение баланса не активирует подписку автоматически. "
|
||||
"Обязательно активируйте подписку отдельно!\n\n"
|
||||
f"🔄 При наличии сохранённой корзины подписки и включенной автопокупке, "
|
||||
f"подписка будет приобретена автоматически после пополнения баланса."
|
||||
f"✅ Баланс успешно пополнен на {settings.format_price(amount_kopeks)}!"
|
||||
)
|
||||
|
||||
logger.info(f"✅ Обработан Stars платеж: {payment.telegram_payment_charge_id}")
|
||||
|
||||
+3
-21
@@ -760,12 +760,6 @@ def get_user_management_keyboard(user_id: int, user_status: str, language: str =
|
||||
callback_data=f"admin_user_promo_group_{user_id}"
|
||||
)
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=_t(texts, "ADMIN_USER_REFERRALS_BUTTON", "🤝 Рефералы"),
|
||||
callback_data=f"admin_user_referrals_{user_id}"
|
||||
)
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=_t(texts, "ADMIN_USER_STATISTICS", "📊 Статистика"),
|
||||
@@ -827,28 +821,20 @@ def get_user_management_keyboard(user_id: int, user_status: str, language: str =
|
||||
def get_user_promo_group_keyboard(
|
||||
promo_groups: List[Tuple[Any, int]],
|
||||
user_id: int,
|
||||
current_group_ids, # Can be Optional[int] or List[int]
|
||||
current_group_id: Optional[int],
|
||||
language: str = "ru"
|
||||
) -> InlineKeyboardMarkup:
|
||||
texts = get_texts(language)
|
||||
|
||||
# Ensure current_group_ids is a list
|
||||
if current_group_ids is None:
|
||||
current_group_ids = []
|
||||
elif isinstance(current_group_ids, int):
|
||||
current_group_ids = [current_group_ids]
|
||||
|
||||
keyboard: List[List[InlineKeyboardButton]] = []
|
||||
|
||||
for group, members_count in promo_groups:
|
||||
# Check if user has this group
|
||||
has_group = group.id in current_group_ids
|
||||
prefix = "✅" if has_group else "👥"
|
||||
prefix = "✅" if current_group_id is not None and group.id == current_group_id else "👥"
|
||||
count_text = f" ({members_count})" if members_count else ""
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(
|
||||
text=f"{prefix} {group.name}{count_text}",
|
||||
callback_data=f"admin_user_promo_group_toggle_{user_id}_{group.id}"
|
||||
callback_data=f"admin_user_promo_group_set_{user_id}_{group.id}"
|
||||
)
|
||||
])
|
||||
|
||||
@@ -895,10 +881,6 @@ def get_promocode_type_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
|
||||
InlineKeyboardButton(
|
||||
text=_t(texts, "ADMIN_PROMOCODE_TYPE_TRIAL", "🎁 Триал"),
|
||||
callback_data="promo_type_trial"
|
||||
),
|
||||
InlineKeyboardButton(
|
||||
text=_t(texts, "ADMIN_PROMOCODE_TYPE_PROMO_GROUP", "🏷️ Промогруппа"),
|
||||
callback_data="promo_type_group"
|
||||
)
|
||||
],
|
||||
[
|
||||
|
||||
+65
-114
@@ -9,11 +9,7 @@ from app.config import settings, PERIOD_PRICES, TRAFFIC_PRICES
|
||||
from app.localization.loader import DEFAULT_LANGUAGE
|
||||
from app.localization.texts import get_texts
|
||||
from app.utils.miniapp_buttons import build_miniapp_or_callback_button
|
||||
from app.utils.pricing_utils import (
|
||||
format_period_description,
|
||||
apply_percentage_discount,
|
||||
)
|
||||
from app.utils.price_display import PriceInfo, format_price_button
|
||||
from app.utils.pricing_utils import format_period_description, apply_percentage_discount
|
||||
from app.utils.subscription_utils import (
|
||||
get_display_subscription_link,
|
||||
get_happ_cryptolink_redirect_link,
|
||||
@@ -357,13 +353,10 @@ def get_main_menu_keyboard(
|
||||
paired_buttons.append(simple_purchase_button)
|
||||
|
||||
if show_resume_checkout or has_saved_cart:
|
||||
resume_callback = (
|
||||
"return_to_saved_cart" if has_saved_cart else "subscription_resume_checkout"
|
||||
)
|
||||
paired_buttons.append(
|
||||
InlineKeyboardButton(
|
||||
text=texts.RETURN_TO_SUBSCRIPTION_CHECKOUT,
|
||||
callback_data=resume_callback,
|
||||
callback_data="subscription_resume_checkout",
|
||||
)
|
||||
)
|
||||
|
||||
@@ -675,7 +668,7 @@ def get_insufficient_balance_keyboard(
|
||||
return_row = [
|
||||
InlineKeyboardButton(
|
||||
text=texts.RETURN_TO_SUBSCRIPTION_CHECKOUT,
|
||||
callback_data="return_to_saved_cart",
|
||||
callback_data="subscription_resume_checkout",
|
||||
)
|
||||
]
|
||||
insert_index = back_row_index if back_row_index is not None else len(keyboard.inline_keyboard)
|
||||
@@ -807,7 +800,7 @@ def get_payment_methods_keyboard_with_cart(
|
||||
keyboard.inline_keyboard.insert(-1, [ # Вставляем перед кнопкой "назад"
|
||||
InlineKeyboardButton(
|
||||
text=texts.RETURN_TO_SUBSCRIPTION_CHECKOUT,
|
||||
callback_data="return_to_saved_cart"
|
||||
callback_data="subscription_resume_checkout"
|
||||
)
|
||||
])
|
||||
|
||||
@@ -864,59 +857,36 @@ def get_trial_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
|
||||
])
|
||||
|
||||
|
||||
def get_subscription_period_keyboard(
|
||||
language: str = DEFAULT_LANGUAGE,
|
||||
user: Optional[User] = None
|
||||
) -> InlineKeyboardMarkup:
|
||||
"""
|
||||
Generate subscription period selection keyboard with personalized pricing.
|
||||
|
||||
Args:
|
||||
language: User's language code
|
||||
user: User object for personalized discounts (None = default discounts)
|
||||
|
||||
Returns:
|
||||
InlineKeyboardMarkup with period buttons showing personalized prices
|
||||
"""
|
||||
from app.utils.price_display import calculate_user_price
|
||||
|
||||
def get_subscription_period_keyboard(language: str = DEFAULT_LANGUAGE) -> InlineKeyboardMarkup:
|
||||
texts = get_texts(language)
|
||||
keyboard = []
|
||||
|
||||
|
||||
available_periods = settings.get_available_subscription_periods()
|
||||
|
||||
|
||||
period_texts = {
|
||||
14: texts.PERIOD_14_DAYS,
|
||||
30: texts.PERIOD_30_DAYS,
|
||||
60: texts.PERIOD_60_DAYS,
|
||||
90: texts.PERIOD_90_DAYS,
|
||||
180: texts.PERIOD_180_DAYS,
|
||||
360: texts.PERIOD_360_DAYS
|
||||
}
|
||||
|
||||
for days in available_periods:
|
||||
# Get base price for this period
|
||||
base_price = PERIOD_PRICES.get(days, 0)
|
||||
|
||||
# Calculate personalized price with user's discounts
|
||||
price_info = calculate_user_price(user, base_price, days, "period")
|
||||
|
||||
# Format period description
|
||||
period_display = format_period_description(days, language)
|
||||
|
||||
# Format button text with discount display
|
||||
button_text = format_price_button(
|
||||
period_label=period_display,
|
||||
price_info=price_info,
|
||||
format_price_func=texts.format_price,
|
||||
emphasize=False,
|
||||
add_exclamation=False
|
||||
)
|
||||
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(
|
||||
text=button_text,
|
||||
callback_data=f"period_{days}"
|
||||
)
|
||||
])
|
||||
|
||||
if days in period_texts:
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(
|
||||
text=period_texts[days],
|
||||
callback_data=f"period_{days}"
|
||||
)
|
||||
])
|
||||
|
||||
# Кнопка "Простая покупка" была убрана из выбора периода подписки
|
||||
|
||||
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(text=texts.BACK, callback_data="back_to_menu")
|
||||
])
|
||||
|
||||
|
||||
return InlineKeyboardMarkup(inline_keyboard=keyboard)
|
||||
|
||||
|
||||
@@ -1159,14 +1129,6 @@ def get_payment_methods_keyboard(amount_kopeks: int, language: str = DEFAULT_LAN
|
||||
)
|
||||
])
|
||||
|
||||
if settings.is_platega_enabled() and settings.get_platega_active_methods():
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(
|
||||
text=texts.t("PAYMENT_PLATEGA", "💳 Platega"),
|
||||
callback_data=_build_callback("platega"),
|
||||
)
|
||||
])
|
||||
|
||||
if settings.is_cryptobot_enabled():
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(
|
||||
@@ -1440,8 +1402,27 @@ def _get_days_word(days: int) -> str:
|
||||
|
||||
|
||||
|
||||
# Deprecated: get_extend_subscription_keyboard() was removed.
|
||||
# Use get_extend_subscription_keyboard_with_prices() instead for personalized pricing.
|
||||
def get_extend_subscription_keyboard(language: str = DEFAULT_LANGUAGE) -> InlineKeyboardMarkup:
|
||||
texts = get_texts(language)
|
||||
keyboard = []
|
||||
|
||||
periods = [
|
||||
(14, f"📅 14 дней - {settings.format_price(settings.PRICE_14_DAYS)}"),
|
||||
(30, f"📅 30 дней - {settings.format_price(settings.PRICE_30_DAYS)}"),
|
||||
(60, f"📅 60 дней - {settings.format_price(settings.PRICE_60_DAYS)}"),
|
||||
(90, f"📅 90 дней - {settings.format_price(settings.PRICE_90_DAYS)}")
|
||||
]
|
||||
|
||||
for days, text in periods:
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(text=text, callback_data=f"extend_period_{days}")
|
||||
])
|
||||
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(text=texts.BACK, callback_data="menu_subscription")
|
||||
])
|
||||
|
||||
return InlineKeyboardMarkup(inline_keyboard=keyboard)
|
||||
|
||||
|
||||
def get_add_traffic_keyboard(
|
||||
@@ -1978,39 +1959,15 @@ def get_extend_subscription_keyboard_with_prices(language: str, prices: dict) ->
|
||||
|
||||
if isinstance(price_info, dict):
|
||||
final_price = price_info.get("final")
|
||||
original_price = price_info.get("original", 0)
|
||||
if final_price is None:
|
||||
final_price = price_info.get("original", 0)
|
||||
else:
|
||||
final_price = price_info
|
||||
original_price = price_info
|
||||
|
||||
period_display = format_period_description(days, language)
|
||||
|
||||
# Create PriceInfo from already calculated prices
|
||||
# Note: original_price and final_price are calculated in the handler
|
||||
discount_percent = 0
|
||||
if original_price > final_price and original_price > 0:
|
||||
discount_percent = ((original_price - final_price) * 100) // original_price
|
||||
|
||||
price_info_obj = PriceInfo(
|
||||
base_price=original_price,
|
||||
final_price=final_price,
|
||||
discount_percent=discount_percent
|
||||
)
|
||||
|
||||
# Format button using unified system
|
||||
button_text = format_price_button(
|
||||
period_label=period_display,
|
||||
price_info=price_info_obj,
|
||||
format_price_func=texts.format_price,
|
||||
emphasize=False,
|
||||
add_exclamation=False
|
||||
)
|
||||
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(
|
||||
text=button_text,
|
||||
text=f"📅 {period_display} - {texts.format_price(final_price)}",
|
||||
callback_data=f"extend_period_{days}"
|
||||
)
|
||||
])
|
||||
@@ -2125,39 +2082,33 @@ def get_updated_subscription_settings_keyboard(language: str = DEFAULT_LANGUAGE,
|
||||
|
||||
texts = get_texts(language)
|
||||
keyboard = []
|
||||
|
||||
|
||||
if show_countries_management:
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(text=texts.t("ADD_COUNTRIES_BUTTON", "🌐 Добавить страны"), callback_data="subscription_add_countries")
|
||||
])
|
||||
|
||||
if settings.is_traffic_selectable():
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(text=texts.t("RESET_TRAFFIC_BUTTON", "🔄 Сбросить трафик"), callback_data="subscription_reset_traffic")
|
||||
])
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(text=texts.t("SWITCH_TRAFFIC_BUTTON", "🔄 Переключить трафик"), callback_data="subscription_switch_traffic")
|
||||
])
|
||||
|
||||
if settings.is_devices_selection_enabled():
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(
|
||||
text=texts.t("CHANGE_DEVICES_BUTTON", "📱 Изменить устройства"),
|
||||
callback_data="subscription_change_devices"
|
||||
)
|
||||
])
|
||||
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(
|
||||
text=texts.t("MANAGE_DEVICES_BUTTON", "🔧 Управление устройствами"),
|
||||
callback_data="subscription_manage_devices"
|
||||
)
|
||||
keyboard.extend([
|
||||
[
|
||||
InlineKeyboardButton(text=texts.t("CHANGE_DEVICES_BUTTON", "📱 Изменить устройства"), callback_data="subscription_change_devices")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text=texts.t("MANAGE_DEVICES_BUTTON", "🔧 Управление устройствами"), callback_data="subscription_manage_devices")
|
||||
]
|
||||
])
|
||||
|
||||
if settings.is_traffic_selectable():
|
||||
keyboard.insert(-2, [
|
||||
InlineKeyboardButton(text=texts.t("SWITCH_TRAFFIC_BUTTON", "🔄 Переключить трафик"), callback_data="subscription_switch_traffic")
|
||||
])
|
||||
keyboard.insert(-2, [
|
||||
InlineKeyboardButton(text=texts.t("RESET_TRAFFIC_BUTTON", "🔄 Сбросить трафик"), callback_data="subscription_reset_traffic")
|
||||
])
|
||||
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(text=texts.BACK, callback_data="menu_subscription")
|
||||
])
|
||||
|
||||
|
||||
return InlineKeyboardMarkup(inline_keyboard=keyboard)
|
||||
|
||||
|
||||
|
||||
@@ -314,7 +314,6 @@
|
||||
"ADMIN_PROMOCODE_TYPE_BALANCE": "💰 Balance",
|
||||
"ADMIN_PROMOCODE_TYPE_DAYS": "📅 Subscription days",
|
||||
"ADMIN_PROMOCODE_TYPE_TRIAL": "🎁 Trial",
|
||||
"ADMIN_PROMOCODE_TYPE_PROMO_GROUP": "🏷️ Promo Group",
|
||||
"ADMIN_PROMO_GROUPS": "💳 Promo groups",
|
||||
"ADMIN_PROMO_GROUPS_DEFAULT_LABEL": " (default)",
|
||||
"ADMIN_PROMO_GROUPS_EMPTY": "No promo groups found.",
|
||||
@@ -371,18 +370,7 @@
|
||||
"ADMIN_PROMO_GROUP_PERIOD_DISCOUNTS_HEADER": "⏳ Period discounts:",
|
||||
"ADMIN_PROMO_GROUP_TOGGLE_ADDON_DISCOUNT_DISABLE": "🧩 Disable add-on discounts",
|
||||
"ADMIN_PROMO_GROUP_TOGGLE_ADDON_DISCOUNT_ENABLE": "🧩 Enable add-on discounts",
|
||||
"ADMIN_PROMO_GROUP_UPDATED": "Promo group \"{name}\" updated.",
|
||||
"ADMIN_PROMO_GROUP_PRIORITY_LINE": "🎯 Priority: {priority}",
|
||||
"ADMIN_PROMO_GROUP_CREATE_PRIORITY_PROMPT": "Enter group priority (0 = base, higher = higher priority):",
|
||||
"ADMIN_PROMO_GROUP_EDIT_PRIORITY_PROMPT": "Enter new priority (current: {current}):",
|
||||
"ADMIN_PROMO_GROUP_EDIT_FIELD_PRIORITY": "🎯 Priority",
|
||||
"ADMIN_PROMO_GROUP_INVALID_PRIORITY": "❌ Priority must be a non-negative integer",
|
||||
"ADMIN_USER_PROMO_GROUPS_PRIMARY": "⭐ Primary: {name} (Priority: {priority})",
|
||||
"ADMIN_USER_PROMO_GROUPS_ADDITIONAL": "Additional groups:",
|
||||
"ADMIN_USER_PROMO_GROUPS_NONE": "User has no promo groups",
|
||||
"ADMIN_USER_PROMO_GROUP_ADDED": "✅ Group «{name}» added",
|
||||
"ADMIN_USER_PROMO_GROUP_REMOVED": "🗑 Group «{name}» removed",
|
||||
"ADMIN_USER_PROMO_GROUP_CANNOT_REMOVE_LAST": "❌ Cannot remove the last promo group",
|
||||
"ADMIN_PROMO_GROUP_UPDATED": "Promo group “{name}” updated.",
|
||||
"ADMIN_PROMO_OFFERS_TITLE": "🎯 <b>Promo offers</b>\n\nSelect a template to configure:",
|
||||
"ADMIN_PROMO_OFFER_ACTIVE_DURATION": "After activation the discount lasts for {hours} h.",
|
||||
"ADMIN_PROMO_OFFER_ALLOWED": "Available segments:",
|
||||
@@ -868,9 +856,6 @@
|
||||
"DEVICE_CHANGE_NO_REFUND": "Payments are not refunded",
|
||||
"DEVICE_CHANGE_NO_REFUND_INFO": "ℹ️ Payments are not refunded",
|
||||
"DEVICE_CHANGE_RESULT_LINE": "📱 Was: {old} → Now: {new}\n",
|
||||
"DEVICES_INVALID_REQUEST": "❌ Invalid request",
|
||||
"DEVICES_INVALID_COUNT": "❌ Invalid device count",
|
||||
"DEVICES_SELECTION_DISABLED": "⚠️ Device selection is unavailable",
|
||||
"DEVICE_CONNECTION_HELP": "❓ How to reconnect a device?",
|
||||
"DEVICE_FETCH_ERROR": "❌ Failed to load devices",
|
||||
"DEVICE_FETCH_INFO_ERROR": "❌ Failed to load device information",
|
||||
@@ -1027,7 +1012,6 @@
|
||||
"PAYMENT_CARD_TRIBUTE": "💳 Bank card (Tribute)",
|
||||
"PAYMENT_CARD_WATA": "💳 Bank card (WATA)",
|
||||
"PAYMENT_CARD_YOOKASSA": "💳 Bank card (YooKassa)",
|
||||
"PAYMENT_PLATEGA": "💳 Platega",
|
||||
"PAYMENT_CHARGE_ERROR": "⚠️ Failed to charge the payment",
|
||||
"PAYMENT_CRYPTOBOT": "🪙 Cryptocurrency (CryptoBot)",
|
||||
"PAYMENT_HELEKET": "🪙 Cryptocurrency (Heleket)",
|
||||
@@ -1061,8 +1045,6 @@
|
||||
"PAYMENT_METHOD_MULENPAY_NAME": "💳 <b>Bank card ({mulenpay_name})</b>",
|
||||
"PAYMENT_METHOD_PAL24_DESCRIPTION": "via Faster Payments System",
|
||||
"PAYMENT_METHOD_PAL24_NAME": "🏦 <b>SBP (PayPalych)</b>",
|
||||
"PAYMENT_METHOD_PLATEGA_DESCRIPTION": "via Platega (cards + SBP)",
|
||||
"PAYMENT_METHOD_PLATEGA_NAME": "💳 <b>Bank card (Platega)</b>",
|
||||
"PAYMENT_METHOD_STARS_DESCRIPTION": "fast and convenient",
|
||||
"PAYMENT_METHOD_STARS_NAME": "⭐ <b>Telegram Stars</b>",
|
||||
"PAYMENT_METHOD_SUPPORT_DESCRIPTION": "other options",
|
||||
@@ -1081,18 +1063,6 @@
|
||||
"PAYMENT_SBP_YOOKASSA": "🏦 Pay via SBP (YooKassa)",
|
||||
"PAYMENT_TELEGRAM_STARS": "⭐ Telegram Stars",
|
||||
"PAYMENT_VIA_SUPPORT": "🛠️ Via support",
|
||||
"PLATEGA_TOPUP_PROMPT": "💳 <b>Payment via Platega ({method_name})</b>\n\nEnter an amount from {min_amount} to {max_amount}.\nPayment is processed by Platega.",
|
||||
"PLATEGA_SELECT_PAYMENT_METHOD": "Choose a Platega payment method:",
|
||||
"PLATEGA_TEMPORARILY_UNAVAILABLE": "❌ Platega payments are temporarily unavailable",
|
||||
"PLATEGA_METHODS_NOT_CONFIGURED": "⚠️ No active Platega methods configured",
|
||||
"PLATEGA_METHOD_SELECTION_REQUIRED": "⚠️ Select a Platega payment method before entering the amount",
|
||||
"PLATEGA_AMOUNT_TOO_LOW": "Minimum amount for Platega: {amount}",
|
||||
"PLATEGA_AMOUNT_TOO_HIGH": "Maximum amount for Platega: {amount}",
|
||||
"PLATEGA_PAYMENT_ERROR": "❌ Failed to create Platega payment. Please try again later or contact support.",
|
||||
"PLATEGA_PAYMENT_INSTRUCTIONS": "💳 <b>Payment via Platega ({method})</b>\n\n💰 Amount: {amount}\n🆔 Transaction ID: {transaction}\n\n📱 <b>Instructions:</b>\n1. Tap the ‘Pay’ button\n2. Follow the payment provider instructions\n3. Confirm the transfer\n4. Funds will be credited automatically\n\n❓ If you have issues, contact {support}",
|
||||
"PLATEGA_PAY_BUTTON": "💳 Pay via {method}",
|
||||
"PLATEGA_PAYMENT_ALREADY_CONFIRMED": "✅ Payment already credited",
|
||||
"PLATEGA_PAYMENT_STATUS": "Current payment status: {status}",
|
||||
"PAY_NOW_BUTTON": "💳 Pay",
|
||||
"PAY_WITH_COINS_BUTTON": "🪙 Pay",
|
||||
"PENDING_CANCEL_BUTTON": "⌛ Cancel",
|
||||
@@ -1148,11 +1118,7 @@
|
||||
"REFERRAL_CODE_APPLIED": "🎁 Referral code applied! You will receive a bonus after the first purchase.",
|
||||
"REFERRAL_CODE_INVALID": "❌ Invalid referral code",
|
||||
"REFERRAL_CODE_INVALID_HELP": "❌ Invalid referral code.\n\n💡 If you have a referral code, please double-check the spelling.\n⏭️ To continue without a referral code, use the /start command.",
|
||||
"REFERRAL_CODE_QUESTION": "\n🤝 <b>Do you have a referral code or promo code?</b>\n\nIf you have a promo code or referral link from a friend, enter it now to receive a bonus!\n\nSend the code or tap \"Skip\":\n",
|
||||
"REFERRAL_OR_PROMO_CODE_INVALID": "❌ Invalid referral code or promo code",
|
||||
"REFERRAL_OR_PROMO_CODE_INVALID_HELP": "❌ Invalid referral code or promo code.\n\n💡 If you have a referral code or promo code, please double-check the spelling.\n⏭️ To continue registration without a code, use the /start command.",
|
||||
"PROMOCODE_ACTIVATED_AT_REGISTRATION": "✅ Promo code activated!\n\n{description}",
|
||||
"PROMOCODE_ACCEPTED_WILL_ACTIVATE": "✅ Promo code accepted! It will be activated after registration is complete.",
|
||||
"REFERRAL_CODE_QUESTION": "\n🤝 <b>Do you have a friend's referral code?</b>\n\nIf you have a promo code or referral link, enter it now to receive a bonus!\n\nSend the code or tap \"Skip\":\n",
|
||||
"REFERRAL_CODE_SKIP": "⏭️ Skip",
|
||||
"REFERRAL_CODE_TITLE": "🆔 <b>Your code:</b> <code>{code}</code>",
|
||||
"REFERRAL_EARNINGS_BY_TYPE_HEADER": "📈 <b>Earnings by type:</b>",
|
||||
@@ -1244,7 +1210,7 @@
|
||||
"SKIP_BUTTON": "Skip ➡️",
|
||||
"STARS_PAYMENT_ENROLLMENT_ERROR": "❌ Failed to credit funds. Please contact support; the payment will be verified manually.",
|
||||
"STARS_PAYMENT_PROCESSING_ERROR": "❌ Technical error processing the payment. Please contact support for assistance.",
|
||||
"STARS_PAYMENT_SUCCESS": "🎉 <b>Payment processed successfully!</b>\n\n⭐ Stars spent: {stars_spent}\n💰 Added to balance: {amount} ₽\n🆔 Transaction ID: {transaction_id}...\n\n⚠️ <b>Important:</b> Balance top-up does not automatically activate your subscription. Please activate your subscription separately!\n\n🔄 If you have a saved subscription cart and auto-purchase is enabled, your subscription will be automatically purchased after balance top-up.\n\nThank you for topping up! 🚀",
|
||||
"STARS_PAYMENT_SUCCESS": "🎉 <b>Payment processed successfully!</b>\n\n⭐ Stars spent: {stars_spent}\n💰 Added to balance: {amount} ₽\n🆔 Transaction ID: {transaction_id}...\n\nThank you for topping up! 🚀",
|
||||
"STARS_PAYMENT_USER_NOT_FOUND": "❌ Error: user not found. Please contact support.",
|
||||
"STARS_PRECHECK_INVALID_PAYLOAD": "Payment validation error. Please try again.",
|
||||
"STARS_PRECHECK_TECHNICAL_ERROR": "Technical error. Please try again later.",
|
||||
@@ -1339,8 +1305,6 @@
|
||||
"SUB_STATUS_ACTIVE_TODAY": "💎 Active\n⚠️ expires today!",
|
||||
"SUB_STATUS_ACTIVE_TOMORROW": "💎 Active\n⚠️ expires tomorrow!",
|
||||
"SUB_STATUS_EXPIRED": "🔴 Expired\n📅 {end_date}",
|
||||
"SUB_STATUS_DISABLED": "⚫ Disabled",
|
||||
"SUB_STATUS_PENDING": "⏳ Pending activation",
|
||||
"SUB_STATUS_NONE": "❌ Not available",
|
||||
"SUB_STATUS_TRIAL_ACTIVE": "🎁 Trial subscription\n📅 until {end_date} ({days} days)",
|
||||
"SUB_STATUS_TRIAL_TODAY": "🎁 Trial subscription\n⚠️ expires today!",
|
||||
@@ -1411,19 +1375,10 @@
|
||||
"TRAFFIC_NO_CHANGE": "ℹ️ Traffic limit was not changed",
|
||||
"TRAFFIC_PACKAGES_NOT_CONFIGURED": "⚠️ Traffic packages are not configured",
|
||||
"TRAFFIC_UNLIMITED": "📊 Unlimited - {settings.format_price(settings.PRICE_TRAFFIC_UNLIMITED)}",
|
||||
"TRAFFIC_UNLIMITED_SHORT": "Unlimited",
|
||||
"TRIAL_ACTIVATED": "🎉 Trial subscription activated!",
|
||||
"TRIAL_ACTIVATE_BUTTON": "🎁 Activate",
|
||||
"TRIAL_ALREADY_USED": "❌ The trial subscription has already been used",
|
||||
"TRIAL_AVAILABLE": "\n🎁 <b>Trial subscription</b>\n\nYou can get a trial plan{price_line}:\n\n⏰ <b>Duration:</b> {days} days\n📈 <b>Traffic:</b> {traffic}{devices_line}\n🌍 <b>Server:</b> {server_name}\n\nActivate the trial subscription?\n",
|
||||
"TRIAL_AVAILABLE_DEVICES_LINE": "\n📱 <b>Devices:</b> {devices} pcs",
|
||||
"TRIAL_PAYMENT_PRICE_LINE": "\n💳 <b>Activation price:</b> {price}",
|
||||
"TRIAL_PAYMENT_INSUFFICIENT_FUNDS": "⚠️ Not enough funds to activate the trial.\nRequired: {required}\nOn balance: {balance}\nMissing: {missing}\n\nTop up your balance and try again.",
|
||||
"TRIAL_PAYMENT_FAILED": "We couldn't charge your balance to activate the trial. Please try again later.",
|
||||
"TRIAL_PROVISIONING_FAILED": "We couldn't finish setting up the trial. Any charge has been refunded. Please try again later.",
|
||||
"TRIAL_ROLLBACK_FAILED": "We couldn't cancel the trial activation after a payment error. Please contact support and try again later.",
|
||||
"TRIAL_REFUND_FAILED": "We couldn't refund the trial activation charge. Please contact support immediately.",
|
||||
"TRIAL_PAYMENT_CHARGED_NOTE": "💳 {amount} has been deducted from your balance.",
|
||||
"TRIAL_AVAILABLE": "\n🎁 <b>Trial subscription</b>\n\nYou can get a free trial plan:\n\n⏰ <b>Duration:</b> {days} days\n📈 <b>Traffic:</b> {traffic} GB\n📱 <b>Devices:</b> {devices} pcs\n🌍 <b>Server:</b> {server_name}\n\nActivate the trial subscription?\n",
|
||||
"TRIAL_CHANNEL_UNSUBSCRIBED": "\n🚫 <b>Access paused</b>\n\nWe couldn't find your subscription to our channel, so the trial plan has been disabled.\n\nJoin the channel and tap “{check_button}” to restore access.",
|
||||
"TRIAL_ENDING_SOON": "\n🎁 <b>The trial subscription is ending soon!</b>\n\nYour trial expires in a few hours.\n\n💎 <b>Don't want to lose VPN access?</b>\nSwitch to the full subscription!\n\n🔥 <b>Special offer:</b>\n• 30 days for {price}\n• Unlimited traffic\n• All servers available\n• Speeds up to 1 Gbit/s\n\n⚡️ Activate before the trial ends!\n",
|
||||
"TRIAL_INACTIVE_1H": "⏳ <b>An hour has passed and we haven't seen any traffic yet</b>\n\nOpen the connection guide and follow the steps. We're always ready to help!",
|
||||
@@ -1475,7 +1430,6 @@
|
||||
"ADMIN_POLLS_CREATION_TITLE_PROMPT": "🗳️ <b>Create poll</b>\n\nEnter poll title:",
|
||||
"ADMIN_POLLS_CREATION_CANCELLED": "❌ Poll creation cancelled.",
|
||||
"ADMIN_POLLS_CREATION_DESCRIPTION_PROMPT": "Enter poll description. HTML is allowed.\nSend /skip to omit.",
|
||||
"ADMIN_POLLS_CREATION_DESCRIPTION_SKIPPED": "Description omitted.",
|
||||
"ADMIN_POLLS_CREATION_INVALID_HTML": "❌ HTML error: {error}",
|
||||
"ADMIN_POLLS_CREATION_REWARD_PROMPT": "Enter reward amount in RUB. Send 0 to disable reward.",
|
||||
"ADMIN_POLLS_CREATION_REWARD_INVALID": "❌ Invalid amount. Try again.",
|
||||
|
||||
@@ -314,7 +314,6 @@
|
||||
"ADMIN_PROMOCODE_TYPE_BALANCE": "💰 Баланс",
|
||||
"ADMIN_PROMOCODE_TYPE_DAYS": "📅 Дни подписки",
|
||||
"ADMIN_PROMOCODE_TYPE_TRIAL": "🎁 Триал",
|
||||
"ADMIN_PROMOCODE_TYPE_PROMO_GROUP": "🏷️ Промогруппа",
|
||||
"ADMIN_PROMO_GROUPS": "💳 Промогруппы",
|
||||
"ADMIN_PROMO_GROUPS_DEFAULT_LABEL": " (базовая)",
|
||||
"ADMIN_PROMO_GROUPS_EMPTY": "Промогруппы не найдены.",
|
||||
@@ -372,17 +371,6 @@
|
||||
"ADMIN_PROMO_GROUP_TOGGLE_ADDON_DISCOUNT_DISABLE": "🧩 Отключить скидки на доп. услуги",
|
||||
"ADMIN_PROMO_GROUP_TOGGLE_ADDON_DISCOUNT_ENABLE": "🧩 Включить скидки на доп. услуги",
|
||||
"ADMIN_PROMO_GROUP_UPDATED": "Промогруппа «{name}» обновлена.",
|
||||
"ADMIN_PROMO_GROUP_PRIORITY_LINE": "🎯 Приоритет: {priority}",
|
||||
"ADMIN_PROMO_GROUP_CREATE_PRIORITY_PROMPT": "Введите приоритет группы (0 = базовая, чем больше - тем выше приоритет):",
|
||||
"ADMIN_PROMO_GROUP_EDIT_PRIORITY_PROMPT": "Введите новый приоритет (текущий: {current}):",
|
||||
"ADMIN_PROMO_GROUP_EDIT_FIELD_PRIORITY": "🎯 Приоритет",
|
||||
"ADMIN_PROMO_GROUP_INVALID_PRIORITY": "❌ Приоритет должен быть неотрицательным целым числом",
|
||||
"ADMIN_USER_PROMO_GROUPS_PRIMARY": "⭐ Основная: {name} (Priority: {priority})",
|
||||
"ADMIN_USER_PROMO_GROUPS_ADDITIONAL": "Дополнительные группы:",
|
||||
"ADMIN_USER_PROMO_GROUPS_NONE": "У пользователя нет промогрупп",
|
||||
"ADMIN_USER_PROMO_GROUP_ADDED": "✅ Группа «{name}» добавлена",
|
||||
"ADMIN_USER_PROMO_GROUP_REMOVED": "🗑 Группа «{name}» удалена",
|
||||
"ADMIN_USER_PROMO_GROUP_CANNOT_REMOVE_LAST": "❌ Нельзя удалить последнюю промогруппу",
|
||||
"ADMIN_PROMO_OFFERS_TITLE": "🎯 <b>Промо-предложения</b>\n\nВыберите предложение для настройки:",
|
||||
"ADMIN_PROMO_OFFER_ACTIVE_DURATION": "Скидка после активации действует {hours} ч.",
|
||||
"ADMIN_PROMO_OFFER_ALLOWED": "Доступные категории:",
|
||||
@@ -725,26 +713,6 @@
|
||||
"ADMIN_USER_PROMO_GROUP_ALREADY": "ℹ️ Пользователь уже состоит в этой промогруппе.",
|
||||
"ADMIN_USER_PROMO_GROUP_BACK": "⬅️ К пользователю",
|
||||
"ADMIN_USER_PROMO_GROUP_BUTTON": "👥 Промогруппа",
|
||||
"ADMIN_USER_REFERRALS_BUTTON": "🤝 Рефералы",
|
||||
"ADMIN_USER_REFERRALS_TITLE": "🤝 <b>Рефералы пользователя</b>",
|
||||
"ADMIN_USER_REFERRALS_SUMMARY": "👤 {name} (ID: <code>{telegram_id}</code>)\n👥 Всего рефералов: {count}",
|
||||
"ADMIN_USER_REFERRALS_LIST_HEADER": "<b>Список рефералов:</b>",
|
||||
"ADMIN_USER_REFERRALS_LIST_ITEM": "• {name} (ID: <code>{telegram_id}</code>{username_part})",
|
||||
"ADMIN_USER_REFERRALS_LIST_TRUNCATED": "• … и ещё {count} рефералов",
|
||||
"ADMIN_USER_REFERRALS_EMPTY": "Рефералов пока нет.",
|
||||
"ADMIN_USER_REFERRALS_EDIT_HINT": "✏️ Чтобы изменить список, нажмите «✏️ Редактировать» ниже.",
|
||||
"ADMIN_USER_REFERRALS_EDIT_BUTTON": "✏️ Редактировать",
|
||||
"ADMIN_USER_REFERRALS_EDIT_PROMPT": "✏️ <b>Редактирование рефералов</b>\n\nОтправьте список рефералов для пользователя <b>{name}</b> (ID: <code>{telegram_id}</code>):\n• Используйте TG ID или @username\n• Значения можно указывать через запятую, пробел или с новой строки\n• Чтобы очистить список, отправьте 0 или слово 'нет'\n\nИли нажмите кнопку ниже, чтобы отменить.",
|
||||
"ADMIN_USER_REFERRALS_STATE_LOST": "❌ Не удалось определить пользователя. Попробуйте начать сначала.",
|
||||
"ADMIN_USER_REFERRALS_NO_VALID": "❌ Не удалось найти ни одного пользователя по введённым данным.",
|
||||
"ADMIN_USER_REFERRALS_INVALID_ENTRIES": "Не найдены: {values}",
|
||||
"ADMIN_USER_REFERRALS_SELF_SKIPPED": "Пропущены значения пользователя: {values}",
|
||||
"ADMIN_USER_REFERRALS_DUPLICATES": "Игнорированы дубли: {values}",
|
||||
"ADMIN_USER_REFERRALS_UPDATE_ERROR": "❌ Не удалось обновить рефералов. Попробуйте позже.",
|
||||
"ADMIN_USER_REFERRALS_UPDATED": "✅ Список рефералов обновлён.",
|
||||
"ADMIN_USER_REFERRALS_UPDATED_TOTAL": "• Текущий список: {total}",
|
||||
"ADMIN_USER_REFERRALS_UPDATED_ADDED": "• Добавлено: {count}",
|
||||
"ADMIN_USER_REFERRALS_UPDATED_REMOVED": "• Удалено: {count}",
|
||||
"ADMIN_USER_PROMO_GROUP_CURRENT": "Текущая группа: {name}",
|
||||
"ADMIN_USER_PROMO_GROUP_CURRENT_NONE": "Текущая группа: не назначена",
|
||||
"ADMIN_USER_PROMO_GROUP_DISCOUNTS": "Скидки — серверы: {servers}%, трафик: {traffic}%, устройства: {devices}%",
|
||||
@@ -888,9 +856,6 @@
|
||||
"DEVICE_CHANGE_NO_REFUND": "Возврат средств не производится",
|
||||
"DEVICE_CHANGE_NO_REFUND_INFO": "ℹ️ Возврат средств не производится",
|
||||
"DEVICE_CHANGE_RESULT_LINE": "📱 Было: {old} → Стало: {new}\n",
|
||||
"DEVICES_INVALID_REQUEST": "❌ Некорректный запрос",
|
||||
"DEVICES_INVALID_COUNT": "❌ Некорректное количество устройств",
|
||||
"DEVICES_SELECTION_DISABLED": "⚠️ Выбор количества устройств недоступен",
|
||||
"DEVICE_CONNECTION_HELP": "❓ Как подключить устройство заново?",
|
||||
"DEVICE_FETCH_ERROR": "❌ Ошибка получения устройств",
|
||||
"DEVICE_FETCH_INFO_ERROR": "❌ Ошибка получения информации об устройствах",
|
||||
@@ -1047,7 +1012,6 @@
|
||||
"PAYMENT_CARD_TRIBUTE": "💳 Банковская карта (Tribute)",
|
||||
"PAYMENT_CARD_WATA": "💳 Банковская карта (WATA)",
|
||||
"PAYMENT_CARD_YOOKASSA": "💳 Банковская карта (YooKassa)",
|
||||
"PAYMENT_PLATEGA": "💳 Platega",
|
||||
"PAYMENT_CHARGE_ERROR": "⚠️ Ошибка списания средств",
|
||||
"PAYMENT_CRYPTOBOT": "🪙 Криптовалюта (CryptoBot)",
|
||||
"PAYMENT_HELEKET": "🪙 Криптовалюта (Heleket)",
|
||||
@@ -1081,8 +1045,6 @@
|
||||
"PAYMENT_METHOD_MULENPAY_NAME": "💳 <b>Банковская карта ({mulenpay_name})</b>",
|
||||
"PAYMENT_METHOD_PAL24_DESCRIPTION": "через систему быстрых платежей",
|
||||
"PAYMENT_METHOD_PAL24_NAME": "🏦 <b>СБП (PayPalych)</b>",
|
||||
"PAYMENT_METHOD_PLATEGA_DESCRIPTION": "через Platega (карты + СБП)",
|
||||
"PAYMENT_METHOD_PLATEGA_NAME": "💳 <b>Банковская карта (Platega)</b>",
|
||||
"PAYMENT_METHOD_STARS_DESCRIPTION": "быстро и удобно",
|
||||
"PAYMENT_METHOD_STARS_NAME": "⭐ <b>Telegram Stars</b>",
|
||||
"PAYMENT_METHOD_SUPPORT_DESCRIPTION": "другие способы",
|
||||
@@ -1101,18 +1063,6 @@
|
||||
"PAYMENT_SBP_YOOKASSA": "🏬 Оплатить по СБП (YooKassa)",
|
||||
"PAYMENT_TELEGRAM_STARS": "⭐ Telegram Stars",
|
||||
"PAYMENT_VIA_SUPPORT": "🛠️ Через поддержку",
|
||||
"PLATEGA_TOPUP_PROMPT": "💳 <b>Оплата через Platega ({method_name})</b>\n\nВведите сумму для пополнения от {min_amount} до {max_amount}.\nОплата происходит через Platega.",
|
||||
"PLATEGA_SELECT_PAYMENT_METHOD": "Выберите способ оплаты Platega:",
|
||||
"PLATEGA_TEMPORARILY_UNAVAILABLE": "❌ Оплата через Platega временно недоступна",
|
||||
"PLATEGA_METHODS_NOT_CONFIGURED": "⚠️ На стороне Platega нет доступных методов оплаты",
|
||||
"PLATEGA_METHOD_SELECTION_REQUIRED": "⚠️ Выберите способ оплаты Platega перед вводом суммы",
|
||||
"PLATEGA_AMOUNT_TOO_LOW": "Минимальная сумма для оплаты через Platega: {amount}",
|
||||
"PLATEGA_AMOUNT_TOO_HIGH": "Максимальная сумма для оплаты через Platega: {amount}",
|
||||
"PLATEGA_PAYMENT_ERROR": "❌ Ошибка создания платежа Platega. Попробуйте позже или обратитесь в поддержку.",
|
||||
"PLATEGA_PAYMENT_INSTRUCTIONS": "💳 <b>Оплата через Platega ({method})</b>\n\n💰 Сумма: {amount}\n🆔 ID транзакции: {transaction}\n\n📱 <b>Инструкция:</b>\n1. Нажмите кнопку «Оплатить»\n2. Следуйте подсказкам платёжной системы\n3. Подтвердите перевод\n4. Средства зачислятся автоматически\n\n❓ Если возникнут проблемы, обратитесь в {support}",
|
||||
"PLATEGA_PAY_BUTTON": "💳 Оплатить через {method}",
|
||||
"PLATEGA_PAYMENT_ALREADY_CONFIRMED": "✅ Платёж уже зачислен",
|
||||
"PLATEGA_PAYMENT_STATUS": "Текущий статус платежа: {status}",
|
||||
"PAY_NOW_BUTTON": "💳 Оплатить",
|
||||
"PAY_WITH_COINS_BUTTON": "🪙 Оплатить",
|
||||
"PENDING_CANCEL_BUTTON": "⌛ Отмена",
|
||||
@@ -1168,11 +1118,7 @@
|
||||
"REFERRAL_CODE_APPLIED": "🎁 Реферальный код применен! Вы получите бонус после первой покупки.",
|
||||
"REFERRAL_CODE_INVALID": "❌ Неверный реферальный код",
|
||||
"REFERRAL_CODE_INVALID_HELP": "❌ Неверный реферальный код.\n\n💡 Если у вас есть реферальный код, убедитесь что он введен правильно.\n⏭️ Для продолжения регистрации без реферального кода используйте команду /start",
|
||||
"REFERRAL_CODE_QUESTION": "\n🤝 <b>У вас есть реферальный код или промокод?</b>\n\nЕсли у вас есть промокод или реферальная ссылка от друга, введите её сейчас, чтобы получить бонус!\n\nВведите код или нажмите \"Пропустить\":\n",
|
||||
"REFERRAL_OR_PROMO_CODE_INVALID": "❌ Неверный реферальный код или промокод",
|
||||
"REFERRAL_OR_PROMO_CODE_INVALID_HELP": "❌ Неверный реферальный код или промокод.\n\n💡 Если у вас есть реферальный код или промокод, убедитесь что он введен правильно.\n⏭️ Для продолжения регистрации без кода используйте команду /start",
|
||||
"PROMOCODE_ACTIVATED_AT_REGISTRATION": "✅ Промокод активирован!\n\n{description}",
|
||||
"PROMOCODE_ACCEPTED_WILL_ACTIVATE": "✅ Промокод принят! Он будет активирован после завершения регистрации.",
|
||||
"REFERRAL_CODE_QUESTION": "\n🤝 <b>У вас есть реферальный код от друга?</b>\n\nЕсли у вас есть промокод или реферальная ссылка от друга, введите её сейчас, чтобы получить бонус!\n\nВведите код или нажмите \"Пропустить\":\n",
|
||||
"REFERRAL_CODE_SKIP": "⏭️ Пропустить",
|
||||
"REFERRAL_CODE_TITLE": "🆔 <b>Ваш код:</b> <code>{code}</code>",
|
||||
"REFERRAL_EARNINGS_BY_TYPE_HEADER": "📈 <b>Доходы по типам:</b>",
|
||||
@@ -1264,7 +1210,7 @@
|
||||
"SKIP_BUTTON": "⏭️ Пропустить",
|
||||
"STARS_PAYMENT_ENROLLMENT_ERROR": "❌ Произошла ошибка при зачислении средств. Обратитесь в поддержку, платеж будет проверен вручную.",
|
||||
"STARS_PAYMENT_PROCESSING_ERROR": "❌ Техническая ошибка при обработке платежа. Обратитесь в поддержку для решения проблемы.",
|
||||
"STARS_PAYMENT_SUCCESS": "🎉 <b>Платеж успешно обработан!</b>\n\n⭐ Потрачено звезд: {stars_spent}\n💰 Зачислено на баланс: {amount} ₽\n\n🆔 ID транзакции: {transaction_id}...\n\n⚠️ <b>Важно:</b> Пополнение баланса не активирует подписку автоматически. Обязательно активируйте подписку отдельно!\n\n🔄 При наличии сохранённой корзины подписки и включенной автопокупке, подписка будет приобретена автоматически после пополнения баланса.\n\nСпасибо за пополнение! 🚀",
|
||||
"STARS_PAYMENT_SUCCESS": "🎉 <b>Платеж успешно обработан!</b>\n\n⭐ Потрачено звезд: {stars_spent}\n💰 Зачислено на баланс: {amount} ₽\n🆔 ID транзакции: {transaction_id}...\n\nСпасибо за пополнение! 🚀",
|
||||
"STARS_PAYMENT_USER_NOT_FOUND": "❌ Ошибка: пользователь не найден. Обратитесь в поддержку.",
|
||||
"STARS_PRECHECK_INVALID_PAYLOAD": "Ошибка валидации платежа. Попробуйте еще раз.",
|
||||
"STARS_PRECHECK_TECHNICAL_ERROR": "Техническая ошибка. Попробуйте позже.",
|
||||
@@ -1359,8 +1305,6 @@
|
||||
"SUB_STATUS_ACTIVE_TODAY": "💎 Активна\n⚠️ истекает сегодня!",
|
||||
"SUB_STATUS_ACTIVE_TOMORROW": "💎 Активна\n⚠️ истекает завтра!",
|
||||
"SUB_STATUS_EXPIRED": "🔴 Истекла\n📅 {end_date}",
|
||||
"SUB_STATUS_DISABLED": "⚫ Отключена",
|
||||
"SUB_STATUS_PENDING": "⏳ Ожидает активации",
|
||||
"SUB_STATUS_NONE": "❌ Отсутствует",
|
||||
"SUB_STATUS_TRIAL_ACTIVE": "🎁 Тестовая подписка\n📅 до {end_date} ({days} дн.)",
|
||||
"SUB_STATUS_TRIAL_TODAY": "🎁 Тестовая подписка\n⚠️ истекает сегодня!",
|
||||
@@ -1431,19 +1375,10 @@
|
||||
"TRAFFIC_NO_CHANGE": "ℹ️ Лимит трафика не изменился",
|
||||
"TRAFFIC_PACKAGES_NOT_CONFIGURED": "⚠️ Пакеты трафика не настроены",
|
||||
"TRAFFIC_UNLIMITED": "📊 Безлимит - {settings.format_price(settings.PRICE_TRAFFIC_UNLIMITED)}",
|
||||
"TRAFFIC_UNLIMITED_SHORT": "Безлимит",
|
||||
"TRIAL_ACTIVATED": "🎉 Тестовая подписка активирована!",
|
||||
"TRIAL_ACTIVATE_BUTTON": "🎁 Активировать",
|
||||
"TRIAL_ALREADY_USED": "❌ Тестовая подписка уже была использована",
|
||||
"TRIAL_AVAILABLE": "\n🎁 <b>Тестовая подписка</b>\n\nВы можете получить тестовую подписку{price_line}:\n\n⏰ <b>Период:</b> {days} дней\n📈 <b>Трафик:</b> {traffic}{devices_line}\n🌍 <b>Сервер:</b> {server_name}\n\nАктивировать тестовую подписку?\n",
|
||||
"TRIAL_AVAILABLE_DEVICES_LINE": "\n📱 <b>Устройства:</b> {devices} шт.",
|
||||
"TRIAL_PAYMENT_PRICE_LINE": "\n💳 <b>Стоимость активации:</b> {price}",
|
||||
"TRIAL_PAYMENT_INSUFFICIENT_FUNDS": "⚠️ Недостаточно средств для активации триала.\nНеобходимо: {required}\nНа балансе: {balance}\nНе хватает: {missing}\n\nПополните баланс и попробуйте снова.",
|
||||
"TRIAL_PAYMENT_FAILED": "Не удалось списать средства для активации триала. Попробуйте позже.",
|
||||
"TRIAL_PROVISIONING_FAILED": "Не удалось завершить активацию триала. Средства возвращены на баланс. Попробуйте позже.",
|
||||
"TRIAL_ROLLBACK_FAILED": "Не удалось отменить активацию триала после ошибки списания. Свяжитесь с поддержкой и попробуйте позже.",
|
||||
"TRIAL_REFUND_FAILED": "Не удалось вернуть оплату за активацию триала. Немедленно свяжитесь с поддержкой.",
|
||||
"TRIAL_PAYMENT_CHARGED_NOTE": "💳 С вашего баланса списано {amount}.",
|
||||
"TRIAL_AVAILABLE": "\n🎁 <b>Тестовая подписка</b>\n\nВы можете получить бесплатную тестовую подписку:\n\n⏰ <b>Период:</b> {days} дней\n📈 <b>Трафик:</b> {traffic} ГБ\n📱 <b>Устройства:</b> {devices} шт.\n🌍 <b>Сервер:</b> {server_name}\n\nАктивировать тестовую подписку?\n",
|
||||
"TRIAL_CHANNEL_UNSUBSCRIBED": "\n🚫 <b>Доступ приостановлен</b>\n\nМы не нашли вашу подписку на наш канал, поэтому тестовая подписка отключена.\n\nПодпишитесь на канал и нажмите «{check_button}», чтобы вернуть доступ.",
|
||||
"TRIAL_ENDING_SOON": "\n🎁 <b>Тестовая подписка скоро закончится!</b>\n\nВаша тестовая подписка истекает через несколько часов.\n\n💎 <b>Не хотите остаться без VPN?</b>\nПереходите на полную подписку!\n\n🔥 <b>Специальное предложение:</b>\n• 30 дней всего за {price}\n• Безлимитный трафик \n• Все серверы доступны\n• Скорость до 1ГБит/сек\n\n⚡️ Успейте оформить до окончания тестового периода!\n",
|
||||
"TRIAL_INACTIVE_1H": "⏳ <b>Прошёл час, а подключение не выполнено</b>\n\nЕсли возникли сложности — откройте инструкцию и следуйте шагам. Мы всегда готовы помочь!",
|
||||
@@ -1495,7 +1430,6 @@
|
||||
"ADMIN_POLLS_CREATION_TITLE_PROMPT": "🗳️ <b>Создание опроса</b>\n\nВведите заголовок опроса:",
|
||||
"ADMIN_POLLS_CREATION_CANCELLED": "❌ Создание опроса отменено.",
|
||||
"ADMIN_POLLS_CREATION_DESCRIPTION_PROMPT": "Введите описание опроса. HTML разрешён.\nОтправьте /skip, чтобы пропустить.",
|
||||
"ADMIN_POLLS_CREATION_DESCRIPTION_SKIPPED": "Описание пропущено.",
|
||||
"ADMIN_POLLS_CREATION_INVALID_HTML": "❌ Ошибка в HTML: {error}",
|
||||
"ADMIN_POLLS_CREATION_REWARD_PROMPT": "Введите сумму награды в рублях. Отправьте 0 чтобы отключить награду.",
|
||||
"ADMIN_POLLS_CREATION_REWARD_INVALID": "❌ Некорректная сумма. Попробуйте ещё раз.",
|
||||
|
||||
@@ -30,6 +30,12 @@ def _build_dynamic_values(language: str) -> Dict[str, Any]:
|
||||
|
||||
if language_code == "ru":
|
||||
return {
|
||||
"PERIOD_14_DAYS": f"📅 14 дней - {settings.format_price(settings.PRICE_14_DAYS)}",
|
||||
"PERIOD_30_DAYS": f"📅 30 дней - {settings.format_price(settings.PRICE_30_DAYS)}",
|
||||
"PERIOD_60_DAYS": f"📅 60 дней - {settings.format_price(settings.PRICE_60_DAYS)}",
|
||||
"PERIOD_90_DAYS": f"📅 90 дней - {settings.format_price(settings.PRICE_90_DAYS)}",
|
||||
"PERIOD_180_DAYS": f"📅 180 дней - {settings.format_price(settings.PRICE_180_DAYS)}",
|
||||
"PERIOD_360_DAYS": f"📅 360 дней - {settings.format_price(settings.PRICE_360_DAYS)}",
|
||||
"TRAFFIC_5GB": f"📊 5 ГБ - {settings.format_price(settings.PRICE_TRAFFIC_5GB)}",
|
||||
"TRAFFIC_10GB": f"📊 10 ГБ - {settings.format_price(settings.PRICE_TRAFFIC_10GB)}",
|
||||
"TRAFFIC_25GB": f"📊 25 ГБ - {settings.format_price(settings.PRICE_TRAFFIC_25GB)}",
|
||||
@@ -49,6 +55,12 @@ def _build_dynamic_values(language: str) -> Dict[str, Any]:
|
||||
|
||||
if language_code == "en":
|
||||
return {
|
||||
"PERIOD_14_DAYS": f"📅 14 days - {settings.format_price(settings.PRICE_14_DAYS)}",
|
||||
"PERIOD_30_DAYS": f"📅 30 days - {settings.format_price(settings.PRICE_30_DAYS)}",
|
||||
"PERIOD_60_DAYS": f"📅 60 days - {settings.format_price(settings.PRICE_60_DAYS)}",
|
||||
"PERIOD_90_DAYS": f"📅 90 days - {settings.format_price(settings.PRICE_90_DAYS)}",
|
||||
"PERIOD_180_DAYS": f"📅 180 days - {settings.format_price(settings.PRICE_180_DAYS)}",
|
||||
"PERIOD_360_DAYS": f"📅 360 days - {settings.format_price(settings.PRICE_360_DAYS)}",
|
||||
"TRAFFIC_5GB": f"📊 5 GB - {settings.format_price(settings.PRICE_TRAFFIC_5GB)}",
|
||||
"TRAFFIC_10GB": f"📊 10 GB - {settings.format_price(settings.PRICE_TRAFFIC_10GB)}",
|
||||
"TRAFFIC_25GB": f"📊 25 GB - {settings.format_price(settings.PRICE_TRAFFIC_25GB)}",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import logging
|
||||
from typing import Callable, Dict, Any, Awaitable, Optional
|
||||
from aiogram import BaseMiddleware, Bot, types
|
||||
from aiogram import BaseMiddleware, Bot
|
||||
from aiogram.exceptions import TelegramForbiddenError, TelegramBadRequest
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from aiogram.types import TelegramObject, Update, Message, CallbackQuery
|
||||
@@ -8,7 +8,6 @@ from aiogram.enums import ChatMemberStatus
|
||||
|
||||
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
|
||||
from app.database.crud.user import get_user_by_telegram_id
|
||||
from app.database.models import SubscriptionStatus
|
||||
@@ -17,7 +16,6 @@ from app.localization.loader import DEFAULT_LANGUAGE
|
||||
from app.localization.texts import get_texts
|
||||
from app.utils.check_reg_process import is_registration_process
|
||||
from app.services.subscription_service import SubscriptionService
|
||||
from app.services.admin_notification_service import AdminNotificationService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -106,7 +104,7 @@ class ChannelCheckerMiddleware(BaseMiddleware):
|
||||
if telegram_id:
|
||||
await self._deactivate_trial_subscription(telegram_id)
|
||||
|
||||
await self._capture_start_payload(state, event, bot)
|
||||
await self._capture_start_payload(state, event)
|
||||
|
||||
if isinstance(event, CallbackQuery) and event.data == "sub_channel_check":
|
||||
await event.answer("❌ Вы еще не подписались на канал! Подпишитесь и попробуйте снова.", show_alert=True)
|
||||
@@ -115,12 +113,12 @@ class ChannelCheckerMiddleware(BaseMiddleware):
|
||||
return await self._deny_message(event, bot, channel_link)
|
||||
else:
|
||||
logger.warning(f"⚠️ Неожиданный статус пользователя {telegram_id}: {member.status}")
|
||||
await self._capture_start_payload(state, event, bot)
|
||||
await self._capture_start_payload(state, event)
|
||||
return await self._deny_message(event, bot, channel_link)
|
||||
|
||||
except TelegramForbiddenError as e:
|
||||
logger.error(f"❌ Бот заблокирован в канале {channel_id}: {e}")
|
||||
await self._capture_start_payload(state, event, bot)
|
||||
await self._capture_start_payload(state, event)
|
||||
return await self._deny_message(event, bot, channel_link)
|
||||
except TelegramBadRequest as e:
|
||||
if "chat not found" in str(e).lower():
|
||||
@@ -129,18 +127,13 @@ class ChannelCheckerMiddleware(BaseMiddleware):
|
||||
logger.error(f"❌ Пользователь {telegram_id} не найден: {e}")
|
||||
else:
|
||||
logger.error(f"❌ Ошибка запроса к каналу {channel_id}: {e}")
|
||||
await self._capture_start_payload(state, event, bot)
|
||||
await self._capture_start_payload(state, event)
|
||||
return await self._deny_message(event, bot, channel_link)
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Неожиданная ошибка при проверке подписки: {e}")
|
||||
return await handler(event, data)
|
||||
|
||||
async def _capture_start_payload(
|
||||
self,
|
||||
state: Optional[FSMContext],
|
||||
event: TelegramObject,
|
||||
bot: Optional[Bot] = None,
|
||||
) -> None:
|
||||
async def _capture_start_payload(self, state: Optional[FSMContext], event: TelegramObject) -> None:
|
||||
if not state:
|
||||
return
|
||||
|
||||
@@ -166,69 +159,12 @@ 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)
|
||||
logger.debug("💾 Сохранен start payload %s для последующей обработки", payload)
|
||||
|
||||
if bot and message.from_user:
|
||||
await self._try_send_campaign_visit_notification(
|
||||
bot,
|
||||
message.from_user,
|
||||
state,
|
||||
payload,
|
||||
)
|
||||
|
||||
async def _try_send_campaign_visit_notification(
|
||||
self,
|
||||
bot: Bot,
|
||||
telegram_user: types.User,
|
||||
state: FSMContext,
|
||||
payload: str,
|
||||
) -> None:
|
||||
try:
|
||||
data = await state.get_data() or {}
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
"❌ Не удалось получить данные состояния для уведомления по кампании %s: %s",
|
||||
payload,
|
||||
error,
|
||||
)
|
||||
if data.get("pending_start_payload") == payload:
|
||||
return
|
||||
|
||||
if data.get("campaign_notification_sent"):
|
||||
return
|
||||
|
||||
async for db in get_db():
|
||||
try:
|
||||
campaign = await get_campaign_by_start_parameter(
|
||||
db,
|
||||
payload,
|
||||
only_active=True,
|
||||
)
|
||||
if not campaign:
|
||||
break
|
||||
|
||||
user = await get_user_by_telegram_id(db, telegram_user.id)
|
||||
|
||||
notification_service = AdminNotificationService(bot)
|
||||
sent = await notification_service.send_campaign_link_visit_notification(
|
||||
db,
|
||||
telegram_user,
|
||||
campaign,
|
||||
user,
|
||||
)
|
||||
if sent:
|
||||
await state.update_data(campaign_notification_sent=True)
|
||||
break
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
"❌ Ошибка отправки уведомления о переходе по кампании %s: %s",
|
||||
payload,
|
||||
error,
|
||||
)
|
||||
finally:
|
||||
break
|
||||
data["pending_start_payload"] = payload
|
||||
await state.set_data(data)
|
||||
logger.debug("💾 Сохранен start payload %s для последующей обработки", payload)
|
||||
|
||||
async def _deactivate_trial_subscription(self, telegram_id: int) -> None:
|
||||
async for db in get_db():
|
||||
|
||||
@@ -19,7 +19,6 @@ from app.database.models import (
|
||||
TransactionType,
|
||||
User,
|
||||
)
|
||||
from app.utils.timezone import format_local_datetime
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -78,20 +77,6 @@ class AdminNotificationService:
|
||||
)
|
||||
return None
|
||||
|
||||
def _get_user_display(self, user: User) -> str:
|
||||
first_name = getattr(user, "first_name", "") or ""
|
||||
if first_name:
|
||||
return first_name
|
||||
|
||||
username = getattr(user, "username", "") or ""
|
||||
if username:
|
||||
return username
|
||||
|
||||
telegram_id = getattr(user, "telegram_id", None)
|
||||
if telegram_id is None:
|
||||
return "IDUnknown"
|
||||
return f"ID{telegram_id}"
|
||||
|
||||
def _format_promo_group_discounts(self, promo_group: PromoGroup) -> List[str]:
|
||||
discount_lines: List[str] = []
|
||||
|
||||
@@ -190,9 +175,7 @@ class AdminNotificationService:
|
||||
self,
|
||||
db: AsyncSession,
|
||||
user: User,
|
||||
subscription: Subscription,
|
||||
*,
|
||||
charged_amount_kopeks: Optional[int] = None,
|
||||
subscription: Subscription
|
||||
) -> bool:
|
||||
if not self._is_enabled():
|
||||
return False
|
||||
@@ -202,42 +185,26 @@ class AdminNotificationService:
|
||||
referrer_info = await self._get_referrer_info(db, user.referred_by_id)
|
||||
promo_group = await self._get_user_promo_group(db, user)
|
||||
promo_block = self._format_promo_group_block(promo_group)
|
||||
user_display = self._get_user_display(user)
|
||||
|
||||
trial_device_limit = subscription.device_limit
|
||||
if trial_device_limit is None:
|
||||
fallback_forced_limit = settings.get_disabled_mode_device_limit()
|
||||
if fallback_forced_limit is not None:
|
||||
trial_device_limit = fallback_forced_limit
|
||||
else:
|
||||
trial_device_limit = settings.TRIAL_DEVICE_LIMIT
|
||||
|
||||
payment_block = ""
|
||||
if charged_amount_kopeks and charged_amount_kopeks > 0:
|
||||
payment_block = (
|
||||
f"\n💳 <b>Оплата за активацию:</b> {settings.format_price(charged_amount_kopeks)}"
|
||||
)
|
||||
|
||||
message = f"""🎯 <b>АКТИВАЦИЯ ТРИАЛА</b>
|
||||
|
||||
👤 <b>Пользователь:</b> {user_display}
|
||||
👤 <b>Пользователь:</b> {user.full_name}
|
||||
🆔 <b>Telegram ID:</b> <code>{user.telegram_id}</code>
|
||||
📱 <b>Username:</b> @{getattr(user, 'username', None) or 'отсутствует'}
|
||||
📱 <b>Username:</b> @{user.username or 'отсутствует'}
|
||||
👥 <b>Статус:</b> {user_status}
|
||||
|
||||
{promo_block}
|
||||
|
||||
⏰ <b>Параметры триала:</b>
|
||||
📅 Период: {settings.TRIAL_DURATION_DAYS} дней
|
||||
📊 Трафик: {self._format_traffic(settings.TRIAL_TRAFFIC_LIMIT_GB)}
|
||||
📱 Устройства: {trial_device_limit}
|
||||
📊 Трафик: {settings.TRIAL_TRAFFIC_LIMIT_GB} ГБ
|
||||
📱 Устройства: {settings.TRIAL_DEVICE_LIMIT}
|
||||
🌐 Сервер: {subscription.connected_squads[0] if subscription.connected_squads else 'По умолчанию'}
|
||||
{payment_block}
|
||||
|
||||
📆 <b>Действует до:</b> {format_local_datetime(subscription.end_date, '%d.%m.%Y %H:%M')}
|
||||
📆 <b>Действует до:</b> {subscription.end_date.strftime('%d.%m.%Y %H:%M')}
|
||||
🔗 <b>Реферер:</b> {referrer_info}
|
||||
|
||||
⏰ <i>{format_local_datetime(datetime.utcnow(), '%d.%m.%Y %H:%M:%S')}</i>"""
|
||||
⏰ <i>{datetime.now().strftime('%d.%m.%Y %H:%M:%S')}</i>"""
|
||||
|
||||
return await self._send_message(message)
|
||||
|
||||
@@ -260,29 +227,28 @@ class AdminNotificationService:
|
||||
|
||||
try:
|
||||
event_type = "🔄 КОНВЕРСИЯ ИЗ ТРИАЛА" if was_trial_conversion else "💎 ПОКУПКА ПОДПИСКИ"
|
||||
|
||||
|
||||
if was_trial_conversion:
|
||||
user_status = "🎯 Конверсия из триала"
|
||||
elif user.has_had_paid_subscription:
|
||||
user_status = "🔄 Продление/Обновление"
|
||||
else:
|
||||
user_status = "🆕 Первая покупка"
|
||||
|
||||
|
||||
servers_info = await self._get_servers_info(subscription.connected_squads)
|
||||
payment_method = self._get_payment_method_display(transaction.payment_method) if transaction else "Баланс"
|
||||
referrer_info = await self._get_referrer_info(db, user.referred_by_id)
|
||||
promo_group = await self._get_user_promo_group(db, user)
|
||||
promo_block = self._format_promo_group_block(promo_group)
|
||||
user_display = self._get_user_display(user)
|
||||
|
||||
total_amount = amount_kopeks if amount_kopeks is not None else (transaction.amount_kopeks if transaction else 0)
|
||||
transaction_id = transaction.id if transaction else "—"
|
||||
|
||||
message = f"""💎 <b>{event_type}</b>
|
||||
|
||||
👤 <b>Пользователь:</b> {user_display}
|
||||
👤 <b>Пользователь:</b> {user.full_name}
|
||||
🆔 <b>Telegram ID:</b> <code>{user.telegram_id}</code>
|
||||
📱 <b>Username:</b> @{getattr(user, 'username', None) or 'отсутствует'}
|
||||
📱 <b>Username:</b> @{user.username or 'отсутствует'}
|
||||
👥 <b>Статус:</b> {user_status}
|
||||
|
||||
{promo_block}
|
||||
@@ -298,11 +264,11 @@ class AdminNotificationService:
|
||||
📱 Устройства: {subscription.device_limit}
|
||||
🌐 Серверы: {servers_info}
|
||||
|
||||
📆 <b>Действует до:</b> {format_local_datetime(subscription.end_date, '%d.%m.%Y %H:%M')}
|
||||
📆 <b>Действует до:</b> {subscription.end_date.strftime('%d.%m.%Y %H:%M')}
|
||||
💰 <b>Баланс после покупки:</b> {settings.format_price(user.balance_kopeks)}
|
||||
🔗 <b>Реферер:</b> {referrer_info}
|
||||
|
||||
⏰ <i>{format_local_datetime(datetime.utcnow(), '%d.%m.%Y %H:%M:%S')}</i>"""
|
||||
⏰ <i>{datetime.now().strftime('%d.%m.%Y %H:%M:%S')}</i>"""
|
||||
|
||||
return await self._send_message(message)
|
||||
|
||||
@@ -349,7 +315,7 @@ class AdminNotificationService:
|
||||
|
||||
ℹ️ Для обновления перезапустите контейнер с новым тегом или обновите код из репозитория.
|
||||
|
||||
⚙️ <i>Автоматическая проверка обновлений • {format_local_datetime(datetime.utcnow(), '%d.%m.%Y %H:%M:%S')}</i>"""
|
||||
⚙️ <i>Автоматическая проверка обновлений • {datetime.now().strftime('%d.%m.%Y %H:%M:%S')}</i>"""
|
||||
|
||||
return await self._send_message(message)
|
||||
|
||||
@@ -374,7 +340,7 @@ class AdminNotificationService:
|
||||
🔄 Следующая попытка через час.
|
||||
⚙️ Проверьте доступность GitHub API и настройки сети.
|
||||
|
||||
⚙️ <i>Система автоматических обновлений • {format_local_datetime(datetime.utcnow(), '%d.%m.%Y %H:%M:%S')}</i>"""
|
||||
⚙️ <i>Система автоматических обновлений • {datetime.now().strftime('%d.%m.%Y %H:%M:%S')}</i>"""
|
||||
|
||||
return await self._send_message(message)
|
||||
|
||||
@@ -397,14 +363,13 @@ class AdminNotificationService:
|
||||
balance_change = user.balance_kopeks - old_balance
|
||||
subscription_status = self._get_subscription_status(subscription)
|
||||
promo_block = self._format_promo_group_block(promo_group)
|
||||
timestamp = format_local_datetime(datetime.utcnow(), '%d.%m.%Y %H:%M:%S')
|
||||
user_display = self._get_user_display(user)
|
||||
timestamp = datetime.now().strftime('%d.%m.%Y %H:%M:%S')
|
||||
|
||||
return f"""💰 <b>ПОПОЛНЕНИЕ БАЛАНСА</b>
|
||||
|
||||
👤 <b>Пользователь:</b> {user_display}
|
||||
👤 <b>Пользователь:</b> {user.full_name}
|
||||
🆔 <b>Telegram ID:</b> <code>{user.telegram_id}</code>
|
||||
📱 <b>Username:</b> @{getattr(user, 'username', None) or 'отсутствует'}
|
||||
📱 <b>Username:</b> @{user.username or 'отсутствует'}
|
||||
💳 <b>Статус:</b> {topup_status}
|
||||
|
||||
{promo_block}
|
||||
@@ -575,16 +540,15 @@ class AdminNotificationService:
|
||||
servers_info = await self._get_servers_info(subscription.connected_squads)
|
||||
promo_group = await self._get_user_promo_group(db, user)
|
||||
promo_block = self._format_promo_group_block(promo_group)
|
||||
user_display = self._get_user_display(user)
|
||||
|
||||
current_end_date = new_end_date or subscription.end_date
|
||||
current_balance = balance_after if balance_after is not None else user.balance_kopeks
|
||||
|
||||
message = f"""⏰ <b>ПРОДЛЕНИЕ ПОДПИСКИ</b>
|
||||
|
||||
👤 <b>Пользователь:</b> {user_display}
|
||||
👤 <b>Пользователь:</b> {user.full_name}
|
||||
🆔 <b>Telegram ID:</b> <code>{user.telegram_id}</code>
|
||||
📱 <b>Username:</b> @{getattr(user, 'username', None) or 'отсутствует'}
|
||||
📱 <b>Username:</b> @{user.username or 'отсутствует'}
|
||||
|
||||
{promo_block}
|
||||
|
||||
@@ -595,8 +559,8 @@ class AdminNotificationService:
|
||||
|
||||
📅 <b>Продление:</b>
|
||||
➕ Добавлено дней: {extended_days}
|
||||
📆 Было до: {format_local_datetime(old_end_date, '%d.%m.%Y %H:%M')}
|
||||
📆 Стало до: {format_local_datetime(current_end_date, '%d.%m.%Y %H:%M')}
|
||||
📆 Было до: {old_end_date.strftime('%d.%m.%Y %H:%M')}
|
||||
📆 Стало до: {current_end_date.strftime('%d.%m.%Y %H:%M')}
|
||||
|
||||
📱 <b>Текущие параметры:</b>
|
||||
📊 Трафик: {self._format_traffic(subscription.traffic_limit_gb)}
|
||||
@@ -605,7 +569,7 @@ class AdminNotificationService:
|
||||
|
||||
💰 <b>Баланс после операции:</b> {settings.format_price(current_balance)}
|
||||
|
||||
⏰ <i>{format_local_datetime(datetime.utcnow(), '%d.%m.%Y %H:%M:%S')}</i>"""
|
||||
⏰ <i>{datetime.now().strftime('%d.%m.%Y %H:%M:%S')}</i>"""
|
||||
|
||||
return await self._send_message(message)
|
||||
|
||||
@@ -628,14 +592,13 @@ class AdminNotificationService:
|
||||
promo_block = self._format_promo_group_block(promo_group)
|
||||
type_display = self._get_promocode_type_display(promocode_data.get("type"))
|
||||
usage_info = f"{promocode_data.get('current_uses', 0)}/{promocode_data.get('max_uses', 0)}"
|
||||
user_display = self._get_user_display(user)
|
||||
|
||||
message_lines = [
|
||||
"🎫 <b>АКТИВАЦИЯ ПРОМОКОДА</b>",
|
||||
"",
|
||||
f"👤 <b>Пользователь:</b> {user_display}",
|
||||
f"👤 <b>Пользователь:</b> {user.full_name}",
|
||||
f"🆔 <b>Telegram ID:</b> <code>{user.telegram_id}</code>",
|
||||
f"📱 <b>Username:</b> @{getattr(user, 'username', None) or 'отсутствует'}",
|
||||
f"📱 <b>Username:</b> @{user.username or 'отсутствует'}",
|
||||
"",
|
||||
promo_block,
|
||||
"",
|
||||
@@ -658,7 +621,7 @@ class AdminNotificationService:
|
||||
valid_until = promocode_data.get("valid_until")
|
||||
if valid_until:
|
||||
message_lines.append(
|
||||
f"⏳ Действует до: {format_local_datetime(valid_until, '%d.%m.%Y %H:%M')}"
|
||||
f"⏳ Действует до: {valid_until.strftime('%d.%m.%Y %H:%M')}"
|
||||
if isinstance(valid_until, datetime)
|
||||
else f"⏳ Действует до: {valid_until}"
|
||||
)
|
||||
@@ -669,7 +632,7 @@ class AdminNotificationService:
|
||||
"📝 <b>Эффект:</b>",
|
||||
effect_description.strip() or "✅ Промокод активирован",
|
||||
"",
|
||||
f"⏰ <i>{format_local_datetime(datetime.utcnow(), '%d.%m.%Y %H:%M:%S')}</i>",
|
||||
f"⏰ <i>{datetime.now().strftime('%d.%m.%Y %H:%M:%S')}</i>",
|
||||
]
|
||||
)
|
||||
|
||||
@@ -723,7 +686,7 @@ class AdminNotificationService:
|
||||
message_lines.extend(
|
||||
[
|
||||
"",
|
||||
f"⏰ <i>{format_local_datetime(datetime.utcnow(), '%d.%m.%Y %H:%M:%S')}</i>",
|
||||
f"⏰ <i>{datetime.now().strftime('%d.%m.%Y %H:%M:%S')}</i>",
|
||||
]
|
||||
)
|
||||
|
||||
@@ -756,14 +719,13 @@ class AdminNotificationService:
|
||||
)
|
||||
elif automatic:
|
||||
initiator_line = "🤖 Автоматическое назначение"
|
||||
user_display = self._get_user_display(user)
|
||||
|
||||
message_lines = [
|
||||
f"{title}",
|
||||
"",
|
||||
f"👤 <b>Пользователь:</b> {user_display}",
|
||||
f"👤 <b>Пользователь:</b> {user.full_name}",
|
||||
f"🆔 <b>Telegram ID:</b> <code>{user.telegram_id}</code>",
|
||||
f"📱 <b>Username:</b> @{getattr(user, 'username', None) or 'отсутствует'}",
|
||||
f"📱 <b>Username:</b> @{user.username or 'отсутствует'}",
|
||||
"",
|
||||
self._format_promo_group_block(new_group, title="Новая промогруппа", icon="🏆"),
|
||||
]
|
||||
@@ -788,7 +750,7 @@ class AdminNotificationService:
|
||||
[
|
||||
"",
|
||||
f"💰 Баланс пользователя: {settings.format_price(user.balance_kopeks)}",
|
||||
f"⏰ <i>{format_local_datetime(datetime.utcnow(), '%d.%m.%Y %H:%M:%S')}</i>",
|
||||
f"⏰ <i>{datetime.now().strftime('%d.%m.%Y %H:%M:%S')}</i>",
|
||||
]
|
||||
)
|
||||
|
||||
@@ -866,9 +828,9 @@ class AdminNotificationService:
|
||||
return "❌ Нет подписки"
|
||||
|
||||
if subscription.is_trial:
|
||||
return f"🎯 Триал (до {format_local_datetime(subscription.end_date, '%d.%m')})"
|
||||
return f"🎯 Триал (до {subscription.end_date.strftime('%d.%m')})"
|
||||
elif subscription.is_active:
|
||||
return f"✅ Активна (до {format_local_datetime(subscription.end_date, '%d.%m')})"
|
||||
return f"✅ Активна (до {subscription.end_date.strftime('%d.%m')})"
|
||||
else:
|
||||
return "❌ Неактивна"
|
||||
|
||||
@@ -939,9 +901,7 @@ class AdminNotificationService:
|
||||
if isinstance(enabled_at, str):
|
||||
from datetime import datetime
|
||||
enabled_at = datetime.fromisoformat(enabled_at)
|
||||
message_parts.append(
|
||||
f"🕐 <b>Время включения:</b> {format_local_datetime(enabled_at, '%d.%m.%Y %H:%M:%S')}"
|
||||
)
|
||||
message_parts.append(f"🕐 <b>Время включения:</b> {enabled_at.strftime('%d.%m.%Y %H:%M:%S')}")
|
||||
|
||||
message_parts.append(f"🤖 <b>Автоматически:</b> {'Да' if details.get('auto_enabled', False) else 'Нет'}")
|
||||
message_parts.append("")
|
||||
@@ -953,9 +913,7 @@ class AdminNotificationService:
|
||||
if isinstance(disabled_at, str):
|
||||
from datetime import datetime
|
||||
disabled_at = datetime.fromisoformat(disabled_at)
|
||||
message_parts.append(
|
||||
f"🕐 <b>Время отключения:</b> {format_local_datetime(disabled_at, '%d.%m.%Y %H:%M:%S')}"
|
||||
)
|
||||
message_parts.append(f"🕐 <b>Время отключения:</b> {disabled_at.strftime('%d.%m.%Y %H:%M:%S')}")
|
||||
|
||||
if details.get("duration"):
|
||||
duration = details["duration"]
|
||||
@@ -1014,10 +972,9 @@ class AdminNotificationService:
|
||||
else:
|
||||
message_parts.append("Автоматический мониторинг API остановлен.")
|
||||
|
||||
from datetime import datetime
|
||||
message_parts.append("")
|
||||
message_parts.append(
|
||||
f"⏰ <i>{format_local_datetime(datetime.utcnow(), '%d.%m.%Y %H:%M:%S')}</i>"
|
||||
)
|
||||
message_parts.append(f"⏰ <i>{datetime.now().strftime('%d.%m.%Y %H:%M:%S')}</i>")
|
||||
|
||||
message = "\n".join(message_parts)
|
||||
|
||||
@@ -1063,9 +1020,7 @@ class AdminNotificationService:
|
||||
if isinstance(last_check, str):
|
||||
from datetime import datetime
|
||||
last_check = datetime.fromisoformat(last_check)
|
||||
message_parts.append(
|
||||
f"🕐 <b>Последняя проверка:</b> {format_local_datetime(last_check, '%H:%M:%S')}"
|
||||
)
|
||||
message_parts.append(f"🕐 <b>Последняя проверка:</b> {last_check.strftime('%H:%M:%S')}")
|
||||
|
||||
if status == "online":
|
||||
if details.get("uptime"):
|
||||
@@ -1111,10 +1066,9 @@ class AdminNotificationService:
|
||||
message_parts.append("")
|
||||
message_parts.append("Панель временно недоступна для обслуживания.")
|
||||
|
||||
from datetime import datetime
|
||||
message_parts.append("")
|
||||
message_parts.append(
|
||||
f"⏰ <i>{format_local_datetime(datetime.utcnow(), '%d.%m.%Y %H:%M:%S')}</i>"
|
||||
)
|
||||
message_parts.append(f"⏰ <i>{datetime.now().strftime('%d.%m.%Y %H:%M:%S')}</i>")
|
||||
|
||||
message = "\n".join(message_parts)
|
||||
|
||||
@@ -1141,7 +1095,6 @@ class AdminNotificationService:
|
||||
referrer_info = await self._get_referrer_info(db, user.referred_by_id)
|
||||
promo_group = await self._get_user_promo_group(db, user)
|
||||
promo_block = self._format_promo_group_block(promo_group)
|
||||
user_display = self._get_user_display(user)
|
||||
|
||||
update_types = {
|
||||
"traffic": ("📊 ИЗМЕНЕНИЕ ТРАФИКА", "трафик"),
|
||||
@@ -1154,9 +1107,9 @@ class AdminNotificationService:
|
||||
message_lines = [
|
||||
f"{title}",
|
||||
"",
|
||||
f"👤 <b>Пользователь:</b> {user_display}",
|
||||
f"👤 <b>Пользователь:</b> {user.full_name}",
|
||||
f"🆔 <b>Telegram ID:</b> <code>{user.telegram_id}</code>",
|
||||
f"📱 <b>Username:</b> @{getattr(user, 'username', None) or 'отсутствует'}",
|
||||
f"📱 <b>Username:</b> @{user.username or 'отсутствует'}",
|
||||
"",
|
||||
promo_block,
|
||||
"",
|
||||
@@ -1189,11 +1142,11 @@ class AdminNotificationService:
|
||||
message_lines.extend(
|
||||
[
|
||||
"",
|
||||
f"📅 <b>Подписка действует до:</b> {format_local_datetime(subscription.end_date, '%d.%m.%Y %H:%M')}",
|
||||
f"📅 <b>Подписка действует до:</b> {subscription.end_date.strftime('%d.%m.%Y %H:%M')}",
|
||||
f"💰 <b>Баланс после операции:</b> {settings.format_price(user.balance_kopeks)}",
|
||||
f"🔗 <b>Рефер:</b> {referrer_info}",
|
||||
"",
|
||||
f"⏰ <i>{format_local_datetime(datetime.utcnow(), '%d.%m.%Y %H:%M:%S')}</i>",
|
||||
f"⏰ <i>{datetime.now().strftime('%d.%m.%Y %H:%M:%S')}</i>",
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
+259
-794
File diff suppressed because it is too large
Load Diff
@@ -137,50 +137,30 @@ class BroadcastService:
|
||||
|
||||
keyboard = self._build_keyboard(config.selected_buttons)
|
||||
|
||||
# Ограничение на количество одновременных отправок
|
||||
semaphore = asyncio.Semaphore(20)
|
||||
|
||||
async def send_single_message(user):
|
||||
"""Отправляет одно сообщение с семафором ограничения"""
|
||||
async with semaphore:
|
||||
if cancel_event.is_set():
|
||||
return False
|
||||
|
||||
telegram_id = getattr(user, "telegram_id", None)
|
||||
if telegram_id is None:
|
||||
return False
|
||||
|
||||
try:
|
||||
await self._deliver_message(telegram_id, config, keyboard)
|
||||
return True
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.error(
|
||||
"Ошибка отправки рассылки %s пользователю %s: %s",
|
||||
broadcast_id,
|
||||
telegram_id,
|
||||
exc,
|
||||
)
|
||||
return False
|
||||
|
||||
# Отправляем сообщения пакетами для эффективности
|
||||
batch_size = 100
|
||||
for i in range(0, len(recipients), batch_size):
|
||||
for index, user in enumerate(recipients, start=1):
|
||||
if cancel_event.is_set():
|
||||
await self._mark_cancelled(broadcast_id, sent_count, failed_count)
|
||||
return
|
||||
|
||||
batch = recipients[i:i + batch_size]
|
||||
tasks = [send_single_message(user) for user in batch]
|
||||
results = await asyncio.gather(*tasks, return_exceptions=True)
|
||||
telegram_id = getattr(user, "telegram_id", None)
|
||||
if telegram_id is None:
|
||||
failed_count += 1
|
||||
continue
|
||||
|
||||
for result in results:
|
||||
if result is True:
|
||||
sent_count += 1
|
||||
else:
|
||||
failed_count += 1
|
||||
try:
|
||||
await self._deliver_message(telegram_id, config, keyboard)
|
||||
sent_count += 1
|
||||
except Exception as exc: # noqa: BLE001
|
||||
failed_count += 1
|
||||
logger.error(
|
||||
"Ошибка отправки рассылки %s пользователю %s: %s",
|
||||
broadcast_id,
|
||||
telegram_id,
|
||||
exc,
|
||||
)
|
||||
|
||||
# Небольшая задержка между пакетами для снижения нагрузки на API
|
||||
await asyncio.sleep(0.1)
|
||||
if index % 20 == 0:
|
||||
await asyncio.sleep(1)
|
||||
|
||||
await self._mark_finished(broadcast_id, sent_count, failed_count, cancelled=False)
|
||||
|
||||
|
||||
@@ -120,16 +120,19 @@ class AdvertisingCampaignService:
|
||||
return CampaignBonusResult(success=False)
|
||||
|
||||
traffic_limit = campaign.subscription_traffic_gb
|
||||
device_limit = campaign.subscription_device_limit
|
||||
if device_limit is None:
|
||||
device_limit = settings.DEFAULT_DEVICE_LIMIT
|
||||
device_limit = (
|
||||
campaign.subscription_device_limit or settings.DEFAULT_DEVICE_LIMIT
|
||||
)
|
||||
squads = list(campaign.subscription_squads or [])
|
||||
|
||||
if not squads:
|
||||
try:
|
||||
from app.database.crud.server_squad import get_random_trial_squad_uuid
|
||||
|
||||
trial_uuid = await get_random_trial_squad_uuid(db)
|
||||
trial_uuid = await get_random_trial_squad_uuid(
|
||||
db,
|
||||
getattr(settings, "TRIAL_SQUAD_UUID", None),
|
||||
)
|
||||
if trial_uuid:
|
||||
squads = [trial_uuid]
|
||||
except Exception as error:
|
||||
@@ -138,6 +141,8 @@ class AdvertisingCampaignService:
|
||||
campaign.id,
|
||||
error,
|
||||
)
|
||||
if getattr(settings, "TRIAL_SQUAD_UUID", None):
|
||||
squads = [settings.TRIAL_SQUAD_UUID]
|
||||
|
||||
new_subscription = await create_paid_subscription(
|
||||
db=db,
|
||||
|
||||
@@ -7,7 +7,6 @@ from dataclasses import dataclass
|
||||
from app.config import settings
|
||||
from app.external.remnawave_api import RemnaWaveAPI, test_api_connection
|
||||
from app.utils.cache import cache
|
||||
from app.utils.timezone import format_local_datetime
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -46,9 +45,6 @@ class MaintenanceService:
|
||||
|
||||
def get_maintenance_message(self) -> str:
|
||||
if self._status.auto_enabled:
|
||||
last_check_display = format_local_datetime(
|
||||
self._status.last_check, "%H:%M:%S", "неизвестно"
|
||||
)
|
||||
return f"""
|
||||
🔧 Технические работы!
|
||||
|
||||
@@ -56,7 +52,7 @@ class MaintenanceService:
|
||||
|
||||
⏰ Мы работаем над восстановлением. Попробуйте через несколько минут.
|
||||
|
||||
🔄 Последняя проверка: {last_check_display}
|
||||
🔄 Последняя проверка: {self._status.last_check.strftime('%H:%M:%S') if self._status.last_check else 'неизвестно'}
|
||||
"""
|
||||
else:
|
||||
return settings.get_maintenance_message()
|
||||
@@ -83,12 +79,7 @@ class MaintenanceService:
|
||||
}
|
||||
emoji = emoji_map.get(alert_type, "ℹ️")
|
||||
|
||||
timestamp = format_local_datetime(
|
||||
datetime.utcnow(), "%d.%m.%Y %H:%M:%S %Z"
|
||||
)
|
||||
formatted_message = (
|
||||
f"{emoji} <b>ТЕХНИЧЕСКИЕ РАБОТЫ</b>\n\n{message}\n\n⏰ <i>{timestamp}</i>"
|
||||
)
|
||||
formatted_message = f"{emoji} <b>ТЕХНИЧЕСКИЕ РАБОТЫ</b>\n\n{message}\n\n⏰ <i>{datetime.now().strftime('%d.%m.%Y %H:%M:%S')}</i>"
|
||||
|
||||
return await notification_service._send_message(formatted_message)
|
||||
|
||||
@@ -161,14 +152,11 @@ class MaintenanceService:
|
||||
|
||||
await self._save_status_to_cache()
|
||||
|
||||
enabled_time = format_local_datetime(
|
||||
self._status.enabled_at, "%d.%m.%Y %H:%M:%S %Z"
|
||||
)
|
||||
notification_msg = f"""Режим технических работ ВКЛЮЧЕН
|
||||
|
||||
📋 <b>Причина:</b> {self._status.reason}
|
||||
🤖 <b>Автоматически:</b> {'Да' if auto else 'Нет'}
|
||||
🕐 <b>Время:</b> {enabled_time}
|
||||
🕐 <b>Время:</b> {self._status.enabled_at.strftime('%d.%m.%Y %H:%M:%S')}
|
||||
|
||||
Обычные пользователи временно не смогут использовать бота."""
|
||||
|
||||
@@ -209,13 +197,10 @@ class MaintenanceService:
|
||||
else:
|
||||
duration_str = f"\n⏱️ <b>Длительность:</b> {minutes}мин"
|
||||
|
||||
notification_time = format_local_datetime(
|
||||
datetime.utcnow(), "%d.%m.%Y %H:%M:%S %Z"
|
||||
)
|
||||
notification_msg = f"""Режим технических работ ВЫКЛЮЧЕН
|
||||
|
||||
🤖 <b>Автоматически:</b> {'Да' if was_auto else 'Нет'}
|
||||
🕐 <b>Время:</b> {notification_time}
|
||||
🕐 <b>Время:</b> {datetime.utcnow().strftime('%d.%m.%Y %H:%M:%S')}
|
||||
{duration_str}
|
||||
|
||||
Сервис снова доступен для пользователей."""
|
||||
@@ -238,23 +223,15 @@ class MaintenanceService:
|
||||
await self._load_status_from_cache()
|
||||
|
||||
self._check_task = asyncio.create_task(self._monitoring_loop())
|
||||
logger.info(
|
||||
"🔄 Запущен мониторинг API Remnawave (интервал: %sс, попыток: %s)",
|
||||
settings.get_maintenance_check_interval(),
|
||||
settings.get_maintenance_retry_attempts(),
|
||||
)
|
||||
|
||||
await self._notify_admins(
|
||||
f"""Мониторинг технических работ запущен
|
||||
logger.info(f"🔄 Запущен мониторинг API Remnawave (интервал: {settings.get_maintenance_check_interval()}с)")
|
||||
|
||||
await self._notify_admins(f"""Мониторинг технических работ запущен
|
||||
|
||||
🔄 <b>Интервал проверки:</b> {settings.get_maintenance_check_interval()} секунд
|
||||
🤖 <b>Автовключение:</b> {'Включено' if settings.is_maintenance_auto_enable() else 'Отключено'}
|
||||
🎯 <b>Порог ошибок:</b> {self._max_consecutive_failures}
|
||||
🔁 <b>Повторных попыток:</b> {settings.get_maintenance_retry_attempts()}
|
||||
|
||||
Система будет следить за доступностью API.""",
|
||||
"info",
|
||||
)
|
||||
Система будет следить за доступностью API.""", "info")
|
||||
|
||||
return True
|
||||
|
||||
@@ -283,130 +260,75 @@ class MaintenanceService:
|
||||
try:
|
||||
if self._is_checking:
|
||||
return self._status.api_status
|
||||
|
||||
|
||||
self._is_checking = True
|
||||
self._status.last_check = datetime.utcnow()
|
||||
|
||||
|
||||
auth_params = settings.get_remnawave_auth_params()
|
||||
base_url = (auth_params.get("base_url") or "").strip()
|
||||
api_key = (auth_params.get("api_key") or "").strip()
|
||||
secret_key = (auth_params.get("secret_key") or "").strip() or None
|
||||
username = (auth_params.get("username") or "").strip() or None
|
||||
password = (auth_params.get("password") or "").strip() or None
|
||||
|
||||
if not base_url:
|
||||
logger.error("REMNAWAVE_API_URL не настроен, пропускаем проверку API")
|
||||
self._status.api_status = False
|
||||
self._status.consecutive_failures = 0
|
||||
return False
|
||||
|
||||
if not api_key:
|
||||
logger.error("REMNAWAVE_API_KEY не настроен, пропускаем проверку API")
|
||||
self._status.api_status = False
|
||||
self._status.consecutive_failures = 0
|
||||
return False
|
||||
|
||||
api = RemnaWaveAPI(
|
||||
base_url=base_url,
|
||||
api_key=api_key,
|
||||
secret_key=secret_key,
|
||||
username=username,
|
||||
password=password
|
||||
base_url=auth_params["base_url"],
|
||||
api_key=auth_params["api_key"],
|
||||
secret_key=auth_params["secret_key"],
|
||||
username=auth_params["username"],
|
||||
password=auth_params["password"]
|
||||
)
|
||||
|
||||
attempts = settings.get_maintenance_retry_attempts()
|
||||
|
||||
|
||||
async with api:
|
||||
for attempt in range(1, attempts + 1):
|
||||
is_connected = await test_api_connection(api)
|
||||
|
||||
if is_connected:
|
||||
if attempt > 1:
|
||||
logger.info(
|
||||
"API Remnawave ответило с %s попытки", attempt
|
||||
)
|
||||
|
||||
if not self._status.api_status:
|
||||
recovery_time = format_local_datetime(
|
||||
self._status.last_check, "%H:%M:%S %Z"
|
||||
)
|
||||
await self._notify_admins(
|
||||
f"""API Remnawave восстановлено!
|
||||
is_connected = await test_api_connection(api)
|
||||
|
||||
if is_connected:
|
||||
if not self._status.api_status:
|
||||
await self._notify_admins(f"""API Remnawave восстановлено!
|
||||
|
||||
✅ <b>Статус:</b> Доступно
|
||||
🕐 <b>Время восстановления:</b> {recovery_time}
|
||||
🕐 <b>Время восстановления:</b> {self._status.last_check.strftime('%H:%M:%S')}
|
||||
🔄 <b>Неудачных попыток было:</b> {self._status.consecutive_failures}
|
||||
|
||||
API снова отвечает на запросы.""",
|
||||
"success",
|
||||
)
|
||||
|
||||
self._status.api_status = True
|
||||
self._status.consecutive_failures = 0
|
||||
|
||||
if self._status.is_active and self._status.auto_enabled:
|
||||
await self.disable_maintenance()
|
||||
logger.info("✅ API восстановился, режим техработ автоматически отключен")
|
||||
|
||||
return True
|
||||
|
||||
if attempt < attempts:
|
||||
logger.warning(
|
||||
"API Remnawave недоступно (попытка %s/%s)",
|
||||
attempt,
|
||||
attempts,
|
||||
)
|
||||
await asyncio.sleep(1)
|
||||
|
||||
was_available = self._status.api_status
|
||||
self._status.api_status = False
|
||||
self._status.consecutive_failures += 1
|
||||
|
||||
if was_available:
|
||||
detection_time = format_local_datetime(
|
||||
self._status.last_check, "%H:%M:%S %Z"
|
||||
)
|
||||
await self._notify_admins(
|
||||
f"""API Remnawave недоступно!
|
||||
API снова отвечает на запросы.""", "success")
|
||||
|
||||
self._status.api_status = True
|
||||
self._status.consecutive_failures = 0
|
||||
|
||||
if self._status.is_active and self._status.auto_enabled:
|
||||
await self.disable_maintenance()
|
||||
logger.info("✅ API восстановился, режим техработ автоматически отключен")
|
||||
|
||||
return True
|
||||
else:
|
||||
was_available = self._status.api_status
|
||||
self._status.api_status = False
|
||||
self._status.consecutive_failures += 1
|
||||
|
||||
if was_available:
|
||||
await self._notify_admins(f"""API Remnawave недоступно!
|
||||
|
||||
❌ <b>Статус:</b> Недоступно
|
||||
🕐 <b>Время обнаружения:</b> {detection_time}
|
||||
🕐 <b>Время обнаружения:</b> {self._status.last_check.strftime('%H:%M:%S')}
|
||||
🔄 <b>Попытка:</b> {self._status.consecutive_failures}
|
||||
|
||||
Началась серия неудачных проверок API.""",
|
||||
"error",
|
||||
)
|
||||
|
||||
if (
|
||||
self._status.consecutive_failures >= self._max_consecutive_failures
|
||||
and not self._status.is_active
|
||||
and settings.is_maintenance_auto_enable()
|
||||
):
|
||||
|
||||
await self.enable_maintenance(
|
||||
reason=(
|
||||
f"Автоматическое включение после {self._status.consecutive_failures} "
|
||||
"неудачных проверок API"
|
||||
),
|
||||
auto=True
|
||||
)
|
||||
|
||||
return False
|
||||
|
||||
Началась серия неудачных проверок API.""", "error")
|
||||
|
||||
if (self._status.consecutive_failures >= self._max_consecutive_failures and
|
||||
not self._status.is_active and
|
||||
settings.is_maintenance_auto_enable()):
|
||||
|
||||
await self.enable_maintenance(
|
||||
reason=f"Автоматическое включение после {self._status.consecutive_failures} неудачных проверок API",
|
||||
auto=True
|
||||
)
|
||||
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка проверки API: {e}")
|
||||
|
||||
if self._status.api_status:
|
||||
error_time = format_local_datetime(datetime.utcnow(), "%H:%M:%S %Z")
|
||||
await self._notify_admins(
|
||||
f"""Ошибка при проверке API Remnawave
|
||||
await self._notify_admins(f"""Ошибка при проверке API Remnawave
|
||||
|
||||
❌ <b>Ошибка:</b> {str(e)}
|
||||
🕐 <b>Время:</b> {error_time}
|
||||
🕐 <b>Время:</b> {datetime.utcnow().strftime('%H:%M:%S')}
|
||||
|
||||
Не удалось выполнить проверку доступности API.""",
|
||||
"error",
|
||||
)
|
||||
Не удалось выполнить проверку доступности API.""", "error")
|
||||
|
||||
self._status.api_status = False
|
||||
self._status.consecutive_failures += 1
|
||||
@@ -476,7 +398,6 @@ API снова отвечает на запросы.""",
|
||||
"api_status": self._status.api_status,
|
||||
"consecutive_failures": self._status.consecutive_failures,
|
||||
"monitoring_active": self._check_task is not None and not self._check_task.done(),
|
||||
"monitoring_configured": settings.is_maintenance_monitoring_enabled(),
|
||||
"auto_enable_configured": settings.is_maintenance_auto_enable(),
|
||||
"check_interval": settings.get_maintenance_check_interval(),
|
||||
"bot_connected": self._bot is not None
|
||||
|
||||
@@ -38,19 +38,7 @@ from app.database.crud.user import (
|
||||
subtract_user_balance,
|
||||
cleanup_expired_promo_offer_discounts,
|
||||
)
|
||||
from app.utils.timezone import format_local_datetime
|
||||
from app.utils.subscription_utils import (
|
||||
resolve_hwid_device_limit_for_payload,
|
||||
)
|
||||
from app.database.models import (
|
||||
MonitoringLog,
|
||||
SubscriptionStatus,
|
||||
Subscription,
|
||||
User,
|
||||
Ticket,
|
||||
TicketStatus,
|
||||
UserPromoGroup,
|
||||
)
|
||||
from app.database.models import MonitoringLog, SubscriptionStatus, Subscription, User, Ticket, TicketStatus
|
||||
from app.localization.texts import get_texts
|
||||
from app.services.notification_settings_service import NotificationSettingsService
|
||||
from app.services.payment_service import PaymentService
|
||||
@@ -253,6 +241,9 @@ class MonitoringService:
|
||||
await expire_subscription(db, subscription)
|
||||
|
||||
user = await get_user_by_id(db, subscription.user_id)
|
||||
if user and user.remnawave_uuid:
|
||||
await self.subscription_service.disable_remnawave_user(user.remnawave_uuid)
|
||||
|
||||
if user and self.bot:
|
||||
await self._send_subscription_expired_notification(user)
|
||||
|
||||
@@ -291,34 +282,21 @@ class MonitoringService:
|
||||
is_active = False
|
||||
logger.info(f"📝 Статус подписки {subscription.id} обновлен на 'expired'")
|
||||
|
||||
if not self.subscription_service.is_configured:
|
||||
logger.warning(
|
||||
"RemnaWave API не настроен. Пропускаем обновление пользователя %s",
|
||||
subscription.user_id,
|
||||
)
|
||||
return None
|
||||
|
||||
async with self.subscription_service.get_api_client() as api:
|
||||
hwid_limit = resolve_hwid_device_limit_for_payload(subscription)
|
||||
|
||||
update_kwargs = dict(
|
||||
async with self.api as api:
|
||||
updated_user = await api.update_user(
|
||||
uuid=user.remnawave_uuid,
|
||||
status=UserStatus.ACTIVE if is_active else UserStatus.EXPIRED,
|
||||
expire_at=subscription.end_date,
|
||||
traffic_limit_bytes=self._gb_to_bytes(subscription.traffic_limit_gb),
|
||||
traffic_limit_strategy=TrafficLimitStrategy.MONTH,
|
||||
hwid_device_limit=subscription.device_limit,
|
||||
description=settings.format_remnawave_user_description(
|
||||
full_name=user.full_name,
|
||||
username=user.username,
|
||||
telegram_id=user.telegram_id
|
||||
),
|
||||
active_internal_squads=subscription.connected_squads,
|
||||
active_internal_squads=subscription.connected_squads
|
||||
)
|
||||
|
||||
if hwid_limit is not None:
|
||||
update_kwargs['hwid_device_limit'] = hwid_limit
|
||||
|
||||
updated_user = await api.update_user(**update_kwargs)
|
||||
|
||||
subscription.subscription_url = updated_user.subscription_url
|
||||
subscription.subscription_crypto_link = updated_user.happ_crypto_link
|
||||
@@ -394,12 +372,7 @@ class MonitoringService:
|
||||
|
||||
result = await db.execute(
|
||||
select(Subscription)
|
||||
.options(
|
||||
selectinload(Subscription.user).selectinload(User.promo_group),
|
||||
selectinload(Subscription.user)
|
||||
.selectinload(User.user_promo_groups)
|
||||
.selectinload(UserPromoGroup.promo_group),
|
||||
)
|
||||
.options(selectinload(Subscription.user))
|
||||
.where(
|
||||
and_(
|
||||
Subscription.status == SubscriptionStatus.ACTIVE.value,
|
||||
@@ -581,11 +554,7 @@ class MonitoringService:
|
||||
)
|
||||
continue
|
||||
|
||||
if (
|
||||
subscription.status == SubscriptionStatus.ACTIVE.value
|
||||
and subscription.is_trial
|
||||
and not is_member
|
||||
):
|
||||
if subscription.status == SubscriptionStatus.ACTIVE.value and not is_member:
|
||||
subscription = await deactivate_subscription(db, subscription)
|
||||
disabled_count += 1
|
||||
logger.info(
|
||||
@@ -619,11 +588,7 @@ class MonitoringService:
|
||||
subscription.id,
|
||||
"trial_channel_unsubscribed",
|
||||
)
|
||||
elif (
|
||||
subscription.status == SubscriptionStatus.DISABLED.value
|
||||
and subscription.is_trial
|
||||
and is_member
|
||||
):
|
||||
elif subscription.status == SubscriptionStatus.DISABLED.value and is_member:
|
||||
subscription.status = SubscriptionStatus.ACTIVE.value
|
||||
subscription.updated_at = datetime.utcnow()
|
||||
await db.commit()
|
||||
@@ -937,10 +902,7 @@ class MonitoringService:
|
||||
if not user:
|
||||
continue
|
||||
|
||||
# Правильный расчет стоимости продления с учетом всех параметров подписки
|
||||
renewal_cost = await self.subscription_service.calculate_renewal_price(
|
||||
subscription, 30, db, user=user
|
||||
)
|
||||
renewal_cost = settings.PRICE_30_DAYS
|
||||
promo_discount_percent = self._get_user_promo_offer_discount_percent(user)
|
||||
charge_amount = renewal_cost
|
||||
promo_discount_value = 0
|
||||
@@ -1064,7 +1026,7 @@ class MonitoringService:
|
||||
message = f"""
|
||||
⚠️ <b>Подписка истекает через {days_text}!</b>
|
||||
|
||||
Ваша платная подписка истекает {format_local_datetime(subscription.end_date, "%d.%m.%Y %H:%M")}.
|
||||
Ваша платная подписка истекает {subscription.end_date.strftime("%d.%m.%Y %H:%M")}.
|
||||
|
||||
💳 <b>Автоплатеж:</b> {autopay_status}
|
||||
|
||||
@@ -1108,56 +1070,6 @@ class MonitoringService:
|
||||
try:
|
||||
texts = get_texts(user.language)
|
||||
|
||||
# Рассчитываем минимальную цену за подписку с минимальной конфигурацией
|
||||
from app.config import settings, PERIOD_PRICES
|
||||
from app.utils.pricing_utils import apply_percentage_discount
|
||||
|
||||
# Базовая цена за 30 дней
|
||||
base_price_original = PERIOD_PRICES.get(30, settings.PRICE_30_DAYS)
|
||||
|
||||
# Применяем скидку промогруппы для категории "period"
|
||||
promo_group_discount = user.get_promo_discount("period", 30) if user else 0
|
||||
# Применяем пользовательскую промо-скидку (если есть)
|
||||
user_discount_percent = self._get_user_promo_offer_discount_percent(user)
|
||||
|
||||
# Общая скидка - максимальная из промогруппы и пользовательской
|
||||
total_discount_percent = max(promo_group_discount, user_discount_percent)
|
||||
|
||||
base_price, _ = apply_percentage_discount(base_price_original, total_discount_percent)
|
||||
|
||||
# Добавляем цену за трафик (если фиксированный трафик включён)
|
||||
if settings.is_traffic_fixed():
|
||||
traffic_price = settings.get_traffic_price(settings.get_fixed_traffic_limit())
|
||||
# Применяем скидки на трафик
|
||||
traffic_discount = user.get_promo_discount("traffic", 30) if user else 0
|
||||
traffic_price, _ = apply_percentage_discount(traffic_price, traffic_discount)
|
||||
else:
|
||||
traffic_price = 0 # Трафик не фиксирован, цена включена в базовую
|
||||
|
||||
# Добавляем цену за серверы (предполагаем минимум 1 сервер по минимальной цене)
|
||||
# Вместо сложного запроса к БД, используем настройки
|
||||
# Для минимальной конфигурации - один сервер с минимальной ценой
|
||||
min_server_price = getattr(settings, 'MIN_SERVER_PRICE', 0) or 0
|
||||
if min_server_price == 0:
|
||||
# Если нет явной минимальной цены, используем базовую цену
|
||||
# В реальных условиях цена сервера будет определяться в ходе оформления подписки
|
||||
min_server_price = 0
|
||||
|
||||
# Добавляем цену за устройства (если больше базового лимита)
|
||||
# В минимальной конфигурации - базовый лимит, без доп. устройств
|
||||
device_limit = settings.DEFAULT_DEVICE_LIMIT
|
||||
additional_devices = max(0, device_limit - settings.DEFAULT_DEVICE_LIMIT)
|
||||
devices_price = additional_devices * settings.PRICE_PER_DEVICE
|
||||
|
||||
# Для простоты и правильной работы без обращения к БД, рассчитываем минимальную цену как:
|
||||
# базовая цена + минимальная цена за трафик (если есть фиксированный)
|
||||
min_server_price = 0 # для минимальной конфигурации с 1 сервером используем 0 или минимальную известную
|
||||
|
||||
# Попробуем получить минимальную цену сервера из настроек или используем подходящее значение
|
||||
# Находим минимальную возможную цену из возможных цен серверов
|
||||
# В упрощенном варианте используем базовую конфигурацию: базовая цена + трафик
|
||||
min_total_price = base_price + traffic_price
|
||||
|
||||
message = f"""
|
||||
🎁 <b>Тестовая подписка скоро закончится!</b>
|
||||
|
||||
@@ -1167,7 +1079,7 @@ class MonitoringService:
|
||||
Переходите на полную подписку!
|
||||
|
||||
🔥 <b>Специальное предложение:</b>
|
||||
• 30 дней всего за {settings.format_price(min_total_price)}
|
||||
• 30 дней всего за {settings.format_price(settings.PRICE_30_DAYS)}
|
||||
• Безлимитный трафик
|
||||
• Все серверы доступны
|
||||
• Скорость до 1ГБит/сек
|
||||
@@ -1230,7 +1142,7 @@ class MonitoringService:
|
||||
|
||||
message = template.format(
|
||||
price=settings.format_price(settings.PRICE_30_DAYS),
|
||||
end_date=format_local_datetime(subscription.end_date, "%d.%m.%Y %H:%M"),
|
||||
end_date=subscription.end_date.strftime("%d.%m.%Y %H:%M"),
|
||||
)
|
||||
|
||||
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
|
||||
@@ -1346,7 +1258,7 @@ class MonitoringService:
|
||||
),
|
||||
)
|
||||
message = template.format(
|
||||
end_date=format_local_datetime(subscription.end_date, "%d.%m.%Y %H:%M"),
|
||||
end_date=subscription.end_date.strftime("%d.%m.%Y %H:%M"),
|
||||
price=settings.format_price(settings.PRICE_30_DAYS),
|
||||
)
|
||||
|
||||
@@ -1423,7 +1335,7 @@ class MonitoringService:
|
||||
|
||||
message = template.format(
|
||||
percent=percent,
|
||||
expires_at=format_local_datetime(expires_at, "%d.%m.%Y %H:%M"),
|
||||
expires_at=expires_at.strftime("%d.%m.%Y %H:%M"),
|
||||
trigger_days=trigger_days or "",
|
||||
)
|
||||
|
||||
@@ -1561,11 +1473,7 @@ class MonitoringService:
|
||||
if now.minute != 0:
|
||||
return
|
||||
|
||||
if not self.subscription_service.is_configured:
|
||||
logger.warning("RemnaWave API не настроен. Пропускаем синхронизацию")
|
||||
return
|
||||
|
||||
async with self.subscription_service.get_api_client() as api:
|
||||
async with self.subscription_service.api as api:
|
||||
system_stats = await api.get_system_stats()
|
||||
|
||||
await self._log_monitoring_event(
|
||||
|
||||
@@ -83,28 +83,22 @@ class Pal24Service:
|
||||
logger.debug("Запрашиваем статус Pal24 платежа %s", payment_id)
|
||||
return await self.client.get_payment_status(payment_id)
|
||||
|
||||
async def get_bill_payments(self, bill_id: str) -> Dict[str, Any]:
|
||||
"""Возвращает список платежей, связанных со счетом."""
|
||||
|
||||
logger.debug("Запрашиваем платежи Pal24 счёта %s", bill_id)
|
||||
return await self.client.get_bill_payments(bill_id)
|
||||
|
||||
@staticmethod
|
||||
def parse_callback(payload: Dict[str, Any]) -> Dict[str, Any]:
|
||||
def parse_postback(payload: Dict[str, Any]) -> Dict[str, Any]:
|
||||
required_fields = ["InvId", "OutSum", "Status", "SignatureValue"]
|
||||
missing = [field for field in required_fields if field not in payload]
|
||||
if missing:
|
||||
raise Pal24APIError(f"Pal24 callback missing fields: {', '.join(missing)}")
|
||||
raise Pal24APIError(f"Pal24 postback missing fields: {', '.join(missing)}")
|
||||
|
||||
inv_id = str(payload["InvId"])
|
||||
out_sum = str(payload["OutSum"])
|
||||
signature = str(payload["SignatureValue"])
|
||||
|
||||
if not Pal24Client.verify_signature(out_sum, inv_id, signature):
|
||||
raise Pal24APIError("Pal24 callback signature mismatch")
|
||||
raise Pal24APIError("Pal24 postback signature mismatch")
|
||||
|
||||
logger.info(
|
||||
"Получен Pal24 callback: InvId=%s, Status=%s, TrsId=%s",
|
||||
"Получен Pal24 postback: InvId=%s, Status=%s, TrsId=%s",
|
||||
inv_id,
|
||||
payload.get("Status"),
|
||||
payload.get("TrsId"),
|
||||
|
||||
@@ -12,7 +12,6 @@ from .cryptobot import CryptoBotPaymentMixin
|
||||
from .heleket import HeleketPaymentMixin
|
||||
from .mulenpay import MulenPayPaymentMixin
|
||||
from .pal24 import Pal24PaymentMixin
|
||||
from .platega import PlategaPaymentMixin
|
||||
from .wata import WataPaymentMixin
|
||||
|
||||
__all__ = [
|
||||
@@ -24,6 +23,5 @@ __all__ = [
|
||||
"HeleketPaymentMixin",
|
||||
"MulenPayPaymentMixin",
|
||||
"Pal24PaymentMixin",
|
||||
"PlategaPaymentMixin",
|
||||
"WataPaymentMixin",
|
||||
]
|
||||
|
||||
@@ -23,7 +23,6 @@ from app.services.subscription_checkout_service import (
|
||||
has_subscription_checkout_draft,
|
||||
should_offer_checkout_resume,
|
||||
)
|
||||
from app.services.user_cart_service import user_cart_service
|
||||
from app.utils.miniapp_buttons import build_miniapp_or_callback_button
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -42,7 +41,6 @@ class PaymentCommonMixin:
|
||||
user and user.subscription and not user.subscription.is_trial and user.subscription.is_active
|
||||
)
|
||||
|
||||
# Создаем основную кнопку: если есть активная подписка - продлить, иначе купить
|
||||
first_button = build_miniapp_or_callback_button(
|
||||
text=(
|
||||
texts.MENU_EXTEND_SUBSCRIPTION
|
||||
@@ -54,45 +52,18 @@ class PaymentCommonMixin:
|
||||
),
|
||||
)
|
||||
|
||||
# Кнопка активации подписки (всегда отображается)
|
||||
activate_subscription_button = build_miniapp_or_callback_button(
|
||||
text="🚀 Активировать подписку",
|
||||
callback_data="menu_buy" # Используем ту же callback_data что и "Купить подписку"
|
||||
)
|
||||
|
||||
keyboard_rows: list[list[InlineKeyboardButton]] = [
|
||||
[first_button],
|
||||
[activate_subscription_button]
|
||||
]
|
||||
keyboard_rows: list[list[InlineKeyboardButton]] = [[first_button]]
|
||||
|
||||
# Если для пользователя есть незавершённый checkout, предлагаем вернуться к нему.
|
||||
if user:
|
||||
try:
|
||||
has_saved_cart = await user_cart_service.has_user_cart(user.id)
|
||||
except Exception as cart_error:
|
||||
logger.warning(
|
||||
"Не удалось проверить наличие сохраненной корзины у пользователя %s: %s",
|
||||
user.id,
|
||||
cart_error,
|
||||
)
|
||||
has_saved_cart = False
|
||||
|
||||
if has_saved_cart:
|
||||
draft_exists = await has_subscription_checkout_draft(user.id)
|
||||
if should_offer_checkout_resume(user, draft_exists):
|
||||
keyboard_rows.append([
|
||||
build_miniapp_or_callback_button(
|
||||
text=texts.RETURN_TO_SUBSCRIPTION_CHECKOUT,
|
||||
callback_data="return_to_saved_cart",
|
||||
callback_data="subscription_resume_checkout",
|
||||
)
|
||||
])
|
||||
else:
|
||||
draft_exists = await has_subscription_checkout_draft(user.id)
|
||||
if should_offer_checkout_resume(user, draft_exists):
|
||||
keyboard_rows.append([
|
||||
build_miniapp_or_callback_button(
|
||||
text=texts.RETURN_TO_SUBSCRIPTION_CHECKOUT,
|
||||
callback_data="subscription_resume_checkout",
|
||||
)
|
||||
])
|
||||
|
||||
# Стандартные кнопки быстрого доступа к балансу и главному меню.
|
||||
keyboard_rows.append([
|
||||
@@ -138,11 +109,7 @@ class PaymentCommonMixin:
|
||||
"✅ <b>Платеж успешно завершен!</b>\n\n"
|
||||
f"💰 Сумма: {settings.format_price(amount_kopeks)}\n"
|
||||
f"💳 Способ: {payment_method}\n\n"
|
||||
"Средства зачислены на ваш баланс!\n\n"
|
||||
"⚠️ <b>Важно:</b> Пополнение баланса не активирует подписку автоматически. "
|
||||
"Обязательно активируйте подписку отдельно!\n\n"
|
||||
f"🔄 При наличии сохранённой корзины подписки и включенной автопокупке, "
|
||||
f"подписка будет приобретена автоматически после пополнения баланса."
|
||||
"Средства зачислены на ваш баланс!"
|
||||
)
|
||||
|
||||
await self.bot.send_message(
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
"""Mixin с логикой обработки платежей CryptoBot."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import math
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from importlib import import_module
|
||||
from typing import Any, Dict, Optional
|
||||
@@ -11,7 +10,6 @@ from typing import Any, Dict, Optional
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.database import AsyncSessionLocal
|
||||
from app.database.models import PaymentMethod, TransactionType
|
||||
from app.services.subscription_auto_purchase_service import (
|
||||
auto_purchase_saved_cart_after_topup,
|
||||
@@ -22,33 +20,6 @@ from app.utils.user_utils import format_referrer_info
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class _AdminNotificationContext:
|
||||
user_id: int
|
||||
transaction_id: int
|
||||
old_balance: int
|
||||
topup_status: str
|
||||
referrer_info: str
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class _UserNotificationPayload:
|
||||
telegram_id: int
|
||||
text: str
|
||||
parse_mode: Optional[str]
|
||||
reply_markup: Any
|
||||
amount_rubles: float
|
||||
asset: str
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class _SavedCartNotificationPayload:
|
||||
telegram_id: int
|
||||
text: str
|
||||
reply_markup: Any
|
||||
user_id: int
|
||||
|
||||
|
||||
class CryptoBotPaymentMixin:
|
||||
"""Mixin, отвечающий за генерацию инвойсов CryptoBot и обработку webhook."""
|
||||
|
||||
@@ -178,16 +149,14 @@ class CryptoBotPaymentMixin:
|
||||
|
||||
try:
|
||||
amount_rubles = await currency_converter.usd_to_rub(amount_usd)
|
||||
amount_rubles_rounded = math.ceil(amount_rubles)
|
||||
amount_kopeks = int(amount_rubles_rounded * 100)
|
||||
amount_kopeks = int(amount_rubles * 100)
|
||||
conversion_rate = (
|
||||
amount_rubles / amount_usd if amount_usd > 0 else 0
|
||||
)
|
||||
logger.info(
|
||||
"Конвертация USD->RUB: $%s -> %s₽ (округлено до %s₽, курс: %.2f)",
|
||||
"Конвертация USD->RUB: $%s -> %s₽ (курс: %.2f)",
|
||||
amount_usd,
|
||||
amount_rubles,
|
||||
amount_rubles_rounded,
|
||||
conversion_rate,
|
||||
)
|
||||
except Exception as error:
|
||||
@@ -197,8 +166,7 @@ class CryptoBotPaymentMixin:
|
||||
error,
|
||||
)
|
||||
amount_rubles = amount_usd
|
||||
amount_rubles_rounded = math.ceil(amount_rubles)
|
||||
amount_kopeks = int(amount_rubles_rounded * 100)
|
||||
amount_kopeks = int(amount_usd * 100)
|
||||
conversion_rate = 1.0
|
||||
|
||||
if amount_kopeks <= 0:
|
||||
@@ -217,7 +185,7 @@ class CryptoBotPaymentMixin:
|
||||
amount_kopeks=amount_kopeks,
|
||||
description=(
|
||||
"Пополнение через CryptoBot "
|
||||
f"({updated_payment.amount} {updated_payment.asset} → {amount_rubles_rounded:.2f}₽)"
|
||||
f"({updated_payment.amount} {updated_payment.asset} → {amount_rubles:.2f}₽)"
|
||||
),
|
||||
payment_method=PaymentMethod.CRYPTOBOT,
|
||||
external_id=invoice_id,
|
||||
@@ -243,6 +211,8 @@ class CryptoBotPaymentMixin:
|
||||
user.balance_kopeks += amount_kopeks
|
||||
user.updated_at = datetime.utcnow()
|
||||
|
||||
promo_group = getattr(user, "promo_group", None)
|
||||
subscription = getattr(user, "subscription", None)
|
||||
referrer_info = format_referrer_info(user)
|
||||
topup_status = (
|
||||
"🆕 Первое пополнение" if was_first_topup else "🔄 Пополнение"
|
||||
@@ -271,41 +241,55 @@ class CryptoBotPaymentMixin:
|
||||
|
||||
await db.refresh(user)
|
||||
|
||||
admin_notification: Optional[_AdminNotificationContext] = None
|
||||
user_notification: Optional[_UserNotificationPayload] = None
|
||||
saved_cart_notification: Optional[_SavedCartNotificationPayload] = None
|
||||
|
||||
bot_instance = getattr(self, "bot", None)
|
||||
if bot_instance:
|
||||
admin_notification = _AdminNotificationContext(
|
||||
user_id=user.id,
|
||||
transaction_id=transaction.id,
|
||||
old_balance=old_balance,
|
||||
topup_status=topup_status,
|
||||
referrer_info=referrer_info,
|
||||
)
|
||||
|
||||
if getattr(self, "bot", None):
|
||||
try:
|
||||
keyboard = await self.build_topup_success_keyboard(user)
|
||||
message_text = (
|
||||
"✅ <b>Пополнение успешно!</b>\n\n"
|
||||
f"💰 Сумма: {settings.format_price(amount_kopeks)}\n"
|
||||
f"🪙 Платеж: {updated_payment.amount} {updated_payment.asset}\n"
|
||||
f"💱 Курс: 1 USD = {conversion_rate:.2f}₽\n"
|
||||
f"🆔 Транзакция: {invoice_id[:8]}...\n\n"
|
||||
"Баланс пополнен автоматически!"
|
||||
from app.services.admin_notification_service import (
|
||||
AdminNotificationService,
|
||||
)
|
||||
user_notification = _UserNotificationPayload(
|
||||
telegram_id=user.telegram_id,
|
||||
text=message_text,
|
||||
parse_mode="HTML",
|
||||
reply_markup=keyboard,
|
||||
amount_rubles=amount_rubles_rounded,
|
||||
asset=updated_payment.asset,
|
||||
|
||||
notification_service = AdminNotificationService(self.bot)
|
||||
await notification_service.send_balance_topup_notification(
|
||||
user,
|
||||
transaction,
|
||||
old_balance,
|
||||
topup_status=topup_status,
|
||||
referrer_info=referrer_info,
|
||||
subscription=subscription,
|
||||
promo_group=promo_group,
|
||||
db=db,
|
||||
)
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
"Ошибка подготовки уведомления о пополнении CryptoBot: %s",
|
||||
"Ошибка отправки уведомления о пополнении CryptoBot: %s",
|
||||
error,
|
||||
)
|
||||
|
||||
if getattr(self, "bot", None):
|
||||
try:
|
||||
keyboard = await self.build_topup_success_keyboard(user)
|
||||
|
||||
await self.bot.send_message(
|
||||
user.telegram_id,
|
||||
(
|
||||
"✅ <b>Пополнение успешно!</b>\n\n"
|
||||
f"💰 Сумма: {settings.format_price(amount_kopeks)}\n"
|
||||
f"🪙 Платеж: {updated_payment.amount} {updated_payment.asset}\n"
|
||||
f"💱 Курс: 1 USD = {conversion_rate:.2f}₽\n"
|
||||
f"🆔 Транзакция: {invoice_id[:8]}...\n\n"
|
||||
"Баланс пополнен автоматически!"
|
||||
),
|
||||
parse_mode="HTML",
|
||||
reply_markup=keyboard,
|
||||
)
|
||||
logger.info(
|
||||
"✅ Отправлено уведомление пользователю %s о пополнении на %s₽ (%s)",
|
||||
user.telegram_id,
|
||||
f"{amount_rubles:.2f}",
|
||||
updated_payment.asset,
|
||||
)
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
"Ошибка отправки уведомления о пополнении CryptoBot: %s",
|
||||
error,
|
||||
)
|
||||
|
||||
@@ -321,7 +305,7 @@ class CryptoBotPaymentMixin:
|
||||
auto_purchase_success = await auto_purchase_saved_cart_after_topup(
|
||||
db,
|
||||
user,
|
||||
bot=bot_instance,
|
||||
bot=getattr(self, "bot", None),
|
||||
)
|
||||
except Exception as auto_error:
|
||||
logger.error(
|
||||
@@ -334,18 +318,21 @@ class CryptoBotPaymentMixin:
|
||||
if auto_purchase_success:
|
||||
has_saved_cart = False
|
||||
|
||||
if has_saved_cart and bot_instance:
|
||||
if has_saved_cart and getattr(self, "bot", None):
|
||||
# Если у пользователя есть сохраненная корзина,
|
||||
# отправляем ему уведомление с кнопкой вернуться к оформлению
|
||||
from app.localization.texts import get_texts
|
||||
|
||||
|
||||
texts = get_texts(user.language)
|
||||
cart_message = texts.BALANCE_TOPUP_CART_REMINDER_DETAILED.format(
|
||||
total_amount=settings.format_price(payment.amount_kopeks)
|
||||
)
|
||||
|
||||
|
||||
# Создаем клавиатуру с кнопками
|
||||
keyboard = types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(
|
||||
text=texts.RETURN_TO_SUBSCRIPTION_CHECKOUT,
|
||||
callback_data="return_to_saved_cart"
|
||||
callback_data="subscription_resume_checkout"
|
||||
)],
|
||||
[types.InlineKeyboardButton(
|
||||
text="💰 Мой баланс",
|
||||
@@ -356,35 +343,18 @@ class CryptoBotPaymentMixin:
|
||||
callback_data="back_to_menu"
|
||||
)]
|
||||
])
|
||||
|
||||
saved_cart_notification = _SavedCartNotificationPayload(
|
||||
telegram_id=user.telegram_id,
|
||||
text=(
|
||||
f"✅ Баланс пополнен на {settings.format_price(payment.amount_kopeks)}!\n\n"
|
||||
f"⚠️ <b>Важно:</b> Пополнение баланса не активирует подписку автоматически. "
|
||||
f"Обязательно активируйте подписку отдельно!\n\n"
|
||||
f"🔄 При наличии сохранённой корзины подписки и включенной автопокупке, "
|
||||
f"подписка будет приобретена автоматически после пополнения баланса.\n\n{cart_message}"
|
||||
),
|
||||
reply_markup=keyboard,
|
||||
user_id=user.id,
|
||||
|
||||
await self.bot.send_message(
|
||||
chat_id=user.telegram_id,
|
||||
text=f"✅ Баланс пополнен на {settings.format_price(payment.amount_kopeks)}!\n\n{cart_message}",
|
||||
reply_markup=keyboard
|
||||
)
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
"Ошибка при работе с сохраненной корзиной для пользователя %s: %s",
|
||||
user.id,
|
||||
error,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
if admin_notification:
|
||||
await self._deliver_admin_topup_notification(admin_notification)
|
||||
|
||||
if user_notification and bot_instance:
|
||||
await self._deliver_user_topup_notification(user_notification)
|
||||
|
||||
if saved_cart_notification and bot_instance:
|
||||
await self._deliver_saved_cart_reminder(saved_cart_notification)
|
||||
logger.info(
|
||||
"Отправлено уведомление с кнопкой возврата к оформлению подписки пользователю %s",
|
||||
user.id,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при работе с сохраненной корзиной для пользователя {user.id}: {e}", exc_info=True)
|
||||
|
||||
return True
|
||||
|
||||
@@ -394,116 +364,6 @@ class CryptoBotPaymentMixin:
|
||||
)
|
||||
return False
|
||||
|
||||
async def _deliver_admin_topup_notification(
|
||||
self, context: _AdminNotificationContext
|
||||
) -> None:
|
||||
bot_instance = getattr(self, "bot", None)
|
||||
if not bot_instance:
|
||||
return
|
||||
|
||||
try:
|
||||
from app.services.admin_notification_service import AdminNotificationService
|
||||
from app.database.crud.user import get_user_by_id
|
||||
from app.database.crud.transaction import get_transaction_by_id
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
"Не удалось импортировать зависимости для админ-уведомления CryptoBot: %s",
|
||||
error,
|
||||
exc_info=True,
|
||||
)
|
||||
return
|
||||
|
||||
async with AsyncSessionLocal() as session:
|
||||
try:
|
||||
user = await get_user_by_id(session, context.user_id)
|
||||
transaction = await get_transaction_by_id(session, context.transaction_id)
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
"Ошибка загрузки данных для админ-уведомления CryptoBot: %s",
|
||||
error,
|
||||
exc_info=True,
|
||||
)
|
||||
await session.rollback()
|
||||
return
|
||||
|
||||
if not user or not transaction:
|
||||
logger.warning(
|
||||
"Пропущена отправка админ-уведомления CryptoBot: user=%s transaction=%s",
|
||||
bool(user),
|
||||
bool(transaction),
|
||||
)
|
||||
return
|
||||
|
||||
notification_service = AdminNotificationService(bot_instance)
|
||||
try:
|
||||
await notification_service.send_balance_topup_notification(
|
||||
user,
|
||||
transaction,
|
||||
context.old_balance,
|
||||
topup_status=context.topup_status,
|
||||
referrer_info=context.referrer_info,
|
||||
subscription=getattr(user, "subscription", None),
|
||||
promo_group=getattr(user, "promo_group", None),
|
||||
db=session,
|
||||
)
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
"Ошибка отправки админ-уведомления о пополнении CryptoBot: %s",
|
||||
error,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
async def _deliver_user_topup_notification(
|
||||
self, payload: _UserNotificationPayload
|
||||
) -> None:
|
||||
bot_instance = getattr(self, "bot", None)
|
||||
if not bot_instance:
|
||||
return
|
||||
|
||||
try:
|
||||
await bot_instance.send_message(
|
||||
payload.telegram_id,
|
||||
payload.text,
|
||||
parse_mode=payload.parse_mode,
|
||||
reply_markup=payload.reply_markup,
|
||||
)
|
||||
logger.info(
|
||||
"✅ Отправлено уведомление пользователю %s о пополнении на %s₽ (%s)",
|
||||
payload.telegram_id,
|
||||
f"{payload.amount_rubles:.2f}",
|
||||
payload.asset,
|
||||
)
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
"Ошибка отправки уведомления о пополнении CryptoBot: %s",
|
||||
error,
|
||||
)
|
||||
|
||||
async def _deliver_saved_cart_reminder(
|
||||
self, payload: _SavedCartNotificationPayload
|
||||
) -> None:
|
||||
bot_instance = getattr(self, "bot", None)
|
||||
if not bot_instance:
|
||||
return
|
||||
|
||||
try:
|
||||
await bot_instance.send_message(
|
||||
chat_id=payload.telegram_id,
|
||||
text=payload.text,
|
||||
reply_markup=payload.reply_markup,
|
||||
)
|
||||
logger.info(
|
||||
"Отправлено уведомление с кнопкой возврата к оформлению подписки пользователю %s",
|
||||
payload.user_id,
|
||||
)
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
"Ошибка отправки уведомления о сохраненной корзине для пользователя %s: %s",
|
||||
payload.user_id,
|
||||
error,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
async def get_cryptobot_payment_status(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
|
||||
@@ -335,7 +335,7 @@ class HeleketPaymentMixin:
|
||||
topup_status = "🆕 Первое пополнение" if was_first_topup else "🔄 Пополнение"
|
||||
referrer_info = format_referrer_info(user)
|
||||
subscription = getattr(user, "subscription", None)
|
||||
promo_group = user.get_primary_promo_group()
|
||||
promo_group = getattr(user, "promo_group", None)
|
||||
|
||||
try:
|
||||
from app.services.admin_notification_service import AdminNotificationService
|
||||
|
||||
@@ -247,7 +247,6 @@ class MulenPayPaymentMixin:
|
||||
user,
|
||||
payment.amount_kopeks,
|
||||
f"Пополнение {display_name}: {payment.amount_kopeks // 100}₽",
|
||||
create_transaction=False,
|
||||
)
|
||||
|
||||
try:
|
||||
@@ -270,18 +269,9 @@ class MulenPayPaymentMixin:
|
||||
user.has_made_first_topup = True
|
||||
await db.commit()
|
||||
|
||||
# После коммита отношения пользователя могли быть сброшены, поэтому
|
||||
# повторно загружаем пользователя с предзагрузкой зависимостей
|
||||
user = await payment_module.get_user_by_id(db, user.id)
|
||||
if not user:
|
||||
logger.error(
|
||||
"Пользователь %s не найден при повторной загрузке после %s",
|
||||
payment.user_id,
|
||||
display_name,
|
||||
)
|
||||
return False
|
||||
await db.refresh(user)
|
||||
|
||||
promo_group = user.get_primary_promo_group()
|
||||
promo_group = getattr(user, "promo_group", None)
|
||||
subscription = getattr(user, "subscription", None)
|
||||
referrer_info = format_referrer_info(user)
|
||||
topup_status = (
|
||||
@@ -375,7 +365,7 @@ class MulenPayPaymentMixin:
|
||||
keyboard = types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(
|
||||
text=texts.RETURN_TO_SUBSCRIPTION_CHECKOUT,
|
||||
callback_data="return_to_saved_cart"
|
||||
callback_data="subscription_resume_checkout"
|
||||
)],
|
||||
[types.InlineKeyboardButton(
|
||||
text="💰 Мой баланс",
|
||||
@@ -389,11 +379,7 @@ class MulenPayPaymentMixin:
|
||||
|
||||
await self.bot.send_message(
|
||||
chat_id=user.telegram_id,
|
||||
text=f"✅ Баланс пополнен на {settings.format_price(payment.amount_kopeks)}!\n\n"
|
||||
f"⚠️ <b>Важно:</b> Пополнение баланса не активирует подписку автоматически. "
|
||||
f"Обязательно активируйте подписку отдельно!\n\n"
|
||||
f"🔄 При наличии сохранённой корзины подписки и включенной автопокупке, "
|
||||
f"подписка будет приобретена автоматически после пополнения баланса.\n\n{cart_message}",
|
||||
text=f"✅ Баланс пополнен на {settings.format_price(payment.amount_kopeks)}!\n\n{cart_message}",
|
||||
reply_markup=keyboard
|
||||
)
|
||||
logger.info(
|
||||
|
||||
+45
-185
@@ -22,7 +22,7 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class Pal24PaymentMixin:
|
||||
"""Mixin с созданием счетов Pal24, обработкой callback и запросом статуса."""
|
||||
"""Mixin с созданием счетов Pal24, обработкой postback и запросом статуса."""
|
||||
|
||||
async def create_pal24_payment(
|
||||
self,
|
||||
@@ -197,12 +197,12 @@ class Pal24PaymentMixin:
|
||||
"payment_url": primary_link,
|
||||
}
|
||||
|
||||
async def process_pal24_callback(
|
||||
async def process_pal24_postback(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
callback: Dict[str, Any],
|
||||
postback: Dict[str, Any],
|
||||
) -> bool:
|
||||
"""Обрабатывает callback от Pal24 и начисляет баланс при успехе."""
|
||||
"""Обрабатывает postback от Pal24 и начисляет баланс при успехе."""
|
||||
try:
|
||||
payment_module = import_module("app.services.payment_service")
|
||||
|
||||
@@ -213,26 +213,26 @@ class Pal24PaymentMixin:
|
||||
return None
|
||||
|
||||
payment_id = _first_non_empty(
|
||||
callback.get("id"),
|
||||
callback.get("TrsId"),
|
||||
callback.get("TrsID"),
|
||||
postback.get("id"),
|
||||
postback.get("TrsId"),
|
||||
postback.get("TrsID"),
|
||||
)
|
||||
bill_id = _first_non_empty(
|
||||
callback.get("bill_id"),
|
||||
callback.get("billId"),
|
||||
callback.get("BillId"),
|
||||
callback.get("BillID"),
|
||||
postback.get("bill_id"),
|
||||
postback.get("billId"),
|
||||
postback.get("BillId"),
|
||||
postback.get("BillID"),
|
||||
)
|
||||
order_id = _first_non_empty(
|
||||
callback.get("order_id"),
|
||||
callback.get("orderId"),
|
||||
callback.get("InvId"),
|
||||
callback.get("InvID"),
|
||||
postback.get("order_id"),
|
||||
postback.get("orderId"),
|
||||
postback.get("InvId"),
|
||||
postback.get("InvID"),
|
||||
)
|
||||
status = (callback.get("status") or callback.get("Status") or "").upper()
|
||||
status = (postback.get("status") or postback.get("Status") or "").upper()
|
||||
|
||||
if not bill_id and not order_id:
|
||||
logger.error("Pal24 callback без идентификаторов: %s", callback)
|
||||
logger.error("Pal24 postback без идентификаторов: %s", postback)
|
||||
return False
|
||||
|
||||
payment = None
|
||||
@@ -250,65 +250,57 @@ class Pal24PaymentMixin:
|
||||
return True
|
||||
|
||||
if status in {"PAID", "SUCCESS", "OVERPAID"}:
|
||||
metadata = getattr(payment, "metadata_json", {}) or {}
|
||||
if not isinstance(metadata, dict):
|
||||
metadata = {}
|
||||
|
||||
payment = await payment_module.update_pal24_payment_status(
|
||||
db,
|
||||
payment,
|
||||
status=status,
|
||||
is_paid=True,
|
||||
paid_at=datetime.utcnow(),
|
||||
callback_payload=callback,
|
||||
postback_payload=postback,
|
||||
payment_id=payment_id,
|
||||
payment_status=callback.get("Status") or status,
|
||||
payment_status=postback.get("Status") or status,
|
||||
payment_method=(
|
||||
callback.get("payment_method")
|
||||
or callback.get("PaymentMethod")
|
||||
or metadata.get("selected_method")
|
||||
postback.get("payment_method")
|
||||
or postback.get("PaymentMethod")
|
||||
or (payment.metadata_json or {}).get("selected_method")
|
||||
or getattr(payment, "payment_method", None)
|
||||
),
|
||||
balance_amount=callback.get("BalanceAmount")
|
||||
or callback.get("balance_amount"),
|
||||
balance_currency=callback.get("BalanceCurrency")
|
||||
or callback.get("balance_currency"),
|
||||
payer_account=callback.get("AccountNumber")
|
||||
or callback.get("account")
|
||||
or callback.get("Account"),
|
||||
balance_amount=postback.get("BalanceAmount")
|
||||
or postback.get("balance_amount"),
|
||||
balance_currency=postback.get("BalanceCurrency")
|
||||
or postback.get("balance_currency"),
|
||||
payer_account=postback.get("AccountNumber")
|
||||
or postback.get("account")
|
||||
or postback.get("Account"),
|
||||
)
|
||||
|
||||
return await self._finalize_pal24_payment(
|
||||
db,
|
||||
payment,
|
||||
payment_id=payment_id,
|
||||
trigger="callback",
|
||||
trigger="postback",
|
||||
)
|
||||
|
||||
metadata = getattr(payment, "metadata_json", {}) or {}
|
||||
if not isinstance(metadata, dict):
|
||||
metadata = {}
|
||||
|
||||
await payment_module.update_pal24_payment_status(
|
||||
db,
|
||||
payment,
|
||||
status=status or "UNKNOWN",
|
||||
is_paid=False,
|
||||
callback_payload=callback,
|
||||
postback_payload=postback,
|
||||
payment_id=payment_id,
|
||||
payment_status=callback.get("Status") or status,
|
||||
payment_status=postback.get("Status") or status,
|
||||
payment_method=(
|
||||
callback.get("payment_method")
|
||||
or callback.get("PaymentMethod")
|
||||
postback.get("payment_method")
|
||||
or postback.get("PaymentMethod")
|
||||
or getattr(payment, "payment_method", None)
|
||||
),
|
||||
balance_amount=callback.get("BalanceAmount")
|
||||
or callback.get("balance_amount"),
|
||||
balance_currency=callback.get("BalanceCurrency")
|
||||
or callback.get("balance_currency"),
|
||||
payer_account=callback.get("AccountNumber")
|
||||
or callback.get("account")
|
||||
or callback.get("Account"),
|
||||
balance_amount=postback.get("BalanceAmount")
|
||||
or postback.get("balance_amount"),
|
||||
balance_currency=postback.get("BalanceCurrency")
|
||||
or postback.get("balance_currency"),
|
||||
payer_account=postback.get("AccountNumber")
|
||||
or postback.get("account")
|
||||
or postback.get("Account"),
|
||||
)
|
||||
logger.info(
|
||||
"Обновили Pal24 платеж %s до статуса %s",
|
||||
@@ -318,7 +310,7 @@ class Pal24PaymentMixin:
|
||||
return True
|
||||
|
||||
except Exception as error:
|
||||
logger.error("Ошибка обработки Pal24 callback: %s", error, exc_info=True)
|
||||
logger.error("Ошибка обработки Pal24 postback: %s", error, exc_info=True)
|
||||
return False
|
||||
|
||||
async def _finalize_pal24_payment(
|
||||
@@ -370,7 +362,7 @@ class Pal24PaymentMixin:
|
||||
user.balance_kopeks += payment.amount_kopeks
|
||||
user.updated_at = datetime.utcnow()
|
||||
|
||||
promo_group = user.get_primary_promo_group()
|
||||
promo_group = getattr(user, "promo_group", None)
|
||||
subscription = getattr(user, "subscription", None)
|
||||
referrer_info = format_referrer_info(user)
|
||||
topup_status = "🆕 Первое пополнение" if was_first_topup else "🔄 Пополнение"
|
||||
@@ -481,7 +473,7 @@ class Pal24PaymentMixin:
|
||||
"BALANCE_TOPUP_CART_BUTTON",
|
||||
"🛒 Продолжить оформление",
|
||||
),
|
||||
callback_data="return_to_saved_cart",
|
||||
callback_data="subscription_resume_checkout",
|
||||
)
|
||||
],
|
||||
[
|
||||
@@ -497,11 +489,7 @@ class Pal24PaymentMixin:
|
||||
chat_id=user.telegram_id,
|
||||
text=(
|
||||
"✅ Баланс пополнен на "
|
||||
f"{settings.format_price(payment.amount_kopeks)}!\n\n"
|
||||
f"⚠️ <b>Важно:</b> Пополнение баланса не активирует подписку автоматически. "
|
||||
f"Обязательно активируйте подписку отдельно!\n\n"
|
||||
f"🔄 При наличии сохранённой корзины подписки и включенной автопокупке, "
|
||||
f"подписка будет приобретена автоматически после пополнения баланса.\n\n{cart_message}"
|
||||
f"{settings.format_price(payment.amount_kopeks)}!\n\n{cart_message}"
|
||||
),
|
||||
reply_markup=keyboard,
|
||||
)
|
||||
@@ -693,34 +681,12 @@ class Pal24PaymentMixin:
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
links_map, selected_method = self._build_links_map(payment, remote_payloads)
|
||||
primary_url = (
|
||||
links_map.get(selected_method)
|
||||
or links_map.get("sbp")
|
||||
or links_map.get("page")
|
||||
or links_map.get("card")
|
||||
)
|
||||
secondary_url = (
|
||||
links_map.get("page")
|
||||
or links_map.get("card")
|
||||
or links_map.get("sbp")
|
||||
)
|
||||
|
||||
return {
|
||||
"payment": payment,
|
||||
"status": payment.status,
|
||||
"is_paid": payment.is_paid,
|
||||
"remote_status": remote_status_for_return,
|
||||
"remote_data": remote_data,
|
||||
"links": links_map or None,
|
||||
"primary_url": primary_url,
|
||||
"secondary_url": secondary_url,
|
||||
"sbp_url": links_map.get("sbp"),
|
||||
"card_url": links_map.get("card"),
|
||||
"link_page_url": links_map.get("page")
|
||||
or getattr(payment, "link_page_url", None),
|
||||
"link_url": getattr(payment, "link_url", None),
|
||||
"selected_method": selected_method,
|
||||
}
|
||||
|
||||
except Exception as error:
|
||||
@@ -871,112 +837,6 @@ class Pal24PaymentMixin:
|
||||
normalized = payment_method.strip().lower()
|
||||
return mapping.get(normalized, "sbp")
|
||||
|
||||
@staticmethod
|
||||
def _pick_first(mapping: Dict[str, Any], *keys: str) -> Optional[str]:
|
||||
for key in keys:
|
||||
value = mapping.get(key)
|
||||
if value:
|
||||
return str(value)
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
def _build_links_map(
|
||||
cls,
|
||||
payment: Any,
|
||||
remote_payloads: Dict[str, Any],
|
||||
) -> tuple[Dict[str, str], str]:
|
||||
links: Dict[str, str] = {}
|
||||
|
||||
metadata = getattr(payment, "metadata_json", {}) or {}
|
||||
if not isinstance(metadata, dict):
|
||||
metadata = {}
|
||||
|
||||
if metadata:
|
||||
links_meta = metadata.get("links")
|
||||
if isinstance(links_meta, dict):
|
||||
for key, value in links_meta.items():
|
||||
if value:
|
||||
links[key] = str(value)
|
||||
|
||||
selected_method = cls._normalize_payment_method(
|
||||
(metadata.get("selected_method") if isinstance(metadata, dict) else None)
|
||||
or getattr(payment, "payment_method", None)
|
||||
)
|
||||
|
||||
def _visit(value: Any) -> List[Dict[str, Any]]:
|
||||
stack: List[Any] = [value]
|
||||
result: List[Dict[str, Any]] = []
|
||||
while stack:
|
||||
current = stack.pop()
|
||||
if isinstance(current, dict):
|
||||
result.append(current)
|
||||
stack.extend(current.values())
|
||||
elif isinstance(current, list):
|
||||
stack.extend(current)
|
||||
return result
|
||||
|
||||
payload_sources: List[Any] = []
|
||||
if metadata:
|
||||
payload_sources.append(metadata.get("raw_response"))
|
||||
payload_sources.append(getattr(payment, "callback_payload", None))
|
||||
payload_sources.extend(remote_payloads.values())
|
||||
|
||||
sbp_keys = (
|
||||
"transfer_url",
|
||||
"transferUrl",
|
||||
"transfer_link",
|
||||
"transferLink",
|
||||
"transfer",
|
||||
"sbp_url",
|
||||
"sbpUrl",
|
||||
"sbp_link",
|
||||
"sbpLink",
|
||||
)
|
||||
card_keys = (
|
||||
"link_url",
|
||||
"linkUrl",
|
||||
"link",
|
||||
"card_url",
|
||||
"cardUrl",
|
||||
"card_link",
|
||||
"cardLink",
|
||||
"payment_url",
|
||||
"paymentUrl",
|
||||
"url",
|
||||
)
|
||||
page_keys = (
|
||||
"link_page_url",
|
||||
"linkPageUrl",
|
||||
"page_url",
|
||||
"pageUrl",
|
||||
)
|
||||
|
||||
for source in payload_sources:
|
||||
if not source:
|
||||
continue
|
||||
for candidate in _visit(source):
|
||||
sbp_url = cls._pick_first(candidate, *sbp_keys)
|
||||
if sbp_url and "sbp" not in links:
|
||||
links["sbp"] = sbp_url
|
||||
card_url = cls._pick_first(candidate, *card_keys)
|
||||
if card_url and "card" not in links:
|
||||
links["card"] = card_url
|
||||
page_url = cls._pick_first(candidate, *page_keys)
|
||||
if page_url and "page" not in links:
|
||||
links["page"] = page_url
|
||||
|
||||
if getattr(payment, "link_page_url", None):
|
||||
links.setdefault("page", str(payment.link_page_url))
|
||||
|
||||
if getattr(payment, "link_url", None):
|
||||
link_url_value = str(payment.link_url)
|
||||
if selected_method == "card":
|
||||
links.setdefault("card", link_url_value)
|
||||
else:
|
||||
links.setdefault("sbp", link_url_value)
|
||||
|
||||
return links, selected_method
|
||||
|
||||
@staticmethod
|
||||
def _map_api_payment_method(normalized_payment_method: str) -> Optional[str]:
|
||||
"""Преобразует нормализованный метод оплаты в значение для Pal24 API."""
|
||||
|
||||
@@ -1,536 +0,0 @@
|
||||
"""Mixin для интеграции платежей Platega."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from importlib import import_module
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.models import PaymentMethod, TransactionType
|
||||
from app.services.platega_service import PlategaService
|
||||
from app.services.subscription_auto_purchase_service import (
|
||||
auto_purchase_saved_cart_after_topup,
|
||||
)
|
||||
from app.utils.user_utils import format_referrer_info
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class PlategaPaymentMixin:
|
||||
"""Логика создания и обработки платежей Platega."""
|
||||
|
||||
_SUCCESS_STATUSES = {"CONFIRMED"}
|
||||
_FAILED_STATUSES = {"FAILED", "CANCELED", "EXPIRED"}
|
||||
_PENDING_STATUSES = {"PENDING", "INPROGRESS"}
|
||||
|
||||
async def create_platega_payment(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
*,
|
||||
user_id: int,
|
||||
amount_kopeks: int,
|
||||
description: str,
|
||||
language: str,
|
||||
payment_method_code: int,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
service: Optional[PlategaService] = getattr(self, "platega_service", None)
|
||||
if not service or not service.is_configured:
|
||||
logger.error("Platega сервис не инициализирован")
|
||||
return None
|
||||
|
||||
if amount_kopeks < settings.PLATEGA_MIN_AMOUNT_KOPEKS:
|
||||
logger.warning(
|
||||
"Сумма Platega меньше минимальной: %s < %s",
|
||||
amount_kopeks,
|
||||
settings.PLATEGA_MIN_AMOUNT_KOPEKS,
|
||||
)
|
||||
return None
|
||||
|
||||
if amount_kopeks > settings.PLATEGA_MAX_AMOUNT_KOPEKS:
|
||||
logger.warning(
|
||||
"Сумма Platega больше максимальной: %s > %s",
|
||||
amount_kopeks,
|
||||
settings.PLATEGA_MAX_AMOUNT_KOPEKS,
|
||||
)
|
||||
return None
|
||||
|
||||
correlation_id = uuid.uuid4().hex
|
||||
payload_token = f"platega:{correlation_id}"
|
||||
|
||||
amount_value = amount_kopeks / 100
|
||||
|
||||
try:
|
||||
response = await service.create_payment(
|
||||
payment_method=payment_method_code,
|
||||
amount=amount_value,
|
||||
currency=settings.PLATEGA_CURRENCY,
|
||||
description=description,
|
||||
return_url=settings.get_platega_return_url(),
|
||||
failed_url=settings.get_platega_failed_url(),
|
||||
payload=payload_token,
|
||||
)
|
||||
except Exception as error: # pragma: no cover - network errors
|
||||
logger.exception("Ошибка Platega при создании платежа: %s", error)
|
||||
return None
|
||||
|
||||
if not response:
|
||||
logger.error("Platega вернул пустой ответ при создании платежа")
|
||||
return None
|
||||
|
||||
transaction_id = response.get("transactionId") or response.get("id")
|
||||
redirect_url = response.get("redirect")
|
||||
status = str(response.get("status") or "PENDING").upper()
|
||||
expires_at = PlategaService.parse_expires_at(response.get("expiresIn"))
|
||||
|
||||
metadata = {
|
||||
"raw_response": response,
|
||||
"language": language,
|
||||
"selected_method": payment_method_code,
|
||||
}
|
||||
|
||||
payment_module = import_module("app.services.payment_service")
|
||||
|
||||
payment = await payment_module.create_platega_payment(
|
||||
db,
|
||||
user_id=user_id,
|
||||
amount_kopeks=amount_kopeks,
|
||||
currency=settings.PLATEGA_CURRENCY,
|
||||
description=description,
|
||||
status=status,
|
||||
payment_method_code=payment_method_code,
|
||||
correlation_id=correlation_id,
|
||||
platega_transaction_id=transaction_id,
|
||||
redirect_url=redirect_url,
|
||||
return_url=settings.get_platega_return_url(),
|
||||
failed_url=settings.get_platega_failed_url(),
|
||||
payload=payload_token,
|
||||
metadata=metadata,
|
||||
expires_at=expires_at,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"Создан Platega платеж %s для пользователя %s (метод %s, сумма %s₽)",
|
||||
transaction_id or payment.id,
|
||||
user_id,
|
||||
payment_method_code,
|
||||
amount_value,
|
||||
)
|
||||
|
||||
return {
|
||||
"local_payment_id": payment.id,
|
||||
"transaction_id": transaction_id,
|
||||
"redirect_url": redirect_url,
|
||||
"status": status,
|
||||
"expires_at": expires_at,
|
||||
"correlation_id": correlation_id,
|
||||
}
|
||||
|
||||
async def process_platega_webhook(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
payload: Dict[str, Any],
|
||||
) -> bool:
|
||||
payment_module = import_module("app.services.payment_service")
|
||||
|
||||
transaction_id = str(payload.get("id") or "").strip()
|
||||
payload_token = payload.get("payload")
|
||||
|
||||
payment = None
|
||||
if transaction_id:
|
||||
payment = await payment_module.get_platega_payment_by_transaction_id(
|
||||
db, transaction_id
|
||||
)
|
||||
if not payment and payload_token:
|
||||
payment = await payment_module.get_platega_payment_by_correlation_id(
|
||||
db, str(payload_token).replace("platega:", "")
|
||||
)
|
||||
|
||||
if not payment:
|
||||
logger.warning("Platega webhook: платеж не найден (id=%s)", transaction_id)
|
||||
return False
|
||||
|
||||
status_raw = str(payload.get("status") or "").upper()
|
||||
if not status_raw:
|
||||
logger.warning("Platega webhook без статуса для платежа %s", payment.id)
|
||||
return False
|
||||
|
||||
update_kwargs = {
|
||||
"status": status_raw,
|
||||
"callback_payload": payload,
|
||||
}
|
||||
|
||||
if transaction_id:
|
||||
update_kwargs["platega_transaction_id"] = transaction_id
|
||||
|
||||
if status_raw in self._SUCCESS_STATUSES:
|
||||
if payment.is_paid:
|
||||
logger.info(
|
||||
"Platega платеж %s уже помечен как оплачен", payment.correlation_id
|
||||
)
|
||||
await payment_module.update_platega_payment(
|
||||
db,
|
||||
payment=payment,
|
||||
**update_kwargs,
|
||||
is_paid=True,
|
||||
)
|
||||
return True
|
||||
|
||||
payment = await payment_module.update_platega_payment(
|
||||
db,
|
||||
payment=payment,
|
||||
**update_kwargs,
|
||||
)
|
||||
await self._finalize_platega_payment(db, payment, payload)
|
||||
return True
|
||||
|
||||
if status_raw in self._FAILED_STATUSES:
|
||||
await payment_module.update_platega_payment(
|
||||
db,
|
||||
payment=payment,
|
||||
**update_kwargs,
|
||||
is_paid=False,
|
||||
)
|
||||
logger.info(
|
||||
"Platega платеж %s перешёл в статус %s", payment.correlation_id, status_raw
|
||||
)
|
||||
return True
|
||||
|
||||
await payment_module.update_platega_payment(
|
||||
db,
|
||||
payment=payment,
|
||||
**update_kwargs,
|
||||
)
|
||||
return True
|
||||
|
||||
async def get_platega_payment_status(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
local_payment_id: int,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
payment_module = import_module("app.services.payment_service")
|
||||
payment = await payment_module.get_platega_payment_by_id(db, local_payment_id)
|
||||
if not payment:
|
||||
return None
|
||||
|
||||
service: Optional[PlategaService] = getattr(self, "platega_service", None)
|
||||
remote_status: Optional[str] = None
|
||||
remote_payload: Optional[Dict[str, Any]] = None
|
||||
|
||||
if service and payment.platega_transaction_id:
|
||||
try:
|
||||
remote_payload = await service.get_transaction(
|
||||
payment.platega_transaction_id
|
||||
)
|
||||
except Exception as error: # pragma: no cover - network errors
|
||||
logger.error(
|
||||
"Ошибка Platega при получении транзакции %s: %s",
|
||||
payment.platega_transaction_id,
|
||||
error,
|
||||
)
|
||||
|
||||
if remote_payload:
|
||||
remote_status = str(remote_payload.get("status") or "").upper()
|
||||
if remote_status and remote_status != payment.status:
|
||||
await payment_module.update_platega_payment(
|
||||
db,
|
||||
payment=payment,
|
||||
status=remote_status,
|
||||
metadata={
|
||||
**(getattr(payment, "metadata_json", {}) or {}),
|
||||
"remote_status": remote_payload,
|
||||
},
|
||||
)
|
||||
payment = await payment_module.get_platega_payment_by_id(db, local_payment_id)
|
||||
|
||||
if (
|
||||
remote_status in self._SUCCESS_STATUSES
|
||||
and not payment.is_paid
|
||||
):
|
||||
payment = await payment_module.update_platega_payment(
|
||||
db,
|
||||
payment=payment,
|
||||
status=remote_status,
|
||||
callback_payload=remote_payload,
|
||||
)
|
||||
await self._finalize_platega_payment(db, payment, remote_payload)
|
||||
|
||||
return {
|
||||
"payment": payment,
|
||||
"status": payment.status,
|
||||
"is_paid": payment.is_paid,
|
||||
"remote": remote_payload,
|
||||
}
|
||||
|
||||
async def _finalize_platega_payment(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
payment: Any,
|
||||
payload: Optional[Dict[str, Any]],
|
||||
) -> Any:
|
||||
payment_module = import_module("app.services.payment_service")
|
||||
|
||||
metadata = dict(getattr(payment, "metadata_json", {}) or {})
|
||||
if payload is not None:
|
||||
metadata["webhook"] = payload
|
||||
|
||||
paid_at = None
|
||||
if isinstance(payload, dict):
|
||||
paid_at_raw = payload.get("paidAt") or payload.get("confirmedAt")
|
||||
if paid_at_raw:
|
||||
try:
|
||||
paid_at = datetime.fromisoformat(str(paid_at_raw))
|
||||
except ValueError:
|
||||
paid_at = None
|
||||
|
||||
payment = await payment_module.update_platega_payment(
|
||||
db,
|
||||
payment=payment,
|
||||
status="CONFIRMED",
|
||||
is_paid=True,
|
||||
paid_at=paid_at,
|
||||
metadata=metadata,
|
||||
callback_payload=payload,
|
||||
)
|
||||
|
||||
locked_payment = await payment_module.get_platega_payment_by_id_for_update(
|
||||
db, payment.id
|
||||
)
|
||||
if locked_payment:
|
||||
payment = locked_payment
|
||||
|
||||
metadata = dict(getattr(payment, "metadata_json", {}) or {})
|
||||
balance_already_credited = bool(metadata.get("balance_credited"))
|
||||
|
||||
if payment.transaction_id:
|
||||
logger.info(
|
||||
"Platega платеж %s уже связан с транзакцией %s",
|
||||
payment.correlation_id,
|
||||
payment.transaction_id,
|
||||
)
|
||||
return payment
|
||||
|
||||
user = await payment_module.get_user_by_id(db, payment.user_id)
|
||||
if not user:
|
||||
logger.error("Пользователь %s не найден для Platega", payment.user_id)
|
||||
return payment
|
||||
|
||||
transaction_external_id = (
|
||||
str(payload.get("id"))
|
||||
if isinstance(payload, dict) and payload.get("id")
|
||||
else payment.platega_transaction_id
|
||||
)
|
||||
|
||||
existing_transaction = None
|
||||
if transaction_external_id:
|
||||
existing_transaction = await payment_module.get_transaction_by_external_id(
|
||||
db,
|
||||
transaction_external_id,
|
||||
PaymentMethod.PLATEGA,
|
||||
)
|
||||
|
||||
method_display = settings.get_platega_method_display_name(payment.payment_method_code)
|
||||
description = (
|
||||
f"Пополнение через Platega ({method_display})"
|
||||
if method_display
|
||||
else "Пополнение через Platega"
|
||||
)
|
||||
|
||||
transaction = existing_transaction
|
||||
created_transaction = False
|
||||
|
||||
if not transaction:
|
||||
transaction = await payment_module.create_transaction(
|
||||
db,
|
||||
user_id=payment.user_id,
|
||||
type=TransactionType.DEPOSIT,
|
||||
amount_kopeks=payment.amount_kopeks,
|
||||
description=description,
|
||||
payment_method=PaymentMethod.PLATEGA,
|
||||
external_id=transaction_external_id or payment.correlation_id,
|
||||
is_completed=True,
|
||||
)
|
||||
created_transaction = True
|
||||
|
||||
await payment_module.link_platega_payment_to_transaction(
|
||||
db, payment=payment, transaction_id=transaction.id
|
||||
)
|
||||
|
||||
should_credit_balance = created_transaction or not balance_already_credited
|
||||
|
||||
if not should_credit_balance:
|
||||
logger.info(
|
||||
"Platega платеж %s уже зачислил баланс ранее",
|
||||
payment.correlation_id,
|
||||
)
|
||||
return payment
|
||||
|
||||
old_balance = user.balance_kopeks
|
||||
was_first_topup = not user.has_made_first_topup
|
||||
|
||||
user.balance_kopeks += payment.amount_kopeks
|
||||
user.updated_at = datetime.utcnow()
|
||||
await db.commit()
|
||||
await db.refresh(user)
|
||||
|
||||
promo_group = user.get_primary_promo_group()
|
||||
subscription = getattr(user, "subscription", None)
|
||||
referrer_info = format_referrer_info(user)
|
||||
topup_status = "🆕 Первое пополнение" if was_first_topup else "🔄 Пополнение"
|
||||
|
||||
try:
|
||||
from app.services.referral_service import process_referral_topup
|
||||
|
||||
await process_referral_topup(
|
||||
db,
|
||||
user.id,
|
||||
payment.amount_kopeks,
|
||||
getattr(self, "bot", None),
|
||||
)
|
||||
except Exception as error:
|
||||
logger.error("Ошибка обработки реферального пополнения Platega: %s", error)
|
||||
|
||||
if was_first_topup and not user.has_made_first_topup:
|
||||
user.has_made_first_topup = True
|
||||
await db.commit()
|
||||
await db.refresh(user)
|
||||
|
||||
if getattr(self, "bot", None):
|
||||
try:
|
||||
from app.services.admin_notification_service import AdminNotificationService
|
||||
|
||||
notification_service = AdminNotificationService(self.bot)
|
||||
await notification_service.send_balance_topup_notification(
|
||||
user,
|
||||
transaction,
|
||||
old_balance,
|
||||
topup_status=topup_status,
|
||||
referrer_info=referrer_info,
|
||||
subscription=subscription,
|
||||
promo_group=promo_group,
|
||||
db=db,
|
||||
)
|
||||
except Exception as error:
|
||||
logger.error("Ошибка отправки админ уведомления Platega: %s", error)
|
||||
|
||||
method_title = settings.get_platega_method_display_title(payment.payment_method_code)
|
||||
|
||||
if getattr(self, "bot", None):
|
||||
try:
|
||||
keyboard = await self.build_topup_success_keyboard(user)
|
||||
await self.bot.send_message(
|
||||
user.telegram_id,
|
||||
(
|
||||
"✅ <b>Пополнение успешно!</b>\n\n"
|
||||
f"💰 Сумма: {settings.format_price(payment.amount_kopeks)}\n"
|
||||
f"🦊 Способ: {method_title}\n"
|
||||
f"🆔 Транзакция: {transaction.id}\n\n"
|
||||
"Баланс пополнен автоматически!"
|
||||
),
|
||||
parse_mode="HTML",
|
||||
reply_markup=keyboard,
|
||||
)
|
||||
except Exception as error:
|
||||
logger.error("Ошибка отправки уведомления пользователю Platega: %s", error)
|
||||
|
||||
try:
|
||||
from app.services.user_cart_service import user_cart_service
|
||||
from aiogram import types
|
||||
|
||||
has_saved_cart = await user_cart_service.has_user_cart(user.id)
|
||||
auto_purchase_success = False
|
||||
if has_saved_cart:
|
||||
try:
|
||||
auto_purchase_success = await auto_purchase_saved_cart_after_topup(
|
||||
db,
|
||||
user,
|
||||
bot=getattr(self, "bot", None),
|
||||
)
|
||||
except Exception as auto_error:
|
||||
logger.error(
|
||||
"Ошибка автоматической покупки подписки для пользователя %s: %s",
|
||||
user.id,
|
||||
auto_error,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
if auto_purchase_success:
|
||||
has_saved_cart = False
|
||||
|
||||
if has_saved_cart and getattr(self, "bot", None):
|
||||
from app.localization.texts import get_texts
|
||||
|
||||
texts = get_texts(user.language)
|
||||
cart_message = texts.t(
|
||||
"BALANCE_TOPUP_CART_REMINDER_DETAILED",
|
||||
"🛒 У вас есть неоформленный заказ.\n\n"
|
||||
"Вы можете продолжить оформление с теми же параметрами.",
|
||||
)
|
||||
|
||||
keyboard = types.InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.RETURN_TO_SUBSCRIPTION_CHECKOUT,
|
||||
callback_data="return_to_saved_cart",
|
||||
)
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text="💰 Мой баланс",
|
||||
callback_data="menu_balance",
|
||||
)
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text="🏠 Главное меню",
|
||||
callback_data="back_to_menu",
|
||||
)
|
||||
],
|
||||
]
|
||||
)
|
||||
|
||||
await self.bot.send_message(
|
||||
chat_id=user.telegram_id,
|
||||
text=(
|
||||
f"✅ Баланс пополнен на {settings.format_price(payment.amount_kopeks)}!\n\n"
|
||||
f"⚠️ <b>Важно:</b> Пополнение баланса не активирует подписку автоматически. "
|
||||
f"Обязательно активируйте подписку отдельно!\n\n"
|
||||
f"🔄 При наличии сохранённой корзины подписки и включенной автопокупке, "
|
||||
f"подписка будет приобретена автоматически после пополнения баланса.\n\n"
|
||||
f"{cart_message}"
|
||||
),
|
||||
reply_markup=keyboard,
|
||||
)
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
"Ошибка при работе с сохраненной корзиной для пользователя %s: %s",
|
||||
payment.user_id,
|
||||
error,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
metadata["balance_change"] = {
|
||||
"old_balance": old_balance,
|
||||
"new_balance": user.balance_kopeks,
|
||||
"credited_at": datetime.utcnow().isoformat(),
|
||||
}
|
||||
metadata["balance_credited"] = True
|
||||
|
||||
await payment_module.update_platega_payment(
|
||||
db,
|
||||
payment=payment,
|
||||
metadata=metadata,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"✅ Обработан Platega платеж %s для пользователя %s",
|
||||
payment.correlation_id,
|
||||
payment.user_id,
|
||||
)
|
||||
|
||||
return payment
|
||||
@@ -430,7 +430,7 @@ class TelegramStarsMixin:
|
||||
user.balance_kopeks += amount_kopeks
|
||||
user.updated_at = datetime.utcnow()
|
||||
|
||||
promo_group = user.get_primary_promo_group()
|
||||
promo_group = getattr(user, "promo_group", None)
|
||||
subscription = getattr(user, "subscription", None)
|
||||
referrer_info = format_referrer_info(user)
|
||||
topup_status = "🆕 Первое пополнение" if was_first_topup else "🔄 Пополнение"
|
||||
@@ -584,7 +584,7 @@ class TelegramStarsMixin:
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.RETURN_TO_SUBSCRIPTION_CHECKOUT,
|
||||
callback_data="return_to_saved_cart",
|
||||
callback_data="subscription_resume_checkout",
|
||||
)
|
||||
],
|
||||
[
|
||||
@@ -604,11 +604,7 @@ class TelegramStarsMixin:
|
||||
|
||||
await self.bot.send_message(
|
||||
chat_id=user.telegram_id,
|
||||
text=f"✅ Баланс пополнен на {settings.format_price(amount_kopeks)}!\n\n"
|
||||
f"⚠️ <b>Важно:</b> Пополнение баланса не активирует подписку автоматически. "
|
||||
f"Обязательно активируйте подписку отдельно!\n\n"
|
||||
f"🔄 При наличии сохранённой корзины подписки и включенной автопокупке, "
|
||||
f"подписка будет приобретена автоматически после пополнения баланса.\n\n{cart_message}",
|
||||
text=f"✅ Баланс пополнен на {settings.format_price(amount_kopeks)}!\n\n{cart_message}",
|
||||
reply_markup=keyboard,
|
||||
)
|
||||
logger.info(
|
||||
|
||||
@@ -464,7 +464,7 @@ class WataPaymentMixin:
|
||||
await db.commit()
|
||||
await db.refresh(user)
|
||||
|
||||
promo_group = user.get_primary_promo_group()
|
||||
promo_group = getattr(user, "promo_group", None)
|
||||
subscription = getattr(user, "subscription", None)
|
||||
referrer_info = format_referrer_info(user)
|
||||
topup_status = "🆕 Первое пополнение" if was_first_topup else "🔄 Пополнение"
|
||||
@@ -514,10 +514,6 @@ class WataPaymentMixin:
|
||||
f"💰 Сумма: {settings.format_price(payment.amount_kopeks)}\n"
|
||||
"🦊 Способ: WATA\n"
|
||||
f"🆔 Транзакция: {transaction.id}\n\n"
|
||||
"⚠️ <b>Важно:</b> Пополнение баланса не активирует подписку автоматически. "
|
||||
"Обязательно активируйте подписку отдельно!\n\n"
|
||||
"🔄 При наличии сохранённой корзины подписки и включенной автопокупке, "
|
||||
"подписка будет приобретена автоматически после пополнения баланса.\n\n"
|
||||
"Баланс пополнен автоматически!"
|
||||
),
|
||||
parse_mode="HTML",
|
||||
@@ -565,7 +561,7 @@ class WataPaymentMixin:
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.RETURN_TO_SUBSCRIPTION_CHECKOUT,
|
||||
callback_data="return_to_saved_cart",
|
||||
callback_data="subscription_resume_checkout",
|
||||
)
|
||||
],
|
||||
[
|
||||
|
||||
@@ -30,66 +30,6 @@ if TYPE_CHECKING:
|
||||
class YooKassaPaymentMixin:
|
||||
"""Mixin с операциями по созданию и подтверждению платежей YooKassa."""
|
||||
|
||||
@staticmethod
|
||||
def _format_amount_value(value: Any) -> str:
|
||||
"""Форматирует сумму для хранения в webhook-объекте."""
|
||||
|
||||
try:
|
||||
quantized = Decimal(str(value)).quantize(Decimal("0.00"))
|
||||
return format(quantized, "f")
|
||||
except (InvalidOperation, ValueError, TypeError):
|
||||
return str(value)
|
||||
|
||||
@classmethod
|
||||
def _merge_remote_yookassa_payload(
|
||||
cls,
|
||||
event_object: Dict[str, Any],
|
||||
remote_data: Dict[str, Any],
|
||||
) -> Dict[str, Any]:
|
||||
"""Объединяет локальные данные вебхука с ответом API YooKassa."""
|
||||
|
||||
merged: Dict[str, Any] = dict(event_object)
|
||||
|
||||
status = remote_data.get("status")
|
||||
if status:
|
||||
merged["status"] = status
|
||||
|
||||
if "paid" in remote_data:
|
||||
merged["paid"] = bool(remote_data.get("paid"))
|
||||
|
||||
if "refundable" in remote_data:
|
||||
merged["refundable"] = bool(remote_data.get("refundable"))
|
||||
|
||||
payment_method_type = remote_data.get("payment_method_type")
|
||||
if payment_method_type:
|
||||
payment_method = dict(merged.get("payment_method") or {})
|
||||
payment_method["type"] = payment_method_type
|
||||
merged["payment_method"] = payment_method
|
||||
|
||||
amount_value = remote_data.get("amount_value")
|
||||
amount_currency = remote_data.get("amount_currency")
|
||||
if amount_value is not None or amount_currency:
|
||||
merged_amount = dict(merged.get("amount") or {})
|
||||
if amount_value is not None:
|
||||
merged_amount["value"] = cls._format_amount_value(amount_value)
|
||||
if amount_currency:
|
||||
merged_amount["currency"] = str(amount_currency).upper()
|
||||
merged["amount"] = merged_amount
|
||||
|
||||
for datetime_field in ("captured_at", "created_at"):
|
||||
value = remote_data.get(datetime_field)
|
||||
if value:
|
||||
merged[datetime_field] = value
|
||||
|
||||
metadata = remote_data.get("metadata")
|
||||
if metadata:
|
||||
try:
|
||||
merged["metadata"] = dict(metadata) # type: ignore[arg-type]
|
||||
except TypeError:
|
||||
merged["metadata"] = metadata
|
||||
|
||||
return merged
|
||||
|
||||
async def create_yookassa_payment(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
@@ -379,50 +319,9 @@ class YooKassaPaymentMixin:
|
||||
) -> bool:
|
||||
"""Переносит успешный платёж YooKassa в транзакции и начисляет баланс пользователю."""
|
||||
try:
|
||||
from sqlalchemy import select
|
||||
payment_module = import_module("app.services.payment_service")
|
||||
|
||||
# Проверяем, не обрабатывается ли уже этот платеж (защита от дублирования)
|
||||
existing_transaction = await payment_module.get_transaction_by_external_id( # type: ignore[attr-defined]
|
||||
db,
|
||||
payment.yookassa_payment_id,
|
||||
PaymentMethod.YOOKASSA,
|
||||
)
|
||||
|
||||
if existing_transaction:
|
||||
# Если транзакция уже существует, просто завершаем обработку
|
||||
logger.info(
|
||||
"Платеж YooKassa %s уже был обработан транзакцией %s. Пропускаем повторную обработку.",
|
||||
payment.yookassa_payment_id,
|
||||
existing_transaction.id,
|
||||
)
|
||||
|
||||
# Убедимся, что платеж связан с транзакцией
|
||||
if not getattr(payment, "transaction_id", None):
|
||||
try:
|
||||
linked_payment = await payment_module.link_yookassa_payment_to_transaction( # type: ignore[attr-defined]
|
||||
db,
|
||||
payment.yookassa_payment_id,
|
||||
existing_transaction.id,
|
||||
)
|
||||
if linked_payment:
|
||||
payment.transaction_id = getattr(
|
||||
linked_payment,
|
||||
"transaction_id",
|
||||
existing_transaction.id,
|
||||
)
|
||||
if hasattr(linked_payment, "transaction"):
|
||||
payment.transaction = linked_payment.transaction
|
||||
except Exception as link_error: # pragma: no cover - защитный лог
|
||||
logger.warning(
|
||||
"Не удалось привязать платеж YooKassa %s к существующей транзакции %s: %s",
|
||||
payment.yookassa_payment_id,
|
||||
existing_transaction.id,
|
||||
link_error,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
return True
|
||||
payment_description = getattr(payment, "description", "YooKassa платеж")
|
||||
|
||||
payment_metadata: Dict[str, Any] = {}
|
||||
try:
|
||||
@@ -437,84 +336,6 @@ class YooKassaPaymentMixin:
|
||||
except Exception as parse_error:
|
||||
logger.error(f"Ошибка парсинга метаданных платежа: {parse_error}")
|
||||
|
||||
processing_completed = bool(payment_metadata.get("processing_completed"))
|
||||
|
||||
transaction = None
|
||||
|
||||
existing_transaction_id = getattr(payment, "transaction_id", None)
|
||||
if existing_transaction_id:
|
||||
try:
|
||||
from app.database.crud.transaction import get_transaction_by_id
|
||||
|
||||
transaction = await get_transaction_by_id(db, existing_transaction_id)
|
||||
except Exception as fetch_error: # pragma: no cover - диагностический лог
|
||||
logger.warning(
|
||||
"Не удалось получить транзакцию %s для платежа YooKassa %s: %s",
|
||||
existing_transaction_id,
|
||||
payment.yookassa_payment_id,
|
||||
fetch_error,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
if transaction and processing_completed:
|
||||
logger.info(
|
||||
"Пропускаем повторную обработку платежа YooKassa %s: транзакция %s уже завершила начисление.",
|
||||
payment.yookassa_payment_id,
|
||||
existing_transaction_id,
|
||||
)
|
||||
return True
|
||||
|
||||
if transaction:
|
||||
logger.info(
|
||||
"Транзакция %s для платежа YooKassa %s найдена, но обработка ранее не была завершена — повторяем критические шаги.",
|
||||
existing_transaction_id,
|
||||
payment.yookassa_payment_id,
|
||||
)
|
||||
|
||||
if transaction is None:
|
||||
existing_transaction = await payment_module.get_transaction_by_external_id( # type: ignore[attr-defined]
|
||||
db,
|
||||
payment.yookassa_payment_id,
|
||||
PaymentMethod.YOOKASSA,
|
||||
)
|
||||
|
||||
if existing_transaction:
|
||||
# Если транзакция уже существует, пропускаем обработку
|
||||
logger.info(
|
||||
"Платеж YooKassa %s уже был обработан транзакцией %s. Пропускаем повторную обработку.",
|
||||
payment.yookassa_payment_id,
|
||||
existing_transaction.id,
|
||||
)
|
||||
|
||||
# Убедимся, что платеж связан с транзакцией
|
||||
if not getattr(payment, "transaction_id", None):
|
||||
try:
|
||||
linked_payment = await payment_module.link_yookassa_payment_to_transaction( # type: ignore[attr-defined]
|
||||
db,
|
||||
payment.yookassa_payment_id,
|
||||
existing_transaction.id,
|
||||
)
|
||||
if linked_payment:
|
||||
payment.transaction_id = getattr(
|
||||
linked_payment,
|
||||
"transaction_id",
|
||||
existing_transaction.id,
|
||||
)
|
||||
if hasattr(linked_payment, "transaction"):
|
||||
payment.transaction = linked_payment.transaction
|
||||
except Exception as link_error: # pragma: no cover - защитный лог
|
||||
logger.warning(
|
||||
"Не удалось привязать платеж YooKassa %s к существующей транзакции %s: %s",
|
||||
payment.yookassa_payment_id,
|
||||
existing_transaction.id,
|
||||
link_error,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
payment_description = getattr(payment, "description", "YooKassa платеж")
|
||||
|
||||
payment_purpose = payment_metadata.get("payment_purpose", "")
|
||||
is_simple_subscription = payment_purpose == "simple_subscription_purchase"
|
||||
|
||||
@@ -529,32 +350,27 @@ class YooKassaPaymentMixin:
|
||||
else f"Пополнение через YooKassa: {payment_description}"
|
||||
)
|
||||
|
||||
if transaction is None:
|
||||
transaction = await payment_module.create_transaction(
|
||||
db=db,
|
||||
user_id=payment.user_id,
|
||||
type=transaction_type,
|
||||
amount_kopeks=payment.amount_kopeks,
|
||||
description=transaction_description,
|
||||
payment_method=PaymentMethod.YOOKASSA,
|
||||
external_id=payment.yookassa_payment_id,
|
||||
is_completed=True,
|
||||
)
|
||||
transaction = await payment_module.create_transaction(
|
||||
db=db,
|
||||
user_id=payment.user_id,
|
||||
type=transaction_type,
|
||||
amount_kopeks=payment.amount_kopeks,
|
||||
description=transaction_description,
|
||||
payment_method=PaymentMethod.YOOKASSA,
|
||||
external_id=payment.yookassa_payment_id,
|
||||
is_completed=True,
|
||||
)
|
||||
|
||||
if not getattr(payment, "transaction_id", None):
|
||||
linked_payment = await payment_module.link_yookassa_payment_to_transaction(
|
||||
db,
|
||||
payment.yookassa_payment_id,
|
||||
transaction.id,
|
||||
)
|
||||
linked_payment = await payment_module.link_yookassa_payment_to_transaction(
|
||||
db,
|
||||
payment.yookassa_payment_id,
|
||||
transaction.id,
|
||||
)
|
||||
|
||||
if linked_payment:
|
||||
payment.transaction_id = getattr(linked_payment, "transaction_id", transaction.id)
|
||||
if hasattr(linked_payment, "transaction"):
|
||||
payment.transaction = linked_payment.transaction
|
||||
|
||||
critical_flow_completed = False
|
||||
processing_marked = False
|
||||
if linked_payment:
|
||||
payment.transaction_id = getattr(linked_payment, "transaction_id", transaction.id)
|
||||
if hasattr(linked_payment, "transaction"):
|
||||
payment.transaction = linked_payment.transaction
|
||||
|
||||
user = await payment_module.get_user_by_id(db, payment.user_id)
|
||||
if user:
|
||||
@@ -571,39 +387,13 @@ class YooKassaPaymentMixin:
|
||||
user.balance_kopeks += payment.amount_kopeks
|
||||
user.updated_at = datetime.utcnow()
|
||||
|
||||
# Обновляем пользователя с нужными связями, чтобы избежать проблем с ленивой загрузкой
|
||||
from app.database.crud.user import get_user_by_id
|
||||
from sqlalchemy.orm import selectinload
|
||||
from app.database.models import User, Subscription as SubscriptionModel
|
||||
|
||||
# Загружаем пользователя с подпиской и промо-группой
|
||||
full_user_result = await db.execute(
|
||||
select(User)
|
||||
.options(selectinload(User.subscription))
|
||||
.options(selectinload(User.user_promo_groups))
|
||||
.where(User.id == user.id)
|
||||
)
|
||||
full_user = full_user_result.scalar_one_or_none()
|
||||
|
||||
# Используем обновленные данные или исходные, если не удалось обновить
|
||||
subscription = full_user.subscription if full_user else getattr(user, "subscription", None)
|
||||
promo_group = full_user.get_primary_promo_group() if full_user else (user.get_primary_promo_group() if hasattr(user, 'get_primary_promo_group') else None)
|
||||
|
||||
# Используем full_user для форматирования реферальной информации, чтобы избежать проблем с ленивой загрузкой
|
||||
user_for_referrer = full_user if full_user else user
|
||||
referrer_info = format_referrer_info(user_for_referrer)
|
||||
promo_group = getattr(user, "promo_group", None)
|
||||
subscription = getattr(user, "subscription", None)
|
||||
referrer_info = format_referrer_info(user)
|
||||
topup_status = (
|
||||
"🆕 Первое пополнение" if was_first_topup else "🔄 Пополнение"
|
||||
)
|
||||
|
||||
payment_metadata = await self._mark_yookassa_payment_processing_completed(
|
||||
db,
|
||||
payment,
|
||||
payment_metadata,
|
||||
commit=False,
|
||||
)
|
||||
processing_marked = True
|
||||
|
||||
await db.commit()
|
||||
|
||||
try:
|
||||
@@ -635,13 +425,8 @@ class YooKassaPaymentMixin:
|
||||
)
|
||||
|
||||
notification_service = AdminNotificationService(self.bot)
|
||||
|
||||
# Обновляем пользователя, чтобы избежать проблем с ленивой загрузкой
|
||||
from app.database.crud.user import get_user_by_id
|
||||
refreshed_user = await get_user_by_id(db, user.id)
|
||||
|
||||
await notification_service.send_balance_topup_notification(
|
||||
refreshed_user or user,
|
||||
user,
|
||||
transaction,
|
||||
old_balance,
|
||||
topup_status=topup_status,
|
||||
@@ -725,7 +510,7 @@ class YooKassaPaymentMixin:
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.RETURN_TO_SUBSCRIPTION_CHECKOUT,
|
||||
callback_data="return_to_saved_cart",
|
||||
callback_data="subscription_resume_checkout",
|
||||
)
|
||||
],
|
||||
[
|
||||
@@ -745,11 +530,7 @@ class YooKassaPaymentMixin:
|
||||
|
||||
await self.bot.send_message(
|
||||
chat_id=user.telegram_id,
|
||||
text=f"✅ Баланс пополнен на {settings.format_price(payment.amount_kopeks)}!\n\n"
|
||||
f"⚠️ <b>Важно:</b> Пополнение баланса не активирует подписку автоматически. "
|
||||
f"Обязательно активируйте подписку отдельно!\n\n"
|
||||
f"🔄 При наличии сохранённой корзины подписки и включенной автопокупке, "
|
||||
f"подписка будет приобретена автоматически после пополнения баланса.\n\n{cart_message}",
|
||||
text=f"✅ Баланс пополнен на {settings.format_price(payment.amount_kopeks)}!\n\n{cart_message}",
|
||||
reply_markup=keyboard,
|
||||
)
|
||||
logger.info(
|
||||
@@ -838,32 +619,10 @@ class YooKassaPaymentMixin:
|
||||
)
|
||||
|
||||
notification_service = AdminNotificationService(self.bot)
|
||||
|
||||
# Обновляем пользователя с нужными связями, чтобы избежать проблем с ленивой загрузкой
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import selectinload
|
||||
from app.database.models import User, Subscription as SubscriptionModel
|
||||
|
||||
# Загружаем пользователя с подпиской и промо-группой
|
||||
full_user_result = await db.execute(
|
||||
select(User)
|
||||
.options(selectinload(User.subscription))
|
||||
.options(selectinload(User.user_promo_groups))
|
||||
.where(User.id == user.id)
|
||||
)
|
||||
full_user = full_user_result.scalar_one_or_none()
|
||||
|
||||
# Загружаем подписку отдельно, если нужно
|
||||
subscription_result = await db.execute(
|
||||
select(SubscriptionModel)
|
||||
.where(SubscriptionModel.user_id == user.id)
|
||||
)
|
||||
subscription_db = subscription_result.scalar_one_or_none()
|
||||
|
||||
await notification_service.send_subscription_purchase_notification(
|
||||
db,
|
||||
full_user or user,
|
||||
subscription_db or subscription,
|
||||
user,
|
||||
subscription,
|
||||
transaction,
|
||||
subscription_period,
|
||||
was_trial_conversion=False,
|
||||
@@ -879,31 +638,6 @@ class YooKassaPaymentMixin:
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка активации подписки для пользователя {user.id}: {e}", exc_info=True)
|
||||
|
||||
if not processing_marked:
|
||||
payment_metadata = await self._mark_yookassa_payment_processing_completed(
|
||||
db,
|
||||
payment,
|
||||
payment_metadata,
|
||||
commit=True,
|
||||
)
|
||||
processing_marked = True
|
||||
|
||||
critical_flow_completed = True
|
||||
else:
|
||||
logger.warning(
|
||||
"Пользователь %s для платежа YooKassa %s не найден — начисление баланса невозможно",
|
||||
payment.user_id,
|
||||
payment.yookassa_payment_id,
|
||||
)
|
||||
|
||||
if critical_flow_completed and not processing_marked:
|
||||
payment_metadata = await self._mark_yookassa_payment_processing_completed(
|
||||
db,
|
||||
payment,
|
||||
payment_metadata,
|
||||
commit=True,
|
||||
)
|
||||
|
||||
if is_simple_subscription:
|
||||
logger.info(
|
||||
"Успешно обработан платеж YooKassa %s как покупка подписки: пользователь %s, сумма %s₽",
|
||||
@@ -929,46 +663,6 @@ class YooKassaPaymentMixin:
|
||||
)
|
||||
return False
|
||||
|
||||
async def _mark_yookassa_payment_processing_completed(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
payment: "YooKassaPayment",
|
||||
payment_metadata: Dict[str, Any],
|
||||
*,
|
||||
commit: bool = False,
|
||||
) -> Dict[str, Any]:
|
||||
"""Отмечает платёж как полностью обработанный, чтобы избежать повторного начисления."""
|
||||
|
||||
if payment_metadata.get("processing_completed"):
|
||||
return payment_metadata
|
||||
|
||||
updated_metadata = dict(payment_metadata)
|
||||
updated_metadata["processing_completed"] = True
|
||||
|
||||
try:
|
||||
from sqlalchemy import update
|
||||
from app.database.models import YooKassaPayment as YooKassaPaymentModel
|
||||
|
||||
await db.execute(
|
||||
update(YooKassaPaymentModel)
|
||||
.where(YooKassaPaymentModel.id == payment.id)
|
||||
.values(metadata_json=updated_metadata, updated_at=datetime.utcnow())
|
||||
)
|
||||
if commit:
|
||||
await db.commit()
|
||||
else:
|
||||
await db.flush()
|
||||
payment.metadata_json = updated_metadata
|
||||
except Exception as mark_error: # pragma: no cover - защитный лог
|
||||
logger.warning(
|
||||
"Не удалось отметить платеж YooKassa %s как завершенный: %s",
|
||||
payment.yookassa_payment_id,
|
||||
mark_error,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
return updated_metadata
|
||||
|
||||
async def process_yookassa_webhook(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
@@ -982,32 +676,6 @@ class YooKassaPaymentMixin:
|
||||
logger.warning("Webhook без payment id: %s", event)
|
||||
return False
|
||||
|
||||
remote_data: Optional[Dict[str, Any]] = None
|
||||
if getattr(self, "yookassa_service", None):
|
||||
try:
|
||||
remote_data = await self.yookassa_service.get_payment_info( # type: ignore[union-attr]
|
||||
yookassa_payment_id
|
||||
)
|
||||
except Exception as error: # pragma: no cover - диагностический лог
|
||||
logger.warning(
|
||||
"Не удалось запросить актуальный статус платежа YooKassa %s: %s",
|
||||
yookassa_payment_id,
|
||||
error,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
if remote_data:
|
||||
previous_status = event_object.get("status")
|
||||
event_object = self._merge_remote_yookassa_payload(event_object, remote_data)
|
||||
if previous_status and event_object.get("status") != previous_status:
|
||||
logger.info(
|
||||
"Статус платежа YooKassa %s скорректирован по данным API: %s → %s",
|
||||
yookassa_payment_id,
|
||||
previous_status,
|
||||
event_object.get("status"),
|
||||
)
|
||||
event["object"] = event_object
|
||||
|
||||
payment_module = import_module("app.services.payment_service")
|
||||
|
||||
payment = await payment_module.get_yookassa_payment_by_id(db, yookassa_payment_id)
|
||||
|
||||
@@ -15,13 +15,11 @@ from app.external.heleket import HeleketService
|
||||
from app.external.telegram_stars import TelegramStarsService
|
||||
from app.services.mulenpay_service import MulenPayService
|
||||
from app.services.pal24_service import Pal24Service
|
||||
from app.services.platega_service import PlategaService
|
||||
from app.services.payment import (
|
||||
CryptoBotPaymentMixin,
|
||||
HeleketPaymentMixin,
|
||||
MulenPayPaymentMixin,
|
||||
Pal24PaymentMixin,
|
||||
PlategaPaymentMixin,
|
||||
PaymentCommonMixin,
|
||||
TelegramStarsMixin,
|
||||
TributePaymentMixin,
|
||||
@@ -67,11 +65,6 @@ async def create_transaction(*args, **kwargs):
|
||||
return await transaction_crud.create_transaction(*args, **kwargs)
|
||||
|
||||
|
||||
async def get_transaction_by_external_id(*args, **kwargs):
|
||||
transaction_crud = import_module("app.database.crud.transaction")
|
||||
return await transaction_crud.get_transaction_by_external_id(*args, **kwargs)
|
||||
|
||||
|
||||
async def add_user_balance(*args, **kwargs):
|
||||
user_crud = import_module("app.database.crud.user")
|
||||
return await user_crud.add_user_balance(*args, **kwargs)
|
||||
@@ -177,41 +170,6 @@ async def link_wata_payment_to_transaction(*args, **kwargs):
|
||||
return await wata_crud.link_wata_payment_to_transaction(*args, **kwargs)
|
||||
|
||||
|
||||
async def create_platega_payment(*args, **kwargs):
|
||||
platega_crud = import_module("app.database.crud.platega")
|
||||
return await platega_crud.create_platega_payment(*args, **kwargs)
|
||||
|
||||
|
||||
async def get_platega_payment_by_id(*args, **kwargs):
|
||||
platega_crud = import_module("app.database.crud.platega")
|
||||
return await platega_crud.get_platega_payment_by_id(*args, **kwargs)
|
||||
|
||||
|
||||
async def get_platega_payment_by_id_for_update(*args, **kwargs):
|
||||
platega_crud = import_module("app.database.crud.platega")
|
||||
return await platega_crud.get_platega_payment_by_id_for_update(*args, **kwargs)
|
||||
|
||||
|
||||
async def get_platega_payment_by_transaction_id(*args, **kwargs):
|
||||
platega_crud = import_module("app.database.crud.platega")
|
||||
return await platega_crud.get_platega_payment_by_transaction_id(*args, **kwargs)
|
||||
|
||||
|
||||
async def get_platega_payment_by_correlation_id(*args, **kwargs):
|
||||
platega_crud = import_module("app.database.crud.platega")
|
||||
return await platega_crud.get_platega_payment_by_correlation_id(*args, **kwargs)
|
||||
|
||||
|
||||
async def update_platega_payment(*args, **kwargs):
|
||||
platega_crud = import_module("app.database.crud.platega")
|
||||
return await platega_crud.update_platega_payment(*args, **kwargs)
|
||||
|
||||
|
||||
async def link_platega_payment_to_transaction(*args, **kwargs):
|
||||
platega_crud = import_module("app.database.crud.platega")
|
||||
return await platega_crud.link_platega_payment_to_transaction(*args, **kwargs)
|
||||
|
||||
|
||||
async def create_cryptobot_payment(*args, **kwargs):
|
||||
crypto_crud = import_module("app.database.crud.cryptobot")
|
||||
return await crypto_crud.create_cryptobot_payment(*args, **kwargs)
|
||||
@@ -266,7 +224,6 @@ class PaymentService(
|
||||
HeleketPaymentMixin,
|
||||
MulenPayPaymentMixin,
|
||||
Pal24PaymentMixin,
|
||||
PlategaPaymentMixin,
|
||||
WataPaymentMixin,
|
||||
):
|
||||
"""Основной интерфейс платежей, делегирующий работу специализированным mixin-ам."""
|
||||
@@ -291,14 +248,11 @@ class PaymentService(
|
||||
self.pal24_service = (
|
||||
Pal24Service() if settings.is_pal24_enabled() else None
|
||||
)
|
||||
self.platega_service = (
|
||||
PlategaService() if settings.is_platega_enabled() else None
|
||||
)
|
||||
self.wata_service = WataService() if settings.is_wata_enabled() else None
|
||||
|
||||
mulenpay_name = settings.get_mulenpay_display_name()
|
||||
logger.debug(
|
||||
"PaymentService инициализирован (YooKassa=%s, Stars=%s, CryptoBot=%s, Heleket=%s, %s=%s, Pal24=%s, Platega=%s, Wata=%s)",
|
||||
"PaymentService инициализирован (YooKassa=%s, Stars=%s, CryptoBot=%s, Heleket=%s, %s=%s, Pal24=%s, Wata=%s)",
|
||||
bool(self.yookassa_service),
|
||||
bool(self.stars_service),
|
||||
bool(self.cryptobot_service),
|
||||
@@ -306,6 +260,5 @@ class PaymentService(
|
||||
mulenpay_name,
|
||||
bool(self.mulenpay_service),
|
||||
bool(self.pal24_service),
|
||||
bool(self.platega_service),
|
||||
bool(self.wata_service),
|
||||
)
|
||||
|
||||
@@ -21,7 +21,6 @@ from app.database.models import (
|
||||
HeleketPayment,
|
||||
MulenPayPayment,
|
||||
Pal24Payment,
|
||||
PlategaPayment,
|
||||
PaymentMethod,
|
||||
Transaction,
|
||||
TransactionType,
|
||||
@@ -63,7 +62,6 @@ SUPPORTED_MANUAL_CHECK_METHODS: frozenset[PaymentMethod] = frozenset(
|
||||
PaymentMethod.WATA,
|
||||
PaymentMethod.HELEKET,
|
||||
PaymentMethod.CRYPTOBOT,
|
||||
PaymentMethod.PLATEGA,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -75,7 +73,6 @@ SUPPORTED_AUTO_CHECK_METHODS: frozenset[PaymentMethod] = frozenset(
|
||||
PaymentMethod.PAL24,
|
||||
PaymentMethod.WATA,
|
||||
PaymentMethod.CRYPTOBOT,
|
||||
PaymentMethod.PLATEGA,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -89,8 +86,6 @@ def method_display_name(method: PaymentMethod) -> str:
|
||||
return "YooKassa"
|
||||
if method == PaymentMethod.WATA:
|
||||
return "WATA"
|
||||
if method == PaymentMethod.PLATEGA:
|
||||
return "Platega"
|
||||
if method == PaymentMethod.CRYPTOBOT:
|
||||
return "CryptoBot"
|
||||
if method == PaymentMethod.HELEKET:
|
||||
@@ -109,8 +104,6 @@ def _method_is_enabled(method: PaymentMethod) -> bool:
|
||||
return settings.is_pal24_enabled()
|
||||
if method == PaymentMethod.WATA:
|
||||
return settings.is_wata_enabled()
|
||||
if method == PaymentMethod.PLATEGA:
|
||||
return settings.is_platega_enabled()
|
||||
if method == PaymentMethod.CRYPTOBOT:
|
||||
return settings.is_cryptobot_enabled()
|
||||
if method == PaymentMethod.HELEKET:
|
||||
@@ -322,13 +315,6 @@ def _is_wata_pending(payment: WataPayment) -> bool:
|
||||
}
|
||||
|
||||
|
||||
def _is_platega_pending(payment: PlategaPayment) -> bool:
|
||||
if payment.is_paid:
|
||||
return False
|
||||
status = (payment.status or "").lower()
|
||||
return status in {"pending", "inprogress", "in_progress"}
|
||||
|
||||
|
||||
def _is_heleket_pending(payment: HeleketPayment) -> bool:
|
||||
if payment.is_paid:
|
||||
return False
|
||||
@@ -473,33 +459,6 @@ async def _fetch_wata_payments(db: AsyncSession, cutoff: datetime) -> List[Pendi
|
||||
return records
|
||||
|
||||
|
||||
async def _fetch_platega_payments(db: AsyncSession, cutoff: datetime) -> List[PendingPayment]:
|
||||
stmt = (
|
||||
select(PlategaPayment)
|
||||
.options(selectinload(PlategaPayment.user))
|
||||
.where(PlategaPayment.created_at >= cutoff)
|
||||
.order_by(desc(PlategaPayment.created_at))
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
records: List[PendingPayment] = []
|
||||
for payment in result.scalars().all():
|
||||
if not _is_platega_pending(payment):
|
||||
continue
|
||||
identifier = payment.platega_transaction_id or payment.correlation_id or str(payment.id)
|
||||
record = _build_record(
|
||||
PaymentMethod.PLATEGA,
|
||||
payment,
|
||||
identifier=identifier,
|
||||
amount_kopeks=payment.amount_kopeks,
|
||||
status=payment.status or "",
|
||||
is_paid=bool(payment.is_paid),
|
||||
expires_at=getattr(payment, "expires_at", None),
|
||||
)
|
||||
if record:
|
||||
records.append(record)
|
||||
return records
|
||||
|
||||
|
||||
async def _fetch_heleket_payments(db: AsyncSession, cutoff: datetime) -> List[PendingPayment]:
|
||||
stmt = (
|
||||
select(HeleketPayment)
|
||||
@@ -623,7 +582,6 @@ async def list_recent_pending_payments(
|
||||
await _fetch_pal24_payments(db, cutoff),
|
||||
await _fetch_mulenpay_payments(db, cutoff),
|
||||
await _fetch_wata_payments(db, cutoff),
|
||||
await _fetch_platega_payments(db, cutoff),
|
||||
await _fetch_heleket_payments(db, cutoff),
|
||||
await _fetch_cryptobot_payments(db, cutoff),
|
||||
await _fetch_stars_transactions(db, cutoff),
|
||||
@@ -690,22 +648,6 @@ async def get_payment_record(
|
||||
expires_at=getattr(payment, "expires_at", None),
|
||||
)
|
||||
|
||||
if method == PaymentMethod.PLATEGA:
|
||||
payment = await db.get(PlategaPayment, local_payment_id)
|
||||
if not payment:
|
||||
return None
|
||||
await db.refresh(payment, attribute_names=["user"])
|
||||
identifier = payment.platega_transaction_id or payment.correlation_id or str(payment.id)
|
||||
return _build_record(
|
||||
method,
|
||||
payment,
|
||||
identifier=identifier,
|
||||
amount_kopeks=payment.amount_kopeks,
|
||||
status=payment.status or "",
|
||||
is_paid=bool(payment.is_paid),
|
||||
expires_at=getattr(payment, "expires_at", None),
|
||||
)
|
||||
|
||||
if method == PaymentMethod.HELEKET:
|
||||
payment = await db.get(HeleketPayment, local_payment_id)
|
||||
if not payment:
|
||||
@@ -790,9 +732,6 @@ async def run_manual_check(
|
||||
elif method == PaymentMethod.WATA:
|
||||
result = await payment_service.get_wata_payment_status(db, local_payment_id)
|
||||
payment = result.get("payment") if result else None
|
||||
elif method == PaymentMethod.PLATEGA:
|
||||
result = await payment_service.get_platega_payment_status(db, local_payment_id)
|
||||
payment = result.get("payment") if result else None
|
||||
elif method == PaymentMethod.HELEKET:
|
||||
payment = await payment_service.sync_heleket_payment_status(
|
||||
db, local_payment_id=local_payment_id
|
||||
|
||||
@@ -1,190 +0,0 @@
|
||||
"""HTTP-интеграция с Platega API."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
import aiohttp
|
||||
|
||||
from app.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class PlategaService:
|
||||
"""Обертка над Platega API с базовой повторной отправкой запросов."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.base_url = (settings.PLATEGA_BASE_URL or "https://app.platega.io").rstrip("/")
|
||||
self.merchant_id = settings.PLATEGA_MERCHANT_ID
|
||||
self.secret = settings.PLATEGA_SECRET
|
||||
self._timeout = aiohttp.ClientTimeout(total=30, connect=10, sock_read=25)
|
||||
self._max_retries = 3
|
||||
self._retry_delay = 0.5
|
||||
self._retryable_statuses = {500, 502, 503, 504}
|
||||
|
||||
@property
|
||||
def is_configured(self) -> bool:
|
||||
return settings.is_platega_enabled()
|
||||
|
||||
async def create_payment(
|
||||
self,
|
||||
*,
|
||||
payment_method: int,
|
||||
amount: float,
|
||||
currency: str,
|
||||
description: Optional[str] = None,
|
||||
return_url: Optional[str] = None,
|
||||
failed_url: Optional[str] = None,
|
||||
payload: Optional[str] = None,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
body: Dict[str, Any] = {
|
||||
"paymentMethod": payment_method,
|
||||
"paymentDetails": {
|
||||
"amount": round(amount, 2),
|
||||
"currency": currency,
|
||||
},
|
||||
}
|
||||
|
||||
if description:
|
||||
body["description"] = description
|
||||
if return_url:
|
||||
body["return"] = return_url
|
||||
if failed_url:
|
||||
body["failedUrl"] = failed_url
|
||||
if payload:
|
||||
body["payload"] = payload
|
||||
|
||||
return await self._request("POST", "/transaction/process", json_data=body)
|
||||
|
||||
async def get_transaction(self, transaction_id: str) -> Optional[Dict[str, Any]]:
|
||||
endpoint = f"/transaction/{transaction_id}"
|
||||
return await self._request("GET", endpoint)
|
||||
|
||||
async def _request(
|
||||
self,
|
||||
method: str,
|
||||
endpoint: str,
|
||||
*,
|
||||
json_data: Optional[Dict[str, Any]] = None,
|
||||
params: Optional[Dict[str, Any]] = None,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
if not self.is_configured:
|
||||
logger.error("Platega service is not configured")
|
||||
return None
|
||||
|
||||
url = f"{self.base_url}{endpoint}"
|
||||
headers = {
|
||||
"X-MerchantId": self.merchant_id or "",
|
||||
"X-Secret": self.secret or "",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
last_error: Optional[BaseException] = None
|
||||
|
||||
for attempt in range(1, self._max_retries + 1):
|
||||
try:
|
||||
async with aiohttp.ClientSession(timeout=self._timeout) as session:
|
||||
async with session.request(
|
||||
method,
|
||||
url,
|
||||
json=json_data,
|
||||
params=params,
|
||||
headers=headers,
|
||||
) as response:
|
||||
data, raw_text = await self._deserialize_response(response)
|
||||
|
||||
if response.status >= 400:
|
||||
logger.error(
|
||||
"Platega API error %s %s: %s",
|
||||
response.status,
|
||||
endpoint,
|
||||
raw_text,
|
||||
)
|
||||
if (
|
||||
response.status in self._retryable_statuses
|
||||
and attempt < self._max_retries
|
||||
):
|
||||
await asyncio.sleep(self._retry_delay * attempt)
|
||||
continue
|
||||
return None
|
||||
|
||||
return data
|
||||
except asyncio.CancelledError:
|
||||
logger.debug("Platega request cancelled: %s %s", method, endpoint)
|
||||
raise
|
||||
except asyncio.TimeoutError as error:
|
||||
last_error = error
|
||||
logger.warning(
|
||||
"Platega request timeout (%s %s) attempt %s/%s",
|
||||
method,
|
||||
endpoint,
|
||||
attempt,
|
||||
self._max_retries,
|
||||
)
|
||||
except aiohttp.ClientError as error:
|
||||
last_error = error
|
||||
logger.warning(
|
||||
"Platega client error (%s %s) attempt %s/%s: %s",
|
||||
method,
|
||||
endpoint,
|
||||
attempt,
|
||||
self._max_retries,
|
||||
error,
|
||||
)
|
||||
except Exception as error: # pragma: no cover - safety
|
||||
logger.exception("Unexpected Platega error: %s", error)
|
||||
return None
|
||||
|
||||
if attempt < self._max_retries:
|
||||
await asyncio.sleep(self._retry_delay * attempt)
|
||||
|
||||
if last_error is not None:
|
||||
logger.error(
|
||||
"Platega request failed after %s attempts (%s %s): %s",
|
||||
self._max_retries,
|
||||
method,
|
||||
endpoint,
|
||||
last_error,
|
||||
)
|
||||
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
async def _deserialize_response(
|
||||
response: aiohttp.ClientResponse,
|
||||
) -> tuple[Optional[Dict[str, Any]], str]:
|
||||
raw_text = await response.text()
|
||||
if not raw_text:
|
||||
return None, ""
|
||||
|
||||
content_type = response.headers.get("Content-Type", "")
|
||||
if "json" in content_type.lower() or not content_type:
|
||||
try:
|
||||
return json.loads(raw_text), raw_text
|
||||
except json.JSONDecodeError as error:
|
||||
logger.error(
|
||||
"Failed to decode Platega JSON response %s: %s",
|
||||
response.url,
|
||||
error,
|
||||
)
|
||||
return None, raw_text
|
||||
|
||||
return None, raw_text
|
||||
|
||||
@staticmethod
|
||||
def parse_expires_at(expires_in: Optional[str]) -> Optional[datetime]:
|
||||
if not expires_in:
|
||||
return None
|
||||
|
||||
try:
|
||||
hours, minutes, seconds = [int(part) for part in expires_in.split(":", 2)]
|
||||
delta = timedelta(hours=hours, minutes=minutes, seconds=seconds)
|
||||
return datetime.utcnow() + delta
|
||||
except Exception:
|
||||
logger.warning("Failed to parse Platega expiresIn value: %s", expires_in)
|
||||
return None
|
||||
@@ -68,13 +68,17 @@ async def send_poll_to_users(
|
||||
poll: Poll,
|
||||
users: Iterable[User],
|
||||
) -> dict:
|
||||
from app.database.database import AsyncSessionLocal
|
||||
|
||||
sent = 0
|
||||
failed = 0
|
||||
skipped = 0
|
||||
|
||||
poll_id = poll.id
|
||||
poll_snapshot = SimpleNamespace(
|
||||
title=poll.title,
|
||||
description=poll.description,
|
||||
reward_enabled=poll.reward_enabled,
|
||||
reward_amount_kopeks=poll.reward_amount_kopeks,
|
||||
)
|
||||
|
||||
user_snapshots = [
|
||||
SimpleNamespace(
|
||||
@@ -85,108 +89,72 @@ async def send_poll_to_users(
|
||||
for user in users
|
||||
]
|
||||
|
||||
# Получаем список пользователей, которые уже прошли опрос, за один запрос
|
||||
user_ids = [user_snapshot.id for user_snapshot in user_snapshots]
|
||||
existing_responses_result = await db.execute(
|
||||
select(PollResponse.user_id).where(
|
||||
and_(
|
||||
PollResponse.poll_id == poll_id,
|
||||
PollResponse.user_id.in_(user_ids)
|
||||
for index, user in enumerate(user_snapshots, start=1):
|
||||
existing_response = await db.execute(
|
||||
select(PollResponse.id).where(
|
||||
and_(
|
||||
PollResponse.poll_id == poll_id,
|
||||
PollResponse.user_id == user.id,
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
existing_user_ids = set(existing_responses_result.scalars().all())
|
||||
if existing_response.scalar_one_or_none():
|
||||
skipped += 1
|
||||
continue
|
||||
|
||||
# Используем умеренный семафор, чтобы не превышать лимиты подключений к БД
|
||||
semaphore = asyncio.Semaphore(30) # Баланс между производительностью и нагрузкой на БД
|
||||
response = PollResponse(
|
||||
poll_id=poll_id,
|
||||
user_id=user.id,
|
||||
)
|
||||
db.add(response)
|
||||
|
||||
# Создаем отдельную функцию для создания отдельной сессии для каждой отправки
|
||||
async def send_poll_invitation(user_snapshot):
|
||||
"""Отправляет приглашение к опросу одному пользователю"""
|
||||
async with semaphore:
|
||||
# Пропускаем пользователей, которые уже прошли опрос
|
||||
if user_snapshot.id in existing_user_ids:
|
||||
return "skipped"
|
||||
|
||||
# Создаем новую сессию для изоляции транзакции
|
||||
async with AsyncSessionLocal() as new_db:
|
||||
try:
|
||||
# Проверяем еще раз в новой сессии на случай гонки
|
||||
existing_response = await new_db.execute(
|
||||
select(PollResponse.id).where(
|
||||
and_(
|
||||
PollResponse.poll_id == poll_id,
|
||||
PollResponse.user_id == user_snapshot.id,
|
||||
)
|
||||
)
|
||||
)
|
||||
existing_id = existing_response.scalar_one_or_none()
|
||||
if existing_id:
|
||||
return "skipped"
|
||||
try:
|
||||
await db.flush()
|
||||
|
||||
response = PollResponse(
|
||||
poll_id=poll_id,
|
||||
user_id=user_snapshot.id,
|
||||
)
|
||||
new_db.add(response)
|
||||
text = _build_poll_invitation_text(poll_snapshot, user.language)
|
||||
keyboard = build_start_keyboard(response.id, user.language)
|
||||
|
||||
await new_db.flush()
|
||||
await bot.send_message(
|
||||
chat_id=user.telegram_id,
|
||||
text=text,
|
||||
reply_markup=keyboard,
|
||||
parse_mode="HTML",
|
||||
disable_web_page_preview=True,
|
||||
)
|
||||
|
||||
text = _build_poll_invitation_text(poll, user_snapshot.language)
|
||||
keyboard = build_start_keyboard(response.id, user_snapshot.language)
|
||||
await db.commit()
|
||||
sent += 1
|
||||
|
||||
await bot.send_message(
|
||||
chat_id=user_snapshot.telegram_id,
|
||||
text=text,
|
||||
reply_markup=keyboard,
|
||||
parse_mode="HTML",
|
||||
disable_web_page_preview=True,
|
||||
)
|
||||
|
||||
await new_db.commit()
|
||||
return "sent"
|
||||
except TelegramBadRequest as error:
|
||||
error_text = str(error).lower()
|
||||
if "chat not found" in error_text or "bot was blocked by the user" in error_text:
|
||||
await new_db.rollback()
|
||||
return "skipped"
|
||||
else: # pragma: no cover - unexpected telegram error
|
||||
await new_db.rollback()
|
||||
return "failed"
|
||||
except Exception as error: # pragma: no cover - defensive logging
|
||||
await new_db.rollback()
|
||||
# Проверяем, является ли ошибка связанной с лимитом подключений
|
||||
if "too many clients" in str(error).lower():
|
||||
logger.warning(
|
||||
"⚠️ Ограничение на количество подключений к БД: %s пользователю %s",
|
||||
poll_id,
|
||||
user_snapshot.telegram_id,
|
||||
)
|
||||
# Уменьшаем вероятность переполнения, делая небольшую задержку
|
||||
await asyncio.sleep(0.1)
|
||||
else:
|
||||
logger.error(
|
||||
"❌ Ошибка отправки опроса %s пользователю %s: %s",
|
||||
poll_id,
|
||||
user_snapshot.telegram_id,
|
||||
error,
|
||||
)
|
||||
return "failed"
|
||||
|
||||
# Отправляем все приглашения одновременно без задержек для максимальной скорости
|
||||
tasks = [send_poll_invitation(user_snapshot) for user_snapshot in user_snapshots]
|
||||
results = await asyncio.gather(*tasks, return_exceptions=True)
|
||||
|
||||
for result in results:
|
||||
if isinstance(result, str): # Успешно выполненная задача
|
||||
if result == "sent":
|
||||
sent += 1
|
||||
elif result == "failed":
|
||||
failed += 1
|
||||
elif result == "skipped":
|
||||
if index % 20 == 0:
|
||||
await asyncio.sleep(1)
|
||||
except TelegramBadRequest as error:
|
||||
error_text = str(error).lower()
|
||||
if "chat not found" in error_text or "bot was blocked by the user" in error_text:
|
||||
skipped += 1
|
||||
elif isinstance(result, Exception): # Ошибка выполнения задачи
|
||||
logger.info(
|
||||
"ℹ️ Пропуск пользователя %s при отправке опроса %s: %s",
|
||||
user.telegram_id,
|
||||
poll_id,
|
||||
error,
|
||||
)
|
||||
else: # pragma: no cover - unexpected telegram error
|
||||
failed += 1
|
||||
logger.error(
|
||||
"❌ Ошибка отправки опроса %s пользователю %s: %s",
|
||||
poll_id,
|
||||
user.telegram_id,
|
||||
error,
|
||||
)
|
||||
await db.rollback()
|
||||
except Exception as error: # pragma: no cover - defensive logging
|
||||
failed += 1
|
||||
logger.error(
|
||||
"❌ Ошибка отправки опроса %s пользователю %s: %s",
|
||||
poll_id,
|
||||
user.telegram_id,
|
||||
error,
|
||||
)
|
||||
await db.rollback()
|
||||
|
||||
return {
|
||||
"sent": sent,
|
||||
|
||||
@@ -92,19 +92,18 @@ async def maybe_assign_promo_group_by_total_spent(
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
) -> Optional[PromoGroup]:
|
||||
from app.database.crud.user_promo_group import (
|
||||
add_user_to_promo_group,
|
||||
has_user_promo_group,
|
||||
sync_user_primary_promo_group,
|
||||
)
|
||||
|
||||
user = await db.get(User, user_id)
|
||||
if not user:
|
||||
logger.debug("Не удалось найти пользователя %s для автовыдачи промогруппы", user_id)
|
||||
return None
|
||||
|
||||
# Получаем текущую primary промогруппу
|
||||
old_group = user.get_primary_promo_group()
|
||||
old_group = None
|
||||
if user.promo_group_id:
|
||||
try:
|
||||
await db.refresh(user, attribute_names=["promo_group"])
|
||||
except Exception:
|
||||
pass
|
||||
old_group = getattr(user, "promo_group", None)
|
||||
|
||||
total_spent = await get_user_total_spent_kopeks(db, user_id)
|
||||
if total_spent <= 0:
|
||||
@@ -121,6 +120,7 @@ async def maybe_assign_promo_group_by_total_spent(
|
||||
return None
|
||||
|
||||
try:
|
||||
previous_group_id = user.promo_group_id
|
||||
target_threshold = target_group.auto_assign_total_spent_kopeks or 0
|
||||
|
||||
if target_threshold <= previous_threshold:
|
||||
@@ -133,16 +133,12 @@ async def maybe_assign_promo_group_by_total_spent(
|
||||
)
|
||||
return None
|
||||
|
||||
# Проверяем, есть ли уже эта группа у пользователя
|
||||
already_has_group = await has_user_promo_group(db, user_id, target_group.id)
|
||||
|
||||
if user.auto_promo_group_assigned and already_has_group:
|
||||
if user.auto_promo_group_assigned and target_group.id == previous_group_id:
|
||||
logger.debug(
|
||||
"Пользователь %s уже имеет промогруппу '%s', повторная выдача не требуется",
|
||||
"Пользователь %s уже находится в актуальной промогруппе '%s', повторная выдача не требуется",
|
||||
user.telegram_id,
|
||||
target_group.name,
|
||||
)
|
||||
await sync_user_primary_promo_group(db, user_id)
|
||||
if target_threshold > previous_threshold:
|
||||
user.auto_promo_group_threshold_kopeks = target_threshold
|
||||
user.updated_at = datetime.utcnow()
|
||||
@@ -154,18 +150,18 @@ async def maybe_assign_promo_group_by_total_spent(
|
||||
user.auto_promo_group_threshold_kopeks = target_threshold
|
||||
user.updated_at = datetime.utcnow()
|
||||
|
||||
if not already_has_group:
|
||||
# Добавляем новую промогруппу к существующим
|
||||
await add_user_to_promo_group(db, user_id, target_group.id, assigned_by="auto")
|
||||
if target_group.id != previous_group_id:
|
||||
user.promo_group_id = target_group.id
|
||||
user.promo_group = target_group
|
||||
logger.info(
|
||||
"🤖 Пользователю %s добавлена промогруппа '%s' за траты %s ₽",
|
||||
"🤖 Пользователь %s автоматически переведен в промогруппу '%s' за траты %s ₽",
|
||||
user.telegram_id,
|
||||
target_group.name,
|
||||
total_spent / 100,
|
||||
)
|
||||
else:
|
||||
logger.info(
|
||||
"🤖 Пользователь %s уже имеет промогруппу '%s', отмечаем автоприсвоение",
|
||||
"🤖 Пользователь %s уже находится в подходящей промогруппе '%s', отмечаем автоприсвоение",
|
||||
user.telegram_id,
|
||||
target_group.name,
|
||||
)
|
||||
@@ -173,7 +169,7 @@ async def maybe_assign_promo_group_by_total_spent(
|
||||
await db.commit()
|
||||
await db.refresh(user)
|
||||
|
||||
if not already_has_group:
|
||||
if target_group.id != previous_group_id:
|
||||
await _notify_admins_about_auto_assignment(
|
||||
db,
|
||||
user,
|
||||
|
||||
@@ -9,10 +9,6 @@ from app.database.crud.promocode import (
|
||||
)
|
||||
from app.database.crud.user import add_user_balance, get_user_by_id
|
||||
from app.database.crud.subscription import extend_subscription, get_subscription_by_user_id
|
||||
from app.database.crud.user_promo_group import (
|
||||
has_user_promo_group, add_user_to_promo_group
|
||||
)
|
||||
from app.database.crud.promo_group import get_promo_group_by_id
|
||||
from app.database.models import PromoCodeType, SubscriptionStatus, User, PromoCode
|
||||
from app.services.remnawave_service import RemnaWaveService
|
||||
from app.services.subscription_service import SubscriptionService
|
||||
@@ -60,47 +56,6 @@ class PromoCodeService:
|
||||
|
||||
logger.info(f"🎯 Пользователь {user.telegram_id} получил платную подписку через промокод {code}")
|
||||
|
||||
# Assign promo group if promocode has one
|
||||
if promocode.promo_group_id:
|
||||
try:
|
||||
# Check if user already has this promo group
|
||||
has_group = await has_user_promo_group(db, user_id, promocode.promo_group_id)
|
||||
|
||||
if not has_group:
|
||||
# Get promo group details
|
||||
promo_group = await get_promo_group_by_id(db, promocode.promo_group_id)
|
||||
|
||||
if promo_group:
|
||||
# Add promo group to user
|
||||
await add_user_to_promo_group(
|
||||
db,
|
||||
user_id,
|
||||
promocode.promo_group_id,
|
||||
assigned_by="promocode"
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"🎯 Пользователю {user.telegram_id} назначена промогруппа '{promo_group.name}' "
|
||||
f"(приоритет: {promo_group.priority}) через промокод {code}"
|
||||
)
|
||||
|
||||
# Add to result description
|
||||
result_description += f"\n🎁 Назначена промогруппа: {promo_group.name}"
|
||||
else:
|
||||
logger.warning(
|
||||
f"⚠️ Промогруппа ID {promocode.promo_group_id} не найдена для промокода {code}"
|
||||
)
|
||||
else:
|
||||
logger.info(
|
||||
f"ℹ️ Пользователь {user.telegram_id} уже имеет промогруппу ID {promocode.promo_group_id}"
|
||||
)
|
||||
except Exception as pg_error:
|
||||
logger.error(
|
||||
f"❌ Ошибка назначения промогруппы для пользователя {user.telegram_id} "
|
||||
f"при активации промокода {code}: {pg_error}"
|
||||
)
|
||||
# Don't fail the whole promocode activation if promo group assignment fails
|
||||
|
||||
await create_promocode_use(db, promocode.id, user_id)
|
||||
|
||||
promocode.current_uses += 1
|
||||
@@ -116,7 +71,6 @@ class PromoCodeService:
|
||||
"max_uses": promocode.max_uses,
|
||||
"current_uses": promocode.current_uses,
|
||||
"valid_until": promocode.valid_until,
|
||||
"promo_group_id": promocode.promo_group_id,
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -162,7 +116,10 @@ class PromoCodeService:
|
||||
try:
|
||||
from app.database.crud.server_squad import get_random_trial_squad_uuid
|
||||
|
||||
trial_uuid = await get_random_trial_squad_uuid(db)
|
||||
trial_uuid = await get_random_trial_squad_uuid(
|
||||
db,
|
||||
settings.TRIAL_SQUAD_UUID,
|
||||
)
|
||||
if trial_uuid:
|
||||
trial_squads = [trial_uuid]
|
||||
except Exception as error:
|
||||
@@ -171,21 +128,15 @@ class PromoCodeService:
|
||||
promocode.code,
|
||||
error,
|
||||
)
|
||||
if getattr(settings, 'TRIAL_SQUAD_UUID', None):
|
||||
trial_squads = [settings.TRIAL_SQUAD_UUID]
|
||||
|
||||
forced_devices = None
|
||||
if not settings.is_devices_selection_enabled():
|
||||
forced_devices = settings.get_disabled_mode_device_limit()
|
||||
|
||||
device_limit = settings.DEFAULT_DEVICE_LIMIT
|
||||
if forced_devices is not None:
|
||||
device_limit = forced_devices
|
||||
|
||||
new_subscription = await create_paid_subscription(
|
||||
db=db,
|
||||
user_id=user.id,
|
||||
duration_days=promocode.subscription_days,
|
||||
traffic_limit_gb=0,
|
||||
device_limit=device_limit,
|
||||
device_limit=1,
|
||||
connected_squads=trial_squads,
|
||||
update_server_counters=True,
|
||||
)
|
||||
@@ -204,15 +155,10 @@ class PromoCodeService:
|
||||
if not subscription:
|
||||
trial_days = promocode.subscription_days if promocode.subscription_days > 0 else settings.TRIAL_DURATION_DAYS
|
||||
|
||||
forced_devices = None
|
||||
if not settings.is_devices_selection_enabled():
|
||||
forced_devices = settings.get_disabled_mode_device_limit()
|
||||
|
||||
trial_subscription = await create_trial_subscription(
|
||||
db,
|
||||
user.id,
|
||||
duration_days=trial_days,
|
||||
device_limit=forced_devices,
|
||||
duration_days=trial_days
|
||||
)
|
||||
|
||||
await self.subscription_service.create_remnawave_user(db, trial_subscription)
|
||||
|
||||
@@ -89,64 +89,16 @@ async def process_referral_topup(
|
||||
logger.info(f"Пользователь {user_id} не является рефералом")
|
||||
return True
|
||||
|
||||
if topup_amount_kopeks < settings.REFERRAL_MINIMUM_TOPUP_KOPEKS:
|
||||
logger.info(f"Пополнение {user_id} на {topup_amount_kopeks/100}₽ меньше минимума")
|
||||
return True
|
||||
|
||||
referrer = await get_user_by_id(db, user.referred_by_id)
|
||||
if not referrer:
|
||||
logger.error(f"Реферер {user.referred_by_id} не найден")
|
||||
return False
|
||||
|
||||
qualifies_for_first_bonus = (
|
||||
topup_amount_kopeks >= settings.REFERRAL_MINIMUM_TOPUP_KOPEKS
|
||||
)
|
||||
commission_amount = 0
|
||||
if settings.REFERRAL_COMMISSION_PERCENT > 0:
|
||||
commission_amount = int(
|
||||
topup_amount_kopeks * settings.REFERRAL_COMMISSION_PERCENT / 100
|
||||
)
|
||||
|
||||
|
||||
if not user.has_made_first_topup:
|
||||
if not qualifies_for_first_bonus:
|
||||
logger.info(
|
||||
"Пополнение %s на %s₽ меньше минимума для первого бонуса, но комиссия будет начислена",
|
||||
user_id,
|
||||
topup_amount_kopeks / 100,
|
||||
)
|
||||
|
||||
if commission_amount > 0:
|
||||
await add_user_balance(
|
||||
db,
|
||||
referrer,
|
||||
commission_amount,
|
||||
f"Комиссия {settings.REFERRAL_COMMISSION_PERCENT}% с пополнения {user.full_name}",
|
||||
bot=bot,
|
||||
)
|
||||
|
||||
await create_referral_earning(
|
||||
db=db,
|
||||
user_id=referrer.id,
|
||||
referral_id=user.id,
|
||||
amount_kopeks=commission_amount,
|
||||
reason="referral_commission_topup",
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"💰 Комиссия с пополнения: %s получил %s₽ (до первого бонуса)",
|
||||
referrer.telegram_id,
|
||||
commission_amount / 100,
|
||||
)
|
||||
|
||||
if bot:
|
||||
commission_notification = (
|
||||
f"💰 <b>Реферальная комиссия!</b>\n\n"
|
||||
f"Ваш реферал <b>{user.full_name}</b> пополнил баланс на "
|
||||
f"{settings.format_price(topup_amount_kopeks)}\n\n"
|
||||
f"🎁 Ваша комиссия ({settings.REFERRAL_COMMISSION_PERCENT}%): "
|
||||
f"{settings.format_price(commission_amount)}\n\n"
|
||||
f"💎 Средства зачислены на ваш баланс."
|
||||
)
|
||||
await send_referral_notification(bot, referrer.telegram_id, commission_notification)
|
||||
|
||||
return True
|
||||
|
||||
user.has_made_first_topup = True
|
||||
await db.commit()
|
||||
|
||||
@@ -209,33 +161,36 @@ async def process_referral_topup(
|
||||
await send_referral_notification(bot, referrer.telegram_id, inviter_bonus_notification)
|
||||
|
||||
else:
|
||||
if commission_amount > 0:
|
||||
await add_user_balance(
|
||||
db, referrer, commission_amount,
|
||||
f"Комиссия {settings.REFERRAL_COMMISSION_PERCENT}% с пополнения {user.full_name}",
|
||||
bot=bot
|
||||
)
|
||||
|
||||
await create_referral_earning(
|
||||
db=db,
|
||||
user_id=referrer.id,
|
||||
referral_id=user.id,
|
||||
amount_kopeks=commission_amount,
|
||||
reason="referral_commission_topup"
|
||||
)
|
||||
|
||||
logger.info(f"💰 Комиссия с пополнения: {referrer.telegram_id} получил {commission_amount/100}₽")
|
||||
|
||||
if bot:
|
||||
commission_notification = (
|
||||
f"💰 <b>Реферальная комиссия!</b>\n\n"
|
||||
f"Ваш реферал <b>{user.full_name}</b> пополнил баланс на "
|
||||
f"{settings.format_price(topup_amount_kopeks)}\n\n"
|
||||
f"🎁 Ваша комиссия ({settings.REFERRAL_COMMISSION_PERCENT}%): "
|
||||
f"{settings.format_price(commission_amount)}\n\n"
|
||||
f"💎 Средства зачислены на ваш баланс."
|
||||
if settings.REFERRAL_COMMISSION_PERCENT > 0:
|
||||
commission_amount = int(topup_amount_kopeks * settings.REFERRAL_COMMISSION_PERCENT / 100)
|
||||
|
||||
if commission_amount > 0:
|
||||
await add_user_balance(
|
||||
db, referrer, commission_amount,
|
||||
f"Комиссия {settings.REFERRAL_COMMISSION_PERCENT}% с пополнения {user.full_name}",
|
||||
bot=bot
|
||||
)
|
||||
await send_referral_notification(bot, referrer.telegram_id, commission_notification)
|
||||
|
||||
await create_referral_earning(
|
||||
db=db,
|
||||
user_id=referrer.id,
|
||||
referral_id=user.id,
|
||||
amount_kopeks=commission_amount,
|
||||
reason="referral_commission_topup"
|
||||
)
|
||||
|
||||
logger.info(f"💰 Комиссия с пополнения: {referrer.telegram_id} получил {commission_amount/100}₽")
|
||||
|
||||
if bot:
|
||||
commission_notification = (
|
||||
f"💰 <b>Реферальная комиссия!</b>\n\n"
|
||||
f"Ваш реферал <b>{user.full_name}</b> пополнил баланс на "
|
||||
f"{settings.format_price(topup_amount_kopeks)}\n\n"
|
||||
f"🎁 Ваша комиссия ({settings.REFERRAL_COMMISSION_PERCENT}%): "
|
||||
f"{settings.format_price(commission_amount)}\n\n"
|
||||
f"💎 Средства зачислены на ваш баланс."
|
||||
)
|
||||
await send_referral_notification(bot, referrer.telegram_id, commission_notification)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
+158
-750
File diff suppressed because it is too large
Load Diff
@@ -29,7 +29,6 @@ from app.services.subscription_purchase_service import (
|
||||
from app.services.subscription_service import SubscriptionService
|
||||
from app.services.user_cart_service import user_cart_service
|
||||
from app.utils.pricing_utils import format_period_description
|
||||
from app.utils.timezone import format_local_datetime
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -196,16 +195,11 @@ async def _prepare_auto_extend_context(
|
||||
|
||||
|
||||
def _apply_extension_updates(context: AutoExtendContext) -> None:
|
||||
"""
|
||||
Применяет обновления лимитов подписки (трафик, устройства, серверы).
|
||||
НЕ изменяет is_trial - это делается позже после успешного коммита продления.
|
||||
"""
|
||||
subscription = context.subscription
|
||||
|
||||
# Обновляем лимиты для триальной подписки
|
||||
if subscription.is_trial:
|
||||
# НЕ удаляем триал здесь! Это будет сделано после успешного extend_subscription()
|
||||
# subscription.is_trial = False # УДАЛЕНО: преждевременное удаление триала
|
||||
subscription.is_trial = False
|
||||
subscription.status = "active"
|
||||
if context.traffic_limit_gb is not None:
|
||||
subscription.traffic_limit_gb = context.traffic_limit_gb
|
||||
if context.device_limit is not None:
|
||||
@@ -213,7 +207,6 @@ def _apply_extension_updates(context: AutoExtendContext) -> None:
|
||||
if context.squad_uuid and context.squad_uuid not in (subscription.connected_squads or []):
|
||||
subscription.connected_squads = (subscription.connected_squads or []) + [context.squad_uuid]
|
||||
else:
|
||||
# Обновляем лимиты для платной подписки
|
||||
if context.traffic_limit_gb not in (None, 0):
|
||||
subscription.traffic_limit_gb = context.traffic_limit_gb
|
||||
if (
|
||||
@@ -281,7 +274,6 @@ async def _auto_extend_subscription(
|
||||
|
||||
subscription = prepared.subscription
|
||||
old_end_date = subscription.end_date
|
||||
was_trial = subscription.is_trial # Запоминаем, была ли подписка триальной
|
||||
|
||||
_apply_extension_updates(prepared)
|
||||
|
||||
@@ -291,19 +283,6 @@ async def _auto_extend_subscription(
|
||||
subscription,
|
||||
prepared.period_days,
|
||||
)
|
||||
|
||||
# НОВОЕ: Конвертируем триал в платную подписку ТОЛЬКО после успешного продления
|
||||
if was_trial and subscription.is_trial:
|
||||
subscription.is_trial = False
|
||||
subscription.status = "active"
|
||||
user.has_had_paid_subscription = True
|
||||
await db.commit()
|
||||
logger.info(
|
||||
"✅ Триал конвертирован в платную подписку %s для пользователя %s",
|
||||
subscription.id,
|
||||
user.telegram_id,
|
||||
)
|
||||
|
||||
except Exception as error: # pragma: no cover - defensive logging
|
||||
logger.error(
|
||||
"❌ Автопокупка: не удалось продлить подписку пользователя %s: %s",
|
||||
@@ -311,8 +290,6 @@ async def _auto_extend_subscription(
|
||||
error,
|
||||
exc_info=True,
|
||||
)
|
||||
# НОВОЕ: Откатываем изменения при ошибке
|
||||
await db.rollback()
|
||||
return False
|
||||
|
||||
transaction = None
|
||||
@@ -356,7 +333,7 @@ async def _auto_extend_subscription(
|
||||
getattr(user, "language", "ru"),
|
||||
)
|
||||
new_end_date = updated_subscription.end_date
|
||||
end_date_label = format_local_datetime(new_end_date, "%d.%m.%Y %H:%M")
|
||||
end_date_label = new_end_date.strftime("%d.%m.%Y %H:%M")
|
||||
|
||||
if bot:
|
||||
try:
|
||||
|
||||
@@ -17,9 +17,6 @@ from app.utils.pricing_utils import (
|
||||
calculate_prorated_price,
|
||||
validate_pricing_calculation
|
||||
)
|
||||
from app.utils.subscription_utils import (
|
||||
resolve_hwid_device_limit_for_payload,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -50,7 +47,7 @@ def _resolve_addon_discount_percent(
|
||||
*,
|
||||
period_days: Optional[int] = None,
|
||||
) -> int:
|
||||
group = promo_group or (user.get_primary_promo_group() if user else None)
|
||||
group = promo_group or (getattr(user, "promo_group", None) if user else None)
|
||||
|
||||
if group is not None and not getattr(group, "apply_discounts_to_addons", True):
|
||||
return 0
|
||||
@@ -175,7 +172,6 @@ class SubscriptionService:
|
||||
return None
|
||||
|
||||
async with self.get_api_client() as api:
|
||||
hwid_limit = resolve_hwid_device_limit_for_payload(subscription)
|
||||
existing_users = await api.get_user_by_telegram_id(user.telegram_id)
|
||||
if existing_users:
|
||||
logger.info(f"🔄 Найден существующий пользователь в панели для {user.telegram_id}")
|
||||
@@ -187,24 +183,20 @@ class SubscriptionService:
|
||||
except Exception as hwid_error:
|
||||
logger.warning(f"⚠️ Не удалось сбросить HWID: {hwid_error}")
|
||||
|
||||
update_kwargs = dict(
|
||||
updated_user = await api.update_user(
|
||||
uuid=remnawave_user.uuid,
|
||||
status=UserStatus.ACTIVE,
|
||||
expire_at=subscription.end_date,
|
||||
traffic_limit_bytes=self._gb_to_bytes(subscription.traffic_limit_gb),
|
||||
traffic_limit_strategy=get_traffic_reset_strategy(),
|
||||
hwid_device_limit=subscription.device_limit,
|
||||
description=settings.format_remnawave_user_description(
|
||||
full_name=user.full_name,
|
||||
username=user.username,
|
||||
telegram_id=user.telegram_id
|
||||
),
|
||||
active_internal_squads=subscription.connected_squads,
|
||||
active_internal_squads=subscription.connected_squads
|
||||
)
|
||||
|
||||
if hwid_limit is not None:
|
||||
update_kwargs['hwid_device_limit'] = hwid_limit
|
||||
|
||||
updated_user = await api.update_user(**update_kwargs)
|
||||
|
||||
if reset_traffic:
|
||||
await self._reset_user_traffic(
|
||||
@@ -216,31 +208,23 @@ class SubscriptionService:
|
||||
|
||||
else:
|
||||
logger.info(f"🆕 Создаем нового пользователя в панели для {user.telegram_id}")
|
||||
username = settings.format_remnawave_username(
|
||||
full_name=user.full_name,
|
||||
username=user.username,
|
||||
telegram_id=user.telegram_id,
|
||||
)
|
||||
create_kwargs = dict(
|
||||
username = f"user_{user.telegram_id}"
|
||||
updated_user = await api.create_user(
|
||||
username=username,
|
||||
expire_at=subscription.end_date,
|
||||
status=UserStatus.ACTIVE,
|
||||
traffic_limit_bytes=self._gb_to_bytes(subscription.traffic_limit_gb),
|
||||
traffic_limit_strategy=get_traffic_reset_strategy(),
|
||||
telegram_id=user.telegram_id,
|
||||
hwid_device_limit=subscription.device_limit,
|
||||
description=settings.format_remnawave_user_description(
|
||||
full_name=user.full_name,
|
||||
username=user.username,
|
||||
telegram_id=user.telegram_id
|
||||
),
|
||||
active_internal_squads=subscription.connected_squads,
|
||||
active_internal_squads=subscription.connected_squads
|
||||
)
|
||||
|
||||
if hwid_limit is not None:
|
||||
create_kwargs['hwid_device_limit'] = hwid_limit
|
||||
|
||||
updated_user = await api.create_user(**create_kwargs)
|
||||
|
||||
if reset_traffic:
|
||||
await self._reset_user_traffic(
|
||||
api,
|
||||
@@ -298,26 +282,20 @@ class SubscriptionService:
|
||||
logger.info(f"🔔 Статус подписки {subscription.id} автоматически изменен на 'expired'")
|
||||
|
||||
async with self.get_api_client() as api:
|
||||
hwid_limit = resolve_hwid_device_limit_for_payload(subscription)
|
||||
|
||||
update_kwargs = dict(
|
||||
updated_user = await api.update_user(
|
||||
uuid=user.remnawave_uuid,
|
||||
status=UserStatus.ACTIVE if is_actually_active else UserStatus.EXPIRED,
|
||||
expire_at=subscription.end_date,
|
||||
traffic_limit_bytes=self._gb_to_bytes(subscription.traffic_limit_gb),
|
||||
traffic_limit_strategy=get_traffic_reset_strategy(),
|
||||
hwid_device_limit=subscription.device_limit,
|
||||
description=settings.format_remnawave_user_description(
|
||||
full_name=user.full_name,
|
||||
username=user.username,
|
||||
telegram_id=user.telegram_id
|
||||
),
|
||||
active_internal_squads=subscription.connected_squads,
|
||||
active_internal_squads=subscription.connected_squads
|
||||
)
|
||||
|
||||
if hwid_limit is not None:
|
||||
update_kwargs['hwid_device_limit'] = hwid_limit
|
||||
|
||||
updated_user = await api.update_user(**update_kwargs)
|
||||
|
||||
if reset_traffic:
|
||||
await self._reset_user_traffic(
|
||||
@@ -470,7 +448,7 @@ class SubscriptionService:
|
||||
base_discount_total = base_price_original * period_discount_percent // 100
|
||||
base_price = base_price_original - base_discount_total
|
||||
|
||||
promo_group = promo_group or (user.get_primary_promo_group() if user else None)
|
||||
promo_group = promo_group or (user.promo_group if user else None)
|
||||
|
||||
traffic_price = settings.get_traffic_price(traffic_gb)
|
||||
traffic_discount_percent = _resolve_discount_percent(
|
||||
@@ -570,7 +548,7 @@ class SubscriptionService:
|
||||
|
||||
if user is None:
|
||||
user = getattr(subscription, "user", None)
|
||||
promo_group = promo_group or (user.get_primary_promo_group() if user else None)
|
||||
promo_group = promo_group or (user.promo_group if user else None)
|
||||
|
||||
servers_price, _ = await self.get_countries_price_by_uuids(
|
||||
subscription.connected_squads,
|
||||
@@ -587,18 +565,7 @@ class SubscriptionService:
|
||||
servers_discount = servers_price * servers_discount_percent // 100
|
||||
discounted_servers_price = servers_price - servers_discount
|
||||
|
||||
device_limit = subscription.device_limit
|
||||
if device_limit is None:
|
||||
if settings.is_devices_selection_enabled():
|
||||
device_limit = settings.DEFAULT_DEVICE_LIMIT
|
||||
else:
|
||||
forced_limit = settings.get_disabled_mode_device_limit()
|
||||
if forced_limit is None:
|
||||
device_limit = settings.DEFAULT_DEVICE_LIMIT
|
||||
else:
|
||||
device_limit = forced_limit
|
||||
|
||||
devices_price = max(0, (device_limit or 0) - settings.DEFAULT_DEVICE_LIMIT) * settings.PRICE_PER_DEVICE
|
||||
devices_price = max(0, subscription.device_limit - settings.DEFAULT_DEVICE_LIMIT) * settings.PRICE_PER_DEVICE
|
||||
devices_discount_percent = _resolve_discount_percent(
|
||||
user,
|
||||
promo_group,
|
||||
@@ -650,7 +617,7 @@ class SubscriptionService:
|
||||
)
|
||||
logger.info(message)
|
||||
if devices_price > 0:
|
||||
message = f" 📱 Устройства ({device_limit}): {discounted_devices_price/100}₽"
|
||||
message = f" 📱 Устройства ({subscription.device_limit}): {discounted_devices_price/100}₽"
|
||||
if devices_discount > 0:
|
||||
message += (
|
||||
f" (скидка {devices_discount_percent}%: -{devices_discount/100}₽ от {devices_price/100}₽)"
|
||||
@@ -798,7 +765,7 @@ class SubscriptionService:
|
||||
base_discount_total = base_price_original * period_discount_percent // 100
|
||||
base_price = base_price_original - base_discount_total
|
||||
|
||||
promo_group = promo_group or (user.get_primary_promo_group() if user else None)
|
||||
promo_group = promo_group or (user.promo_group if user else None)
|
||||
|
||||
traffic_price_per_month = settings.get_traffic_price(traffic_gb)
|
||||
traffic_discount_percent = _resolve_discount_percent(
|
||||
@@ -910,7 +877,7 @@ class SubscriptionService:
|
||||
|
||||
if user is None:
|
||||
user = getattr(subscription, "user", None)
|
||||
promo_group = promo_group or (user.get_primary_promo_group() if user else None)
|
||||
promo_group = promo_group or (user.promo_group if user else None)
|
||||
|
||||
servers_price_per_month, _ = await self.get_countries_price_by_uuids(
|
||||
subscription.connected_squads,
|
||||
@@ -927,18 +894,7 @@ class SubscriptionService:
|
||||
discounted_servers_per_month = servers_price_per_month - servers_discount_per_month
|
||||
total_servers_price = discounted_servers_per_month * months_in_period
|
||||
|
||||
device_limit = subscription.device_limit
|
||||
if device_limit is None:
|
||||
if settings.is_devices_selection_enabled():
|
||||
device_limit = settings.DEFAULT_DEVICE_LIMIT
|
||||
else:
|
||||
forced_limit = settings.get_disabled_mode_device_limit()
|
||||
if forced_limit is None:
|
||||
device_limit = settings.DEFAULT_DEVICE_LIMIT
|
||||
else:
|
||||
device_limit = forced_limit
|
||||
|
||||
additional_devices = max(0, (device_limit or 0) - settings.DEFAULT_DEVICE_LIMIT)
|
||||
additional_devices = max(0, subscription.device_limit - settings.DEFAULT_DEVICE_LIMIT)
|
||||
devices_price_per_month = additional_devices * settings.PRICE_PER_DEVICE
|
||||
devices_discount_percent = _resolve_discount_percent(
|
||||
user,
|
||||
|
||||
@@ -9,13 +9,7 @@ from app.database.universal_migration import ensure_default_web_api_token
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import (
|
||||
Settings,
|
||||
settings,
|
||||
refresh_period_prices,
|
||||
refresh_traffic_prices,
|
||||
ENV_OVERRIDE_KEYS,
|
||||
)
|
||||
from app.config import Settings, settings, refresh_period_prices, refresh_traffic_prices
|
||||
from app.database.crud.system_setting import (
|
||||
delete_system_setting,
|
||||
upsert_system_setting,
|
||||
@@ -77,14 +71,12 @@ class BotConfigurationService:
|
||||
"SUPPORT": "💬 Поддержка и тикеты",
|
||||
"LOCALIZATION": "🌍 Языки интерфейса",
|
||||
"CHANNEL": "📣 Обязательная подписка",
|
||||
"TIMEZONE": "🗂 Timezone",
|
||||
"PAYMENT": "💳 Общие платежные настройки",
|
||||
"PAYMENT_VERIFICATION": "🕵️ Проверка платежей",
|
||||
"TELEGRAM": "⭐ Telegram Stars",
|
||||
"CRYPTOBOT": "🪙 CryptoBot",
|
||||
"HELEKET": "🪙 Heleket",
|
||||
"YOOKASSA": "🟣 YooKassa",
|
||||
"PLATEGA": "💳 Platega",
|
||||
"TRIBUTE": "🎁 Tribute",
|
||||
"MULENPAY": "💰 {mulenpay_name}",
|
||||
"PAL24": "🏦 PAL24 / PayPalych",
|
||||
@@ -132,13 +124,11 @@ class BotConfigurationService:
|
||||
"SUPPORT": "Контакты поддержки, SLA и режимы обработки обращений.",
|
||||
"LOCALIZATION": "Доступные языки, локализация интерфейса и выбор языка.",
|
||||
"CHANNEL": "Настройки обязательной подписки на канал или группу.",
|
||||
"TIMEZONE": "Часовой пояс панели и отображение времени.",
|
||||
"PAYMENT": "Общие тексты платежей, описания чеков и шаблоны.",
|
||||
"PAYMENT_VERIFICATION": "Автоматическая проверка пополнений и интервал выполнения.",
|
||||
"YOOKASSA": "Интеграция с YooKassa: идентификаторы магазина и вебхуки.",
|
||||
"CRYPTOBOT": "CryptoBot и криптоплатежи через Telegram.",
|
||||
"HELEKET": "Heleket: криптоплатежи, ключи мерчанта и вебхуки.",
|
||||
"PLATEGA": "Platega: merchant ID, секрет, ссылки возврата и методы оплаты.",
|
||||
"MULENPAY": "Платежи {mulenpay_name} и параметры магазина.",
|
||||
"PAL24": "PAL24 / PayPalych подключения и лимиты.",
|
||||
"TRIBUTE": "Tribute и донат-сервисы.",
|
||||
@@ -205,8 +195,6 @@ class BotConfigurationService:
|
||||
"DEFAULT_TRAFFIC_LIMIT_GB": "SUBSCRIPTIONS_CORE",
|
||||
"MAX_DEVICES_LIMIT": "SUBSCRIPTIONS_CORE",
|
||||
"PRICE_PER_DEVICE": "SUBSCRIPTIONS_CORE",
|
||||
"DEVICES_SELECTION_ENABLED": "SUBSCRIPTIONS_CORE",
|
||||
"DEVICES_SELECTION_DISABLED_AMOUNT": "SUBSCRIPTIONS_CORE",
|
||||
"BASE_SUBSCRIPTION_PRICE": "SUBSCRIPTIONS_CORE",
|
||||
"DEFAULT_TRAFFIC_RESET_STRATEGY": "TRAFFIC",
|
||||
"RESET_TRAFFIC_ON_PAYMENT": "TRAFFIC",
|
||||
@@ -223,6 +211,7 @@ class BotConfigurationService:
|
||||
"TRAFFIC_PACKAGES_CONFIG": "TRAFFIC_PACKAGES",
|
||||
"BASE_PROMO_GROUP_PERIOD_DISCOUNTS_ENABLED": "SUBSCRIPTIONS_CORE",
|
||||
"BASE_PROMO_GROUP_PERIOD_DISCOUNTS": "SUBSCRIPTIONS_CORE",
|
||||
"REFERRED_USER_REWARD": "REFERRAL",
|
||||
"DEFAULT_AUTOPAY_ENABLED": "AUTOPAY",
|
||||
"DEFAULT_AUTOPAY_DAYS_BEFORE": "AUTOPAY",
|
||||
"MIN_BALANCE_FOR_AUTOPAY_KOPEKS": "AUTOPAY",
|
||||
@@ -272,7 +261,6 @@ class BotConfigurationService:
|
||||
"MAINTENANCE_MESSAGE": "MAINTENANCE",
|
||||
"MAINTENANCE_CHECK_INTERVAL": "MAINTENANCE",
|
||||
"MAINTENANCE_AUTO_ENABLE": "MAINTENANCE",
|
||||
"MAINTENANCE_RETRY_ATTEMPTS": "MAINTENANCE",
|
||||
"WEBHOOK_URL": "WEBHOOK",
|
||||
"WEBHOOK_SECRET": "WEBHOOK",
|
||||
"VERSION_CHECK_ENABLED": "VERSION",
|
||||
@@ -280,7 +268,6 @@ class BotConfigurationService:
|
||||
"VERSION_CHECK_INTERVAL_HOURS": "VERSION",
|
||||
"TELEGRAM_STARS_RATE_RUB": "TELEGRAM",
|
||||
"REMNAWAVE_USER_DESCRIPTION_TEMPLATE": "REMNAWAVE",
|
||||
"REMNAWAVE_USER_USERNAME_TEMPLATE": "REMNAWAVE",
|
||||
"REMNAWAVE_AUTO_SYNC_ENABLED": "REMNAWAVE",
|
||||
"REMNAWAVE_AUTO_SYNC_TIMES": "REMNAWAVE",
|
||||
}
|
||||
@@ -305,7 +292,6 @@ class BotConfigurationService:
|
||||
"YOOKASSA_": "YOOKASSA",
|
||||
"CRYPTOBOT_": "CRYPTOBOT",
|
||||
"HELEKET_": "HELEKET",
|
||||
"PLATEGA_": "PLATEGA",
|
||||
"MULENPAY_": "MULENPAY",
|
||||
"PAL24_": "PAL24",
|
||||
"PAYMENT_": "PAYMENT",
|
||||
@@ -464,21 +450,6 @@ class BotConfigurationService:
|
||||
"example": "d4aa2b8c-9a36-4f31-93a2-6f07dad05fba",
|
||||
"warning": "Убедитесь, что выбранный сквад активен и доступен для подписки.",
|
||||
},
|
||||
"DEVICES_SELECTION_ENABLED": {
|
||||
"description": "Разрешает пользователям выбирать количество устройств при покупке и продлении подписки.",
|
||||
"format": "Булево значение.",
|
||||
"example": "false",
|
||||
"warning": "При отключении пользователи не смогут докупать устройства из интерфейса бота.",
|
||||
},
|
||||
"DEVICES_SELECTION_DISABLED_AMOUNT": {
|
||||
"description": (
|
||||
"Лимит устройств, который автоматически назначается, когда выбор количества устройств выключен. "
|
||||
"Значение 0 отключает назначение устройств."
|
||||
),
|
||||
"format": "Целое число от 0 и выше.",
|
||||
"example": "3",
|
||||
"warning": "При 0 RemnaWave не получит лимит устройств, пользователям не показываются цифры в интерфейсе.",
|
||||
},
|
||||
"CRYPTOBOT_ENABLED": {
|
||||
"description": "Разрешает принимать криптоплатежи через CryptoBot.",
|
||||
"format": "Булево значение.",
|
||||
@@ -528,28 +499,6 @@ class BotConfigurationService:
|
||||
"warning": "Не забудьте отключить после завершения работ, иначе бот останется недоступен.",
|
||||
"dependencies": "MAINTENANCE_MESSAGE, MAINTENANCE_CHECK_INTERVAL",
|
||||
},
|
||||
"MAINTENANCE_MONITORING_ENABLED": {
|
||||
"description": (
|
||||
"Управляет автоматическим запуском мониторинга панели Remnawave при старте бота."
|
||||
),
|
||||
"format": "Булево значение.",
|
||||
"example": "false",
|
||||
"warning": (
|
||||
"При отключении мониторинг можно запустить вручную из панели администратора."
|
||||
),
|
||||
"dependencies": "MAINTENANCE_CHECK_INTERVAL",
|
||||
},
|
||||
"MAINTENANCE_RETRY_ATTEMPTS": {
|
||||
"description": (
|
||||
"Сколько раз повторять проверку панели Remnawave перед фиксацией недоступности."
|
||||
),
|
||||
"format": "Целое число не меньше 1.",
|
||||
"example": "3",
|
||||
"warning": (
|
||||
"Большие значения увеличивают время реакции на реальные сбои, но помогают избежать ложных срабатываний."
|
||||
),
|
||||
"dependencies": "MAINTENANCE_CHECK_INTERVAL",
|
||||
},
|
||||
"DISPLAY_NAME_BANNED_KEYWORDS": {
|
||||
"description": (
|
||||
"Список слов и фрагментов, при наличии которых в отображаемом имени "
|
||||
@@ -587,31 +536,6 @@ class BotConfigurationService:
|
||||
),
|
||||
"dependencies": "REMNAWAVE_AUTO_SYNC_ENABLED",
|
||||
},
|
||||
"REMNAWAVE_USER_DESCRIPTION_TEMPLATE": {
|
||||
"description": (
|
||||
"Шаблон текста, который бот передает в поле Description при создании "
|
||||
"или обновлении пользователя в панели RemnaWave."
|
||||
),
|
||||
"format": (
|
||||
"Доступные плейсхолдеры: {full_name}, {username}, {username_clean}, {telegram_id}."
|
||||
),
|
||||
"example": "Bot user: {full_name} {username}",
|
||||
"warning": "Плейсхолдер {username} автоматически очищается, если у пользователя нет @username.",
|
||||
},
|
||||
"REMNAWAVE_USER_USERNAME_TEMPLATE": {
|
||||
"description": (
|
||||
"Шаблон имени пользователя, которое создаётся в панели RemnaWave для "
|
||||
"телеграм-пользователя."
|
||||
),
|
||||
"format": (
|
||||
"Доступные плейсхолдеры: {full_name}, {username}, {username_clean}, {telegram_id}."
|
||||
),
|
||||
"example": "vpn_{username_clean}_{telegram_id}",
|
||||
"warning": (
|
||||
"Недопустимые символы автоматически заменяются на подчёркивания. "
|
||||
"Если результат пустой, используется user_{telegram_id}."
|
||||
),
|
||||
},
|
||||
"EXTERNAL_ADMIN_TOKEN": {
|
||||
"description": "Приватный токен, который использует внешняя админка для проверки запросов.",
|
||||
"format": "Значение генерируется автоматически из username бота и его токена и доступно только для чтения.",
|
||||
@@ -642,10 +566,6 @@ class BotConfigurationService:
|
||||
def is_read_only(cls, key: str) -> bool:
|
||||
return key in cls.READ_ONLY_KEYS
|
||||
|
||||
@classmethod
|
||||
def _is_env_override(cls, key: str) -> bool:
|
||||
return key in cls._env_override_keys
|
||||
|
||||
@classmethod
|
||||
def _format_numeric_with_unit(cls, key: str, value: Union[int, float]) -> Optional[str]:
|
||||
if isinstance(value, bool):
|
||||
@@ -759,7 +679,6 @@ class BotConfigurationService:
|
||||
_definitions: Dict[str, SettingDefinition] = {}
|
||||
_original_values: Dict[str, Any] = settings.model_dump()
|
||||
_overrides_raw: Dict[str, Optional[str]] = {}
|
||||
_env_override_keys: set[str] = set(ENV_OVERRIDE_KEYS)
|
||||
_callback_tokens: Dict[str, str] = {}
|
||||
_token_to_key: Dict[str, str] = {}
|
||||
_choice_tokens: Dict[str, Dict[Any, str]] = {}
|
||||
@@ -883,8 +802,6 @@ class BotConfigurationService:
|
||||
|
||||
@classmethod
|
||||
def has_override(cls, key: str) -> bool:
|
||||
if cls._is_env_override(key):
|
||||
return False
|
||||
return key in cls._overrides_raw
|
||||
|
||||
@classmethod
|
||||
@@ -1180,12 +1097,6 @@ class BotConfigurationService:
|
||||
overrides[row.key] = row.value
|
||||
|
||||
for key, raw_value in overrides.items():
|
||||
if cls._is_env_override(key):
|
||||
logger.debug(
|
||||
"Пропускаем настройку %s из БД: используется значение из окружения",
|
||||
key,
|
||||
)
|
||||
continue
|
||||
try:
|
||||
parsed_value = cls.deserialize_value(key, raw_value)
|
||||
except Exception as error:
|
||||
@@ -1305,15 +1216,8 @@ class BotConfigurationService:
|
||||
|
||||
raw_value = cls.serialize_value(key, value)
|
||||
await upsert_system_setting(db, key, raw_value)
|
||||
if cls._is_env_override(key):
|
||||
logger.info(
|
||||
"Настройка %s сохранена в БД, но не применена: значение задаётся через окружение",
|
||||
key,
|
||||
)
|
||||
cls._overrides_raw.pop(key, None)
|
||||
else:
|
||||
cls._overrides_raw[key] = raw_value
|
||||
cls._apply_to_settings(key, value)
|
||||
cls._overrides_raw[key] = raw_value
|
||||
cls._apply_to_settings(key, value)
|
||||
|
||||
if key in {"WEB_API_DEFAULT_TOKEN", "WEB_API_DEFAULT_TOKEN_NAME"}:
|
||||
await cls._sync_default_web_api_token()
|
||||
@@ -1331,26 +1235,14 @@ class BotConfigurationService:
|
||||
|
||||
await delete_system_setting(db, key)
|
||||
cls._overrides_raw.pop(key, None)
|
||||
if cls._is_env_override(key):
|
||||
logger.info(
|
||||
"Настройка %s сброшена в БД, используется значение из окружения",
|
||||
key,
|
||||
)
|
||||
else:
|
||||
original = cls.get_original_value(key)
|
||||
cls._apply_to_settings(key, original)
|
||||
original = cls.get_original_value(key)
|
||||
cls._apply_to_settings(key, original)
|
||||
|
||||
if key in {"WEB_API_DEFAULT_TOKEN", "WEB_API_DEFAULT_TOKEN_NAME"}:
|
||||
await cls._sync_default_web_api_token()
|
||||
|
||||
@classmethod
|
||||
def _apply_to_settings(cls, key: str, value: Any) -> None:
|
||||
if cls._is_env_override(key):
|
||||
logger.debug(
|
||||
"Пропуск применения настройки %s: значение задано через окружение",
|
||||
key,
|
||||
)
|
||||
return
|
||||
try:
|
||||
setattr(settings, key, value)
|
||||
if key in {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user