Compare commits
365 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d0d1b7617a | |||
| 310990ae32 | |||
| b565c32e77 | |||
| f3edb4344b | |||
| ccdcff6a7b | |||
| 1d4090ea7a | |||
| 68c7d35165 | |||
| 221cc05af3 | |||
| 1524970401 | |||
| 32a03c5e4b | |||
| a03036c099 | |||
| 811e8a97b3 | |||
| c7625643c6 | |||
| 5ce87238c8 | |||
| 7d12a327ca | |||
| 8c93637d79 | |||
| 2d0ea02b4b | |||
| 98692ac3bd | |||
| 940b6b93b2 | |||
| 17768c303f | |||
| 441fcd2a36 | |||
| c2b6b4e370 | |||
| 2f8d27b1ec | |||
| dc32f53585 | |||
| 165691412b | |||
| 824d4b3a23 | |||
| 060f0ca199 | |||
| ac43f17a43 | |||
| 54fc1c029c | |||
| d259bcf169 | |||
| da46c18210 | |||
| ace7121a4e | |||
| 61a93f86b3 | |||
| 49bcd18a9a | |||
| 7df64d8edf | |||
| ca63b4d758 | |||
| 3cbb304e9b | |||
| ef5192234a | |||
| f7bb9ac4d9 | |||
| b8bb4f3c3f | |||
| e43ff27639 | |||
| e02123c1ed | |||
| f26df389b4 | |||
| 10d36a829d | |||
| 61e936dd49 | |||
| d0f9a04d5f | |||
| cf6bbffd9b | |||
| 5ad45addfa | |||
| 2af013b75b | |||
| 74bda5c986 | |||
| b290ccfff2 | |||
| 904d2883e8 | |||
| 1a54beed17 | |||
| 701a8d33af | |||
| aa03384012 | |||
| d346c63184 | |||
| b8005aaad7 | |||
| bfa9490425 | |||
| 1f4af21a07 | |||
| f952c0e38d | |||
| d7dab5d30f | |||
| 8407f7cdb1 | |||
| 6c72987201 | |||
| 81b6d3f18f | |||
| 95754ab224 | |||
| 949d964a7f | |||
| 8eab79434a | |||
| 8ab2351995 | |||
| e8802dde55 | |||
| acc5ace234 | |||
| 7b33721305 | |||
| e94f4598b5 | |||
| c66c8b7933 | |||
| 0b934efa09 | |||
| 79e644f6dd | |||
| 5c68a1aca7 | |||
| bde613c4bf | |||
| 184d8c3ba7 | |||
| d48221d57f | |||
| 657fd6d07d | |||
| 1211cd22ad | |||
| 19ed88009d | |||
| 0f30ee87ab | |||
| 4cc6778e45 | |||
| ab00a0d136 | |||
| 3d4f1af9a9 | |||
| 0302176fd2 | |||
| 8910780332 | |||
| 8bea64179a | |||
| 777f90eab1 | |||
| ad0b0f475a | |||
| 499292eab6 | |||
| 21b2f1bd2a | |||
| 3c025b536e | |||
| bded6fe59f | |||
| 344ad8f3c0 | |||
| b43dd3167f | |||
| 79ed9dbab2 | |||
| 38ff59f71f | |||
| 65c85e146d | |||
| 467955e7b3 | |||
| 1a4250a7ed | |||
| e181e4325b | |||
| 1a35237852 | |||
| 90baf3da52 | |||
| 348e4dd4dc | |||
| d2cd96a45c | |||
| fcfac2df1f | |||
| a0734a6eb4 | |||
| 0d7a235715 | |||
| 180788bc7b | |||
| 922681891a | |||
| 257c628cc8 | |||
| dcee842c92 | |||
| 5f02471fd5 | |||
| 82f0f7ce17 | |||
| b805b7eef9 | |||
| ad03fa070a | |||
| 9c354d971f | |||
| 5cc70c4bb8 | |||
| 9db806d387 | |||
| b7f7621d28 | |||
| 822ac2417d | |||
| b3c80a2a82 | |||
| 3e9d606647 | |||
| 0ab89f4956 | |||
| 0090f1c409 | |||
| 1cc37a57c1 | |||
| 7809efa556 | |||
| b4a69e3c85 | |||
| 67c975d9c2 | |||
| c0812a7ee9 | |||
| 8ed755a339 | |||
| 1aae8783ca | |||
| 5997a741fc | |||
| c5d522e6b8 | |||
| fa1d128d76 | |||
| f30516a6c2 | |||
| 1feb0538cf | |||
| 3a791d624f | |||
| fe0f1f7641 | |||
| 212783ae3d | |||
| 7d72fc9555 | |||
| fb4df5cee1 | |||
| 45cd847b44 | |||
| 98fd97dea2 | |||
| e334086ad1 | |||
| 2e792911f1 | |||
| 878b666a0b | |||
| 69c3239469 | |||
| b8d48745c5 | |||
| 6b64e73d6e | |||
| b433a96cd6 | |||
| 0e4c83299d | |||
| c4d1f3eccc | |||
| 22dcf425cb | |||
| edf4132c2d | |||
| a56e14c736 | |||
| 2e3c707745 | |||
| ebd8a10fd9 | |||
| a681a9ff1b | |||
| d11458c4a8 | |||
| 9d6e17b609 | |||
| cb27487021 | |||
| e1e6b40a19 | |||
| 2616292f97 | |||
| 2e1dfa6638 | |||
| 3a730492cb | |||
| c35da4b98d | |||
| 5e98c25020 | |||
| 11a4d5b436 | |||
| 93c733aac0 | |||
| 5b03e7ab8c | |||
| 33912331af | |||
| aacc07835e | |||
| 86df2a1408 | |||
| 018de17c5b | |||
| 66afa2b924 | |||
| 4ee32fe0de | |||
| 5b59ca3c4f | |||
| 0108b0fc22 | |||
| 2e575f82af | |||
| e8af09041d | |||
| 1cf041fb86 | |||
| 63074cb009 | |||
| 89b9a6e04f | |||
| 4efa344e43 | |||
| 2d3d1ba5d3 | |||
| 849d781e92 | |||
| 48a988aa43 | |||
| ec71f733ad | |||
| 051121a366 | |||
| e2c2b9127c | |||
| fdf7263bb0 | |||
| c8dcde0919 | |||
| 9db0cd6328 | |||
| 2375bf51b3 | |||
| 3631b49d6d | |||
| 3938b56802 | |||
| 3c3b793a9f | |||
| 8985a89a9f | |||
| 5750e6a4d2 | |||
| 8d7f73edec | |||
| 4853d5b19b | |||
| 901f5fd90d | |||
| 89e3865019 | |||
| 538b2b52d3 | |||
| f1c9df8471 | |||
| 9e09e97ed8 | |||
| e50355be23 | |||
| bd036fb4a9 | |||
| 0181f8e5fb | |||
| f5eea6798a | |||
| 7c8d0254b2 | |||
| 49c3c1c0c9 | |||
| 38522442d1 | |||
| 0dacd79598 | |||
| 2022654055 | |||
| f3a1de6335 | |||
| 0b5b89ea9a | |||
| 8e21c34c8c | |||
| 400f3b01d6 | |||
| 6a0b2aabaf | |||
| 13a1ddad11 | |||
| ed404a0e54 | |||
| 05acfc0733 | |||
| 969cd6ed90 | |||
| 939c9b9b09 | |||
| 051c0db106 | |||
| e5fbe005f8 | |||
| 63efb31785 | |||
| 32586e177b | |||
| 651dba2648 | |||
| 1bbcf2a95c | |||
| 82af176018 | |||
| c10a4e9f8a | |||
| 9727e07464 | |||
| b780fe2278 | |||
| 947d61f2a8 | |||
| 27725ec1a7 | |||
| 93a29463f4 | |||
| 84cfcf45fa | |||
| 6651e59f63 | |||
| ed46b7c919 | |||
| 88cd9fbc5b | |||
| 46fc5827ca | |||
| 65d386f3b2 | |||
| 9db640931e | |||
| 53e4dec12d | |||
| 174d9f9b84 | |||
| 30ec07f7fe | |||
| dbf1fd8085 | |||
| 32e1f231d3 | |||
| 96b788693d | |||
| a0e00c3735 | |||
| 51e68cf215 | |||
| fa3ed3af81 | |||
| 6e72cbb5b0 | |||
| 5f316f85b3 | |||
| 2abd1fd9df | |||
| fcb5fedf9f | |||
| d35c9ad87e | |||
| 0cc0368b0b | |||
| 00fb154cb9 | |||
| 4441cf79ff | |||
| 7428bd5cf7 | |||
| 76c524e980 | |||
| 5a9fa167c2 | |||
| c61e9c78ba | |||
| e5e3a9e4b5 | |||
| a1f47e13e3 | |||
| 7ff0ed1248 | |||
| 5eef24fb91 | |||
| 05ba26d3ac | |||
| ef68ce19ce | |||
| 2fa84f6dc9 | |||
| 32409d7471 | |||
| 270282a340 | |||
| 87eaa06652 | |||
| 845f03e873 | |||
| 97eabb0604 | |||
| f0d714d787 | |||
| 27a34d6e9f | |||
| a4e97e0317 | |||
| 97dcd0b7cc | |||
| 9b57904cf4 | |||
| 6ebdae0cae | |||
| b9d4a8f8e9 | |||
| 707c3c5daf | |||
| d075abfeea | |||
| d3399c569c | |||
| e36e3f7f8f | |||
| 583e01f5e6 | |||
| 784b5e0af6 | |||
| cc42d33fbe | |||
| 9c3d8fd701 | |||
| d281eeb69d | |||
| b510ffb7d8 | |||
| 6855b7765c | |||
| 9ed1129268 | |||
| 0c4ba3c0d0 | |||
| 70337ac310 | |||
| e981082713 | |||
| 104d98542e | |||
| fe63ef5fc5 | |||
| b1ff964dd6 | |||
| aba1d5dbe5 | |||
| 0af7e4ac68 | |||
| 7d9a572e6e | |||
| de6557d7e7 | |||
| a052c7bb80 | |||
| 8c47f3c49b | |||
| 323ad903e7 | |||
| 8105fe285d | |||
| 82a19025f1 | |||
| e592b3e5c4 | |||
| 2da12354a1 | |||
| fc65b62d65 | |||
| 28603a87e1 | |||
| 8956aafc9a | |||
| 2ee9b263b8 | |||
| 3a5d838be3 | |||
| bf25fcfda6 | |||
| ecdf2fcae4 | |||
| 6fec14de4b | |||
| 7b4cbb27b7 | |||
| d7d7795257 | |||
| 93554c7034 | |||
| fbe7febdde | |||
| f091b3ea5e | |||
| aae469853e | |||
| dac68ccd8c | |||
| fb140e82f6 | |||
| 8cac442de1 | |||
| 8d8a465c43 | |||
| 51f2c660e8 | |||
| 39ecca306d | |||
| 7896a24e13 | |||
| c21325f260 | |||
| 18df7395a2 | |||
| f139251333 | |||
| 369e384b7d | |||
| 008f62e02b | |||
| 6e8981a245 | |||
| bb17cf7361 | |||
| d13c6767e6 | |||
| bf2ee37f33 | |||
| b82929ac3d | |||
| 9b0ea9bf2a | |||
| 5bde7196dc | |||
| 71c3e7fe0d | |||
| 201543c263 | |||
| 5ce7417c80 | |||
| e98564b5ab | |||
| 37dd48cc20 | |||
| 9dff6e8045 | |||
| 572094050e | |||
| 2b41337bbf | |||
| ca94c402bd | |||
| 6fdc46c3c6 | |||
| 34fc568f97 | |||
| 38664ff853 | |||
| e44284d4ba | |||
| d54dc822cc | |||
| dbe9c2d5e9 |
+20
-1
@@ -67,6 +67,14 @@ 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 - только деактивировать пользователя
|
||||
@@ -146,6 +154,10 @@ 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
|
||||
@@ -240,8 +252,13 @@ 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=Пополнение баланса
|
||||
@@ -383,6 +400,8 @@ 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=Ведутся технические работы. Сервис временно недоступен. Попробуйте позже.
|
||||
|
||||
# ===== ЛОКАЛИЗАЦИЯ =====
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
---
|
||||
name: Bug Bedolage
|
||||
about: Создать баг-репорт «Бедолаге»
|
||||
title: "[BUG] Заголовок"
|
||||
labels: ''
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
## Описание / Конфигурация
|
||||
|
||||
Кратко описываем текущую проблему.
|
||||
|
||||
Указываем настройки из `.env` конфига (если таковые имеются):
|
||||
|
||||
```yml
|
||||
|
||||
```
|
||||
|
||||
## Шаги для воспроизведения
|
||||
|
||||
1.
|
||||
2.
|
||||
3.
|
||||
|
||||
### Фактический результат
|
||||
|
||||
*
|
||||
*
|
||||
*
|
||||
|
||||
### Скрины поведения
|
||||
|
||||
Копируем изображение и вставляем прям сюда, если есть. Если нет — удаляем секцию.
|
||||
|
||||
## Ожидаемое поведение
|
||||
|
||||
Опишите, как должно работать.
|
||||
|
||||
## Дополнительная информация
|
||||
|
||||
Добавляем сюда дополнительную инфу, если есть. Если нет — удаляем секцию.
|
||||
@@ -0,0 +1,32 @@
|
||||
---
|
||||
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.5.4-$(git rev-parse --short HEAD)"
|
||||
VERSION="v2.5.7-$(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.5.4-dev-$(git rev-parse --short HEAD)"
|
||||
VERSION="v2.5.7-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.5.4-pr-$(git rev-parse --short HEAD)"
|
||||
VERSION="v2.5.7-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.5.4-$(git rev-parse --short HEAD)"
|
||||
VERSION="v2.5.7-$(git rev-parse --short HEAD)"
|
||||
echo "🚀 Building main version: $VERSION"
|
||||
elif [[ $GITHUB_REF == refs/heads/dev ]]; then
|
||||
VERSION="v2.5.4-dev-$(git rev-parse --short HEAD)"
|
||||
VERSION="v2.5.7-dev-$(git rev-parse --short HEAD)"
|
||||
echo "🧪 Building dev version: $VERSION"
|
||||
else
|
||||
VERSION="v2.5.4-pr-$(git rev-parse --short HEAD)"
|
||||
VERSION="v2.5.7-pr-$(git rev-parse --short HEAD)"
|
||||
echo "🔀 Building PR version: $VERSION"
|
||||
fi
|
||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||
|
||||
+1
-1
@@ -14,7 +14,7 @@ RUN pip install --no-cache-dir --upgrade pip && \
|
||||
|
||||
FROM python:3.13-slim
|
||||
|
||||
ARG VERSION="v2.5.4"
|
||||
ARG VERSION="v2.5.7"
|
||||
ARG BUILD_DATE
|
||||
ARG VCS_REF
|
||||
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
|
||||
<div align="center">
|
||||
|
||||

|
||||
<img width="1024" height="1024" alt="ChatGPT Image 23 окт 2025 г , 13_18_33" src="https://github.com/user-attachments/assets/17ad0128-231d-4553-9f4b-ce0644da796c" />
|
||||
|
||||
|
||||
**🤖 Современный Telegram-бот для управления VPN подписками через Remnawave API**
|
||||
|
||||
@@ -57,7 +58,7 @@
|
||||
- 🛡️ **Защита панели** - поддержка [remnawave-reverse-proxy](https://github.com/eGamesAPI/remnawave-reverse-proxy)
|
||||
- 🗄️ **Бекапы/Восстановление** - автобекапы и восстановление бд прямо в боте с уведомлениями в топики
|
||||
- ✍️ **Проверка на подписку** - проверяет подписку на канал
|
||||
- 🔄 **Автосинхронизация** - фоновая синхронизация серверов с Remnawave по расписанию
|
||||
- 🔄 **Автосинхронизация** - фоновая синхронизация подписок и серверов(сквадов) с Remnawave по расписанию
|
||||
|
||||
### 📚 Поддерживаемые методы авторизации
|
||||
|
||||
@@ -470,7 +471,9 @@ REMNAWAVE_AUTO_SYNC_TIMES=03:00,15:00
|
||||
# Автоматический режим тех. работ
|
||||
MAINTENANCE_MODE=false
|
||||
MAINTENANCE_AUTO_ENABLE=true
|
||||
MAINTENANCE_MONITORING_ENABLED=true
|
||||
MAINTENANCE_CHECK_INTERVAL=30
|
||||
MAINTENANCE_RETRY_ATTEMPTS=1
|
||||
|
||||
# Интервал проверки состояния панели (секунды)
|
||||
MONITORING_INTERVAL=60
|
||||
@@ -537,6 +540,8 @@ REMNAWAVE_AUTO_SYNC_TIMES=03:00,15:00
|
||||
|
||||
# Шаблон описания пользователя
|
||||
REMNAWAVE_USER_DESCRIPTION_TEMPLATE="Bot user: {full_name} {username}"
|
||||
# Шаблон имени пользователя в панели
|
||||
REMNAWAVE_USER_USERNAME_TEMPLATE="user_{telegram_id}"
|
||||
REMNAWAVE_USER_DELETE_MODE=delete
|
||||
|
||||
# ===== ПОДПИСКИ =====
|
||||
@@ -568,6 +573,9 @@ BASE_PROMO_GROUP_PERIOD_DISCOUNTS=60:10,90:20,180:40,360:70
|
||||
|
||||
TRAFFIC_PACKAGES_CONFIG="5:2000:false,10:3500:false,25:7000:false,50:11000:true,100:15000:true,0:20000:true"
|
||||
PRICE_PER_DEVICE=5000
|
||||
DEVICES_SELECTION_ENABLED=true
|
||||
# Единое количество устройств для режима без выбора (0 — не назначать устройства)
|
||||
DEVICES_SELECTION_DISABLED_AMOUNT=0
|
||||
|
||||
# ===== РЕФЕРАЛЬНАЯ СИСТЕМА =====
|
||||
REFERRAL_PROGRAM_ENABLED=true
|
||||
@@ -657,6 +665,8 @@ 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
|
||||
|
||||
# ===== ЛОКАЛИЗАЦИЯ =====
|
||||
DEFAULT_LANGUAGE=ru
|
||||
@@ -747,7 +757,7 @@ LOG_FILE=logs/bot.log
|
||||
- 💸 **Автоматические скидки** при оплате и автопродлении
|
||||
- 👥 Реферальная программа с комиссиями и бонусами
|
||||
- 📊 Аналитика доходов и конверсии рефералов
|
||||
- 🔗 Генерация реферальных ссылок и QR-кодов
|
||||
- 🔗 Генерация реферальных ссылок и QR кодов
|
||||
|
||||
💎 **Промо-группы и скидки**
|
||||
- 🏷️ **Система промогрупп** с индивидуальными скидками
|
||||
@@ -781,7 +791,7 @@ LOG_FILE=logs/bot.log
|
||||
- 📊 **Расширенная фильтрация** пользователей (баланс, траты, активность)
|
||||
|
||||
👥 **Управление пользователями**
|
||||
- 🔍 Поиск, фильтры и детальные карточки
|
||||
- 🔍 Поиск по ID, имени, юзернейму, Telegram ID и фильтры
|
||||
- 💰 Ручное изменение баланса
|
||||
- 📱 Изменение лимитов устройств, трафика, серверов
|
||||
- 🔄 Сброс HWID и перегенерация подписки
|
||||
|
||||
+24
-7
@@ -16,9 +16,18 @@ from app.services.maintenance_service import maintenance_service
|
||||
from app.utils.cache import cache
|
||||
|
||||
from app.handlers import (
|
||||
start, menu, subscription, balance, promocode,
|
||||
referral, support, server_status, common, tickets
|
||||
start,
|
||||
menu,
|
||||
subscription,
|
||||
balance,
|
||||
promocode,
|
||||
referral,
|
||||
support,
|
||||
server_status,
|
||||
common,
|
||||
tickets,
|
||||
)
|
||||
from app.handlers import polls as user_polls
|
||||
from app.handlers import simple_subscription
|
||||
from app.handlers.admin import (
|
||||
main as admin_main,
|
||||
@@ -31,6 +40,7 @@ from app.handlers.admin import (
|
||||
rules as admin_rules,
|
||||
remnawave as admin_remnawave,
|
||||
statistics as admin_statistics,
|
||||
polls as admin_polls,
|
||||
servers as admin_servers,
|
||||
maintenance as admin_maintenance,
|
||||
promo_groups as admin_promo_groups,
|
||||
@@ -48,6 +58,7 @@ from app.handlers.admin import (
|
||||
privacy_policy as admin_privacy_policy,
|
||||
public_offer as admin_public_offer,
|
||||
faq as admin_faq,
|
||||
payments as admin_payments,
|
||||
)
|
||||
from app.handlers.stars_payments import register_stars_handlers
|
||||
|
||||
@@ -145,6 +156,7 @@ async def setup_bot() -> tuple[Bot, Dispatcher]:
|
||||
admin_rules.register_handlers(dp)
|
||||
admin_remnawave.register_handlers(dp)
|
||||
admin_statistics.register_handlers(dp)
|
||||
admin_polls.register_handlers(dp)
|
||||
admin_promo_groups.register_handlers(dp)
|
||||
admin_campaigns.register_handlers(dp)
|
||||
admin_promo_offers.register_handlers(dp)
|
||||
@@ -161,18 +173,23 @@ async def setup_bot() -> tuple[Bot, Dispatcher]:
|
||||
admin_privacy_policy.register_handlers(dp)
|
||||
admin_public_offer.register_handlers(dp)
|
||||
admin_faq.register_handlers(dp)
|
||||
admin_payments.register_handlers(dp)
|
||||
common.register_handlers(dp)
|
||||
register_stars_handlers(dp)
|
||||
user_polls.register_handlers(dp)
|
||||
simple_subscription.register_simple_subscription_handlers(dp)
|
||||
logger.info("⭐ Зарегистрированы обработчики Telegram Stars платежей")
|
||||
logger.info("⚡ Зарегистрированы обработчики простой покупки")
|
||||
logger.info("⚡ Зарегистрированы обработчики простой подписки")
|
||||
|
||||
try:
|
||||
await maintenance_service.start_monitoring()
|
||||
logger.info("Мониторинг техработ запущен")
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка запуска мониторинга техработ: {e}")
|
||||
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("Мониторинг техработ отключен настройками")
|
||||
|
||||
logger.info("🛡️ GlobalErrorMiddleware активирован - бот защищен от устаревших callback queries")
|
||||
logger.info("Бот успешно настроен")
|
||||
|
||||
+124
-36
@@ -7,6 +7,7 @@ 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
|
||||
@@ -18,6 +19,9 @@ DEFAULT_DISPLAY_NAME_BANNED_KEYWORDS = [
|
||||
]
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
|
||||
BOT_TOKEN: str
|
||||
@@ -57,6 +61,8 @@ 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"
|
||||
|
||||
@@ -70,6 +76,7 @@ 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"
|
||||
@@ -120,10 +127,12 @@ 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 = ""
|
||||
@@ -139,7 +148,6 @@ 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"
|
||||
|
||||
@@ -151,8 +159,10 @@ class Settings(BaseSettings):
|
||||
INACTIVE_USER_DELETE_MONTHS: int = 3
|
||||
|
||||
MAINTENANCE_MODE: bool = False
|
||||
MAINTENANCE_CHECK_INTERVAL: int = 30
|
||||
MAINTENANCE_AUTO_ENABLE: bool = True
|
||||
MAINTENANCE_CHECK_INTERVAL: int = 30
|
||||
MAINTENANCE_AUTO_ENABLE: bool = True
|
||||
MAINTENANCE_MONITORING_ENABLED: bool = True
|
||||
MAINTENANCE_RETRY_ATTEMPTS: int = 1
|
||||
MAINTENANCE_MESSAGE: str = "🔧 Ведутся технические работы. Сервис временно недоступен. Попробуйте позже."
|
||||
|
||||
TELEGRAM_STARS_ENABLED: bool = True
|
||||
@@ -182,7 +192,11 @@ class Settings(BaseSettings):
|
||||
YOOKASSA_MAX_AMOUNT_KOPEKS: int = 1000000
|
||||
YOOKASSA_QUICK_AMOUNT_SELECTION_ENABLED: bool = False
|
||||
DISABLE_TOPUP_BUTTONS: bool = False
|
||||
|
||||
PAYMENT_VERIFICATION_AUTO_CHECK_ENABLED: bool = False
|
||||
PAYMENT_VERIFICATION_AUTO_CHECK_INTERVAL_MINUTES: int = 10
|
||||
|
||||
AUTO_PURCHASE_AFTER_TOPUP_ENABLED: bool = False
|
||||
|
||||
# Настройки простой покупки
|
||||
SIMPLE_SUBSCRIPTION_ENABLED: bool = False
|
||||
SIMPLE_SUBSCRIPTION_PERIOD_DAYS: int = 30
|
||||
@@ -543,6 +557,34 @@ 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:
|
||||
@@ -621,6 +663,15 @@ class Settings(BaseSettings):
|
||||
return normalized in {"1", "true", "yes", "on"}
|
||||
|
||||
return bool(value)
|
||||
|
||||
def is_auto_purchase_after_topup_enabled(self) -> bool:
|
||||
value = getattr(self, "AUTO_PURCHASE_AFTER_TOPUP_ENABLED", False)
|
||||
|
||||
if isinstance(value, str):
|
||||
normalized = value.strip().lower()
|
||||
return normalized in {"1", "true", "yes", "on"}
|
||||
|
||||
return bool(value)
|
||||
|
||||
def get_available_languages(self) -> List[str]:
|
||||
try:
|
||||
@@ -781,9 +832,35 @@ 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_yookassa_enabled(self) -> bool:
|
||||
return (self.YOOKASSA_ENABLED and
|
||||
@@ -839,6 +916,24 @@ class Settings(BaseSettings):
|
||||
and self.WATA_TERMINAL_PUBLIC_ID is not None
|
||||
)
|
||||
|
||||
def is_payment_verification_auto_check_enabled(self) -> bool:
|
||||
return self.PAYMENT_VERIFICATION_AUTO_CHECK_ENABLED
|
||||
|
||||
def get_payment_verification_auto_check_interval(self) -> int:
|
||||
try:
|
||||
minutes = int(self.PAYMENT_VERIFICATION_AUTO_CHECK_INTERVAL_MINUTES)
|
||||
except (TypeError, ValueError): # pragma: no cover - защитная проверка конфигурации
|
||||
minutes = 10
|
||||
|
||||
if minutes <= 0:
|
||||
logger.warning(
|
||||
"Некорректный интервал автопроверки платежей: %s. Используется значение по умолчанию 10 минут.",
|
||||
self.PAYMENT_VERIFICATION_AUTO_CHECK_INTERVAL_MINUTES,
|
||||
)
|
||||
return 10
|
||||
|
||||
return minutes
|
||||
|
||||
def get_cryptobot_base_url(self) -> str:
|
||||
if self.CRYPTOBOT_TESTNET:
|
||||
return "https://testnet-pay.crypt.bot"
|
||||
@@ -920,6 +1015,13 @@ 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
|
||||
|
||||
@@ -962,6 +1064,9 @@ 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
|
||||
@@ -1053,34 +1158,7 @@ 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,
|
||||
@@ -1088,8 +1166,6 @@ 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:
|
||||
@@ -1351,11 +1427,23 @@ class Settings(BaseSettings):
|
||||
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",
|
||||
|
||||
@@ -0,0 +1,297 @@
|
||||
import logging
|
||||
from typing import Iterable, Sequence
|
||||
|
||||
from sqlalchemy import and_, delete, func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.database.models import (
|
||||
Poll,
|
||||
PollAnswer,
|
||||
PollOption,
|
||||
PollQuestion,
|
||||
PollResponse,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def create_poll(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
title: str,
|
||||
description: str | None,
|
||||
reward_enabled: bool,
|
||||
reward_amount_kopeks: int,
|
||||
created_by: int | None,
|
||||
questions: Sequence[dict[str, Iterable[str]]],
|
||||
) -> Poll:
|
||||
poll = Poll(
|
||||
title=title,
|
||||
description=description,
|
||||
reward_enabled=reward_enabled,
|
||||
reward_amount_kopeks=reward_amount_kopeks if reward_enabled else 0,
|
||||
created_by=created_by,
|
||||
)
|
||||
db.add(poll)
|
||||
await db.flush()
|
||||
|
||||
for order, question_data in enumerate(questions, start=1):
|
||||
question_text = question_data.get("text", "").strip()
|
||||
if not question_text:
|
||||
continue
|
||||
|
||||
question = PollQuestion(
|
||||
poll_id=poll.id,
|
||||
text=question_text,
|
||||
order=order,
|
||||
)
|
||||
db.add(question)
|
||||
await db.flush()
|
||||
|
||||
for option_order, option_text in enumerate(question_data.get("options", []), start=1):
|
||||
option_text = option_text.strip()
|
||||
if not option_text:
|
||||
continue
|
||||
option = PollOption(
|
||||
question_id=question.id,
|
||||
text=option_text,
|
||||
order=option_order,
|
||||
)
|
||||
db.add(option)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(
|
||||
poll,
|
||||
attribute_names=["questions"],
|
||||
)
|
||||
return poll
|
||||
|
||||
|
||||
async def list_polls(db: AsyncSession) -> list[Poll]:
|
||||
result = await db.execute(
|
||||
select(Poll)
|
||||
.options(
|
||||
selectinload(Poll.questions).options(selectinload(PollQuestion.options))
|
||||
)
|
||||
.order_by(Poll.created_at.desc())
|
||||
)
|
||||
return result.scalars().all()
|
||||
|
||||
|
||||
async def get_poll_by_id(db: AsyncSession, poll_id: int) -> Poll | None:
|
||||
result = await db.execute(
|
||||
select(Poll)
|
||||
.options(
|
||||
selectinload(Poll.questions).options(selectinload(PollQuestion.options)),
|
||||
selectinload(Poll.responses),
|
||||
)
|
||||
.where(Poll.id == poll_id)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def delete_poll(db: AsyncSession, poll_id: int) -> bool:
|
||||
poll = await db.get(Poll, poll_id)
|
||||
if not poll:
|
||||
return False
|
||||
|
||||
await db.delete(poll)
|
||||
await db.commit()
|
||||
logger.info("🗑️ Удалён опрос %s", poll_id)
|
||||
return True
|
||||
|
||||
|
||||
async def create_poll_response(
|
||||
db: AsyncSession,
|
||||
poll_id: int,
|
||||
user_id: int,
|
||||
) -> PollResponse:
|
||||
result = await db.execute(
|
||||
select(PollResponse)
|
||||
.where(
|
||||
and_(
|
||||
PollResponse.poll_id == poll_id,
|
||||
PollResponse.user_id == user_id,
|
||||
)
|
||||
)
|
||||
)
|
||||
response = result.scalar_one_or_none()
|
||||
if response:
|
||||
return response
|
||||
|
||||
response = PollResponse(
|
||||
poll_id=poll_id,
|
||||
user_id=user_id,
|
||||
)
|
||||
db.add(response)
|
||||
await db.commit()
|
||||
await db.refresh(response)
|
||||
return response
|
||||
|
||||
|
||||
async def get_poll_response_by_id(
|
||||
db: AsyncSession,
|
||||
response_id: int,
|
||||
) -> PollResponse | None:
|
||||
result = await db.execute(
|
||||
select(PollResponse)
|
||||
.options(
|
||||
selectinload(PollResponse.poll)
|
||||
.options(selectinload(Poll.questions).options(selectinload(PollQuestion.options))),
|
||||
selectinload(PollResponse.answers),
|
||||
selectinload(PollResponse.user),
|
||||
)
|
||||
.where(PollResponse.id == response_id)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def record_poll_answer(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
response_id: int,
|
||||
question_id: int,
|
||||
option_id: int,
|
||||
) -> PollAnswer:
|
||||
result = await db.execute(
|
||||
select(PollAnswer)
|
||||
.where(
|
||||
and_(
|
||||
PollAnswer.response_id == response_id,
|
||||
PollAnswer.question_id == question_id,
|
||||
)
|
||||
)
|
||||
)
|
||||
answer = result.scalar_one_or_none()
|
||||
if answer:
|
||||
answer.option_id = option_id
|
||||
await db.commit()
|
||||
await db.refresh(answer)
|
||||
return answer
|
||||
|
||||
answer = PollAnswer(
|
||||
response_id=response_id,
|
||||
question_id=question_id,
|
||||
option_id=option_id,
|
||||
)
|
||||
db.add(answer)
|
||||
await db.commit()
|
||||
await db.refresh(answer)
|
||||
return answer
|
||||
|
||||
|
||||
async def reset_poll_answers(db: AsyncSession, response_id: int) -> None:
|
||||
await db.execute(
|
||||
delete(PollAnswer).where(PollAnswer.response_id == response_id)
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
|
||||
async def get_poll_statistics(db: AsyncSession, poll_id: int) -> dict:
|
||||
totals_result = await db.execute(
|
||||
select(
|
||||
func.count(PollResponse.id),
|
||||
func.count(PollResponse.completed_at),
|
||||
func.coalesce(func.sum(PollResponse.reward_amount_kopeks), 0),
|
||||
).where(PollResponse.poll_id == poll_id)
|
||||
)
|
||||
total_responses, completed_responses, reward_sum = totals_result.one()
|
||||
|
||||
option_counts_result = await db.execute(
|
||||
select(
|
||||
PollQuestion.id,
|
||||
PollQuestion.text,
|
||||
PollQuestion.order,
|
||||
PollOption.id,
|
||||
PollOption.text,
|
||||
PollOption.order,
|
||||
func.count(PollAnswer.id),
|
||||
)
|
||||
.join(PollOption, PollOption.question_id == PollQuestion.id)
|
||||
.outerjoin(
|
||||
PollAnswer,
|
||||
and_(
|
||||
PollAnswer.question_id == PollQuestion.id,
|
||||
PollAnswer.option_id == PollOption.id,
|
||||
),
|
||||
)
|
||||
.where(PollQuestion.poll_id == poll_id)
|
||||
.group_by(
|
||||
PollQuestion.id,
|
||||
PollQuestion.text,
|
||||
PollQuestion.order,
|
||||
PollOption.id,
|
||||
PollOption.text,
|
||||
PollOption.order,
|
||||
)
|
||||
.order_by(PollQuestion.order.asc(), PollOption.order.asc())
|
||||
)
|
||||
|
||||
questions_map: dict[int, dict] = {}
|
||||
for (
|
||||
question_id,
|
||||
question_text,
|
||||
question_order,
|
||||
option_id,
|
||||
option_text,
|
||||
option_order,
|
||||
answer_count,
|
||||
) in option_counts_result:
|
||||
question_entry = questions_map.setdefault(
|
||||
question_id,
|
||||
{
|
||||
"id": question_id,
|
||||
"text": question_text,
|
||||
"order": question_order,
|
||||
"options": [],
|
||||
},
|
||||
)
|
||||
question_entry["options"].append(
|
||||
{
|
||||
"id": option_id,
|
||||
"text": option_text,
|
||||
"count": answer_count,
|
||||
}
|
||||
)
|
||||
|
||||
questions = sorted(questions_map.values(), key=lambda item: item["order"])
|
||||
|
||||
return {
|
||||
"total_responses": total_responses,
|
||||
"completed_responses": completed_responses,
|
||||
"reward_sum_kopeks": reward_sum,
|
||||
"questions": questions,
|
||||
}
|
||||
|
||||
|
||||
async def get_poll_responses_with_answers(
|
||||
db: AsyncSession,
|
||||
poll_id: int,
|
||||
*,
|
||||
limit: int,
|
||||
offset: int,
|
||||
) -> tuple[list[PollResponse], int]:
|
||||
total_result = await db.execute(
|
||||
select(func.count()).select_from(PollResponse).where(PollResponse.poll_id == poll_id)
|
||||
)
|
||||
total = int(total_result.scalar_one() or 0)
|
||||
|
||||
if total == 0:
|
||||
return [], 0
|
||||
|
||||
result = await db.execute(
|
||||
select(PollResponse)
|
||||
.options(
|
||||
selectinload(PollResponse.user),
|
||||
selectinload(PollResponse.answers).selectinload(PollAnswer.question),
|
||||
selectinload(PollResponse.answers).selectinload(PollAnswer.option),
|
||||
)
|
||||
.where(PollResponse.poll_id == poll_id)
|
||||
.order_by(PollResponse.sent_at.asc())
|
||||
.offset(offset)
|
||||
.limit(limit)
|
||||
)
|
||||
|
||||
responses = result.scalars().unique().all()
|
||||
return responses, total
|
||||
@@ -15,6 +15,7 @@ from app.database.models import (
|
||||
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__)
|
||||
|
||||
@@ -41,13 +42,14 @@ async def create_trial_subscription(
|
||||
user_id: int,
|
||||
duration_days: int = None,
|
||||
traffic_limit_gb: int = None,
|
||||
device_limit: int = None,
|
||||
device_limit: Optional[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
|
||||
device_limit = device_limit or settings.TRIAL_DEVICE_LIMIT
|
||||
if device_limit is None:
|
||||
device_limit = settings.TRIAL_DEVICE_LIMIT
|
||||
if not squad_uuid:
|
||||
try:
|
||||
from app.database.crud.server_squad import get_random_trial_squad_uuid
|
||||
@@ -126,13 +128,16 @@ async def create_paid_subscription(
|
||||
user_id: int,
|
||||
duration_days: int,
|
||||
traffic_limit_gb: int = 0,
|
||||
device_limit: int = 1,
|
||||
device_limit: Optional[int] = None,
|
||||
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,
|
||||
@@ -1127,7 +1132,13 @@ async def check_and_update_subscription_status(
|
||||
|
||||
current_time = datetime.utcnow()
|
||||
|
||||
logger.info(f"🔍 Проверка статуса подписки {subscription.id}, текущий статус: {subscription.status}, дата окончания: {subscription.end_date}, текущее время: {current_time}")
|
||||
logger.info(
|
||||
"🔍 Проверка статуса подписки %s, текущий статус: %s, дата окончания: %s, текущее время: %s",
|
||||
subscription.id,
|
||||
subscription.status,
|
||||
format_local_datetime(subscription.end_date),
|
||||
format_local_datetime(current_time),
|
||||
)
|
||||
|
||||
if (subscription.status == SubscriptionStatus.ACTIVE.value and
|
||||
subscription.end_date <= current_time):
|
||||
|
||||
@@ -333,18 +333,42 @@ class TicketCRUD:
|
||||
*,
|
||||
limit: int = 50,
|
||||
offset: int = 0,
|
||||
action: Optional[str] = None,
|
||||
) -> List[SupportAuditLog]:
|
||||
from sqlalchemy import select, desc
|
||||
result = await db.execute(
|
||||
select(SupportAuditLog).order_by(desc(SupportAuditLog.created_at)).offset(offset).limit(limit)
|
||||
)
|
||||
|
||||
query = select(SupportAuditLog).order_by(desc(SupportAuditLog.created_at))
|
||||
|
||||
if action:
|
||||
query = query.where(SupportAuditLog.action == action)
|
||||
|
||||
result = await db.execute(query.offset(offset).limit(limit))
|
||||
return result.scalars().all()
|
||||
|
||||
@staticmethod
|
||||
async def count_support_audit(db: AsyncSession) -> int:
|
||||
async def count_support_audit(db: AsyncSession, action: Optional[str] = None) -> int:
|
||||
from sqlalchemy import select, func
|
||||
result = await db.execute(select(func.count()).select_from(SupportAuditLog))
|
||||
|
||||
query = select(func.count()).select_from(SupportAuditLog)
|
||||
|
||||
if action:
|
||||
query = query.where(SupportAuditLog.action == action)
|
||||
|
||||
result = await db.execute(query)
|
||||
return int(result.scalar() or 0)
|
||||
|
||||
@staticmethod
|
||||
async def list_support_audit_actions(db: AsyncSession) -> List[str]:
|
||||
from sqlalchemy import select
|
||||
|
||||
result = await db.execute(
|
||||
select(SupportAuditLog.action)
|
||||
.where(SupportAuditLog.action.isnot(None))
|
||||
.distinct()
|
||||
.order_by(SupportAuditLog.action)
|
||||
)
|
||||
|
||||
return [row[0] for row in result.fetchall()]
|
||||
|
||||
@staticmethod
|
||||
async def get_open_tickets_count(db: AsyncSession) -> int:
|
||||
|
||||
@@ -62,10 +62,34 @@ async def get_user_by_telegram_id(db: AsyncSession, telegram_id: int) -> Optiona
|
||||
.where(User.telegram_id == telegram_id)
|
||||
)
|
||||
user = result.scalar_one_or_none()
|
||||
|
||||
|
||||
if user and user.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.promo_group),
|
||||
selectinload(User.referrer),
|
||||
)
|
||||
.where(func.lower(User.username) == normalized)
|
||||
)
|
||||
|
||||
user = result.scalar_one_or_none()
|
||||
|
||||
if user and user.subscription:
|
||||
_ = user.subscription.is_active
|
||||
|
||||
return user
|
||||
|
||||
|
||||
@@ -219,7 +243,8 @@ async def add_user_balance(
|
||||
amount_kopeks: int,
|
||||
description: str = "Пополнение баланса",
|
||||
create_transaction: bool = True,
|
||||
bot = None
|
||||
transaction_type: TransactionType = TransactionType.DEPOSIT,
|
||||
bot = None
|
||||
) -> bool:
|
||||
try:
|
||||
old_balance = user.balance_kopeks
|
||||
@@ -228,12 +253,11 @@ async def add_user_balance(
|
||||
|
||||
if create_transaction:
|
||||
from app.database.crud.transaction import create_transaction as create_trans
|
||||
from app.database.models import TransactionType
|
||||
|
||||
|
||||
await create_trans(
|
||||
db=db,
|
||||
user_id=user.id,
|
||||
type=TransactionType.DEPOSIT,
|
||||
type=transaction_type,
|
||||
amount_kopeks=amount_kopeks,
|
||||
description=description
|
||||
)
|
||||
@@ -253,9 +277,10 @@ async def add_user_balance(
|
||||
|
||||
async def add_user_balance_by_id(
|
||||
db: AsyncSession,
|
||||
telegram_id: int,
|
||||
telegram_id: int,
|
||||
amount_kopeks: int,
|
||||
description: str = "Пополнение баланса"
|
||||
description: str = "Пополнение баланса",
|
||||
transaction_type: TransactionType = TransactionType.DEPOSIT,
|
||||
) -> bool:
|
||||
try:
|
||||
user = await get_user_by_telegram_id(db, telegram_id)
|
||||
@@ -263,7 +288,13 @@ async def add_user_balance_by_id(
|
||||
logger.error(f"Пользователь с telegram_id {telegram_id} не найден")
|
||||
return False
|
||||
|
||||
return await add_user_balance(db, user, amount_kopeks, description)
|
||||
return await add_user_balance(
|
||||
db,
|
||||
user,
|
||||
amount_kopeks,
|
||||
description,
|
||||
transaction_type=transaction_type,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка пополнения баланса пользователя {telegram_id}: {e}")
|
||||
@@ -508,6 +539,7 @@ async def get_users_list(
|
||||
|
||||
if search.isdigit():
|
||||
conditions.append(User.telegram_id == int(search))
|
||||
conditions.append(User.id == int(search)) # Add support for searching by internal user ID
|
||||
|
||||
query = query.where(or_(*conditions))
|
||||
|
||||
@@ -605,6 +637,7 @@ async def get_users_count(
|
||||
|
||||
if search.isdigit():
|
||||
conditions.append(User.telegram_id == int(search))
|
||||
conditions.append(User.id == int(search)) # Add support for searching by internal user ID
|
||||
|
||||
query = query.where(or_(*conditions))
|
||||
|
||||
|
||||
+109
-7
@@ -58,11 +58,12 @@ class SubscriptionStatus(Enum):
|
||||
|
||||
|
||||
class TransactionType(Enum):
|
||||
DEPOSIT = "deposit"
|
||||
WITHDRAWAL = "withdrawal"
|
||||
SUBSCRIPTION_PAYMENT = "subscription_payment"
|
||||
REFUND = "refund"
|
||||
REFERRAL_REWARD = "referral_reward"
|
||||
DEPOSIT = "deposit"
|
||||
WITHDRAWAL = "withdrawal"
|
||||
SUBSCRIPTION_PAYMENT = "subscription_payment"
|
||||
REFUND = "refund"
|
||||
REFERRAL_REWARD = "referral_reward"
|
||||
POLL_REWARD = "poll_reward"
|
||||
|
||||
|
||||
class PromoCodeType(Enum):
|
||||
@@ -530,6 +531,7 @@ class User(Base):
|
||||
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=False, index=True)
|
||||
promo_group = relationship("PromoGroup", back_populates="users")
|
||||
poll_responses = relationship("PollResponse", back_populates="user")
|
||||
|
||||
@property
|
||||
def balance_rubles(self) -> float:
|
||||
@@ -1061,9 +1063,9 @@ class PromoOfferLog(Base):
|
||||
|
||||
class BroadcastHistory(Base):
|
||||
__tablename__ = "broadcast_history"
|
||||
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
target_type = Column(String(100), nullable=False)
|
||||
target_type = Column(String(100), nullable=False)
|
||||
message_text = Column(Text, nullable=False)
|
||||
has_media = Column(Boolean, default=False)
|
||||
media_type = Column(String(20), nullable=True)
|
||||
@@ -1079,6 +1081,106 @@ class BroadcastHistory(Base):
|
||||
completed_at = Column(DateTime(timezone=True), nullable=True)
|
||||
admin = relationship("User", back_populates="broadcasts")
|
||||
|
||||
|
||||
class Poll(Base):
|
||||
__tablename__ = "polls"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
title = Column(String(255), nullable=False)
|
||||
description = Column(Text, nullable=True)
|
||||
reward_enabled = Column(Boolean, nullable=False, default=False)
|
||||
reward_amount_kopeks = Column(Integer, nullable=False, default=0)
|
||||
created_by = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
|
||||
created_at = Column(DateTime, default=func.now(), nullable=False)
|
||||
updated_at = Column(DateTime, default=func.now(), onupdate=func.now(), nullable=False)
|
||||
|
||||
creator = relationship("User", backref="created_polls", foreign_keys=[created_by])
|
||||
questions = relationship(
|
||||
"PollQuestion",
|
||||
back_populates="poll",
|
||||
cascade="all, delete-orphan",
|
||||
order_by="PollQuestion.order",
|
||||
)
|
||||
responses = relationship(
|
||||
"PollResponse",
|
||||
back_populates="poll",
|
||||
cascade="all, delete-orphan",
|
||||
)
|
||||
|
||||
|
||||
class PollQuestion(Base):
|
||||
__tablename__ = "poll_questions"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
poll_id = Column(Integer, ForeignKey("polls.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
text = Column(Text, nullable=False)
|
||||
order = Column(Integer, nullable=False, default=0)
|
||||
|
||||
poll = relationship("Poll", back_populates="questions")
|
||||
options = relationship(
|
||||
"PollOption",
|
||||
back_populates="question",
|
||||
cascade="all, delete-orphan",
|
||||
order_by="PollOption.order",
|
||||
)
|
||||
answers = relationship("PollAnswer", back_populates="question")
|
||||
|
||||
|
||||
class PollOption(Base):
|
||||
__tablename__ = "poll_options"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
question_id = Column(Integer, ForeignKey("poll_questions.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
text = Column(Text, nullable=False)
|
||||
order = Column(Integer, nullable=False, default=0)
|
||||
|
||||
question = relationship("PollQuestion", back_populates="options")
|
||||
answers = relationship("PollAnswer", back_populates="option")
|
||||
|
||||
|
||||
class PollResponse(Base):
|
||||
__tablename__ = "poll_responses"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
poll_id = Column(Integer, ForeignKey("polls.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
sent_at = Column(DateTime, default=func.now(), nullable=False)
|
||||
started_at = Column(DateTime, nullable=True)
|
||||
completed_at = Column(DateTime, nullable=True)
|
||||
reward_given = Column(Boolean, nullable=False, default=False)
|
||||
reward_amount_kopeks = Column(Integer, nullable=False, default=0)
|
||||
|
||||
poll = relationship("Poll", back_populates="responses")
|
||||
user = relationship("User", back_populates="poll_responses")
|
||||
answers = relationship(
|
||||
"PollAnswer",
|
||||
back_populates="response",
|
||||
cascade="all, delete-orphan",
|
||||
)
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint("poll_id", "user_id", name="uq_poll_user"),
|
||||
)
|
||||
|
||||
|
||||
class PollAnswer(Base):
|
||||
__tablename__ = "poll_answers"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
response_id = Column(Integer, ForeignKey("poll_responses.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
question_id = Column(Integer, ForeignKey("poll_questions.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
option_id = Column(Integer, ForeignKey("poll_options.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
created_at = Column(DateTime, default=func.now(), nullable=False)
|
||||
|
||||
response = relationship("PollResponse", back_populates="answers")
|
||||
question = relationship("PollQuestion", back_populates="answers")
|
||||
option = relationship("PollOption", back_populates="answers")
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint("response_id", "question_id", name="uq_poll_answer_unique"),
|
||||
)
|
||||
|
||||
|
||||
class ServerSquad(Base):
|
||||
__tablename__ = "server_squads"
|
||||
|
||||
|
||||
Vendored
+34
-14
@@ -19,10 +19,10 @@ class CryptoBotService:
|
||||
self.webhook_secret = settings.CRYPTOBOT_WEBHOOK_SECRET
|
||||
|
||||
async def _make_request(
|
||||
self,
|
||||
method: str,
|
||||
endpoint: str,
|
||||
data: Optional[Dict] = None
|
||||
self,
|
||||
method: str,
|
||||
endpoint: str,
|
||||
data: Optional[Dict] = None,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
|
||||
if not self.api_token:
|
||||
@@ -37,11 +37,18 @@ class CryptoBotService:
|
||||
|
||||
try:
|
||||
async with aiohttp.ClientSession() as session:
|
||||
request_kwargs: Dict[str, Any] = {"headers": headers}
|
||||
|
||||
if method.upper() == "GET":
|
||||
if data:
|
||||
request_kwargs["params"] = data
|
||||
elif data:
|
||||
request_kwargs["json"] = data
|
||||
|
||||
async with session.request(
|
||||
method,
|
||||
url,
|
||||
headers=headers,
|
||||
json=data if data else None
|
||||
method,
|
||||
url,
|
||||
**request_kwargs,
|
||||
) as response:
|
||||
|
||||
response_data = await response.json()
|
||||
@@ -95,21 +102,34 @@ class CryptoBotService:
|
||||
asset: Optional[str] = None,
|
||||
status: Optional[str] = None,
|
||||
offset: int = 0,
|
||||
count: int = 100
|
||||
count: int = 100,
|
||||
invoice_ids: Optional[list] = None,
|
||||
) -> Optional[list]:
|
||||
|
||||
|
||||
data = {
|
||||
'offset': offset,
|
||||
'count': count
|
||||
}
|
||||
|
||||
|
||||
if asset:
|
||||
data['asset'] = asset
|
||||
|
||||
|
||||
if status:
|
||||
data['status'] = status
|
||||
|
||||
return await self._make_request('GET', 'getInvoices', data)
|
||||
|
||||
if invoice_ids:
|
||||
data['invoice_ids'] = invoice_ids
|
||||
|
||||
result = await self._make_request('GET', 'getInvoices', data)
|
||||
|
||||
if isinstance(result, dict):
|
||||
items = result.get('items')
|
||||
return items if isinstance(items, list) else []
|
||||
|
||||
if isinstance(result, list):
|
||||
return result
|
||||
|
||||
return []
|
||||
|
||||
async def get_balance(self) -> Optional[list]:
|
||||
return await self._make_request('GET', 'getBalance')
|
||||
|
||||
Vendored
+29
-2
@@ -41,7 +41,13 @@ class HeleketService:
|
||||
raw = f"{encoded}{api_key}"
|
||||
return hashlib.md5(raw.encode("utf-8")).hexdigest()
|
||||
|
||||
async def _request(self, endpoint: str, payload: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
||||
async def _request(
|
||||
self,
|
||||
endpoint: str,
|
||||
payload: Dict[str, Any],
|
||||
*,
|
||||
params: Optional[Dict[str, Any]] = None,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
if not self.is_configured:
|
||||
logger.error("Heleket сервис не настроен: merchant или api_key отсутствуют")
|
||||
return None
|
||||
@@ -59,7 +65,12 @@ class HeleketService:
|
||||
try:
|
||||
timeout = aiohttp.ClientTimeout(total=30)
|
||||
async with aiohttp.ClientSession(timeout=timeout) as session:
|
||||
async with session.post(url, data=body.encode("utf-8"), headers=headers) as response:
|
||||
async with session.post(
|
||||
url,
|
||||
data=body.encode("utf-8"),
|
||||
headers=headers,
|
||||
params=params,
|
||||
) as response:
|
||||
text = await response.text()
|
||||
if response.content_type != "application/json":
|
||||
logger.error("Ответ Heleket не JSON (%s): %s", response.content_type, text)
|
||||
@@ -104,6 +115,22 @@ class HeleketService:
|
||||
|
||||
return await self._request("payment/info", payload)
|
||||
|
||||
async def list_payments(
|
||||
self,
|
||||
*,
|
||||
date_from: Optional[str] = None,
|
||||
date_to: Optional[str] = None,
|
||||
cursor: Optional[str] = None,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
payload: Dict[str, Any] = {}
|
||||
if date_from:
|
||||
payload["date_from"] = date_from
|
||||
if date_to:
|
||||
payload["date_to"] = date_to
|
||||
|
||||
params = {"cursor": cursor} if cursor else None
|
||||
return await self._request("payment/list", payload, params=params)
|
||||
|
||||
def verify_webhook_signature(self, payload: Dict[str, Any]) -> bool:
|
||||
if not self.is_configured:
|
||||
logger.warning("Heleket сервис не настроен, подпись пропускается")
|
||||
|
||||
@@ -42,7 +42,16 @@ CATEGORY_GROUP_METADATA: Dict[str, Dict[str, object]] = {
|
||||
"title": "🤖 Основные",
|
||||
"description": "Базовые настройки бота, обязательные каналы и ключевые сервисы.",
|
||||
"icon": "🤖",
|
||||
"categories": ("CORE", "CHANNEL"),
|
||||
"categories": (
|
||||
"CORE",
|
||||
"CHANNEL",
|
||||
"TIMEZONE",
|
||||
"DATABASE",
|
||||
"POSTGRES",
|
||||
"SQLITE",
|
||||
"REDIS",
|
||||
"REMNAWAVE",
|
||||
),
|
||||
},
|
||||
"support": {
|
||||
"title": "💬 Поддержка",
|
||||
@@ -56,6 +65,7 @@ CATEGORY_GROUP_METADATA: Dict[str, Dict[str, object]] = {
|
||||
"icon": "💳",
|
||||
"categories": (
|
||||
"PAYMENT",
|
||||
"PAYMENT_VERIFICATION",
|
||||
"YOOKASSA",
|
||||
"CRYPTOBOT",
|
||||
"HELEKET",
|
||||
@@ -114,18 +124,6 @@ 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 и внешние метрики.",
|
||||
@@ -142,13 +140,14 @@ CATEGORY_GROUP_METADATA: Dict[str, Dict[str, object]] = {
|
||||
"title": "⚡ Расширенные",
|
||||
"description": "Web API, webhook, логирование, модерация и режим отладки.",
|
||||
"icon": "⚡",
|
||||
"categories": ("WEB_API", "WEBHOOK", "LOG", "MODERATION", "DEBUG"),
|
||||
},
|
||||
"external_admin": {
|
||||
"title": "🛡️ Внешняя админка",
|
||||
"description": "Токен, по которому внешняя админка проверяет запросы.",
|
||||
"icon": "🛡️",
|
||||
"categories": ("EXTERNAL_ADMIN",),
|
||||
"categories": (
|
||||
"WEB_API",
|
||||
"WEBHOOK",
|
||||
"LOG",
|
||||
"MODERATION",
|
||||
"DEBUG",
|
||||
"EXTERNAL_ADMIN",
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
@@ -161,12 +160,9 @@ 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(
|
||||
@@ -313,7 +309,6 @@ 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,6 +46,9 @@ 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"
|
||||
@@ -53,7 +56,7 @@ def _format_campaign_summary(campaign, texts) -> str:
|
||||
).format(
|
||||
days=campaign.subscription_duration_days or 0,
|
||||
traffic=traffic_text,
|
||||
devices=campaign.subscription_device_limit or settings.DEFAULT_DEVICE_LIMIT,
|
||||
devices=device_limit,
|
||||
)
|
||||
|
||||
return (
|
||||
@@ -935,7 +938,9 @@ async def start_edit_campaign_subscription_devices(
|
||||
campaign_edit_message_is_caption=is_caption,
|
||||
)
|
||||
|
||||
current_devices = campaign.subscription_device_limit or settings.DEFAULT_DEVICE_LIMIT
|
||||
current_devices = campaign.subscription_device_limit
|
||||
if current_devices is None:
|
||||
current_devices = settings.DEFAULT_DEVICE_LIMIT
|
||||
|
||||
await callback.message.edit_text(
|
||||
(
|
||||
|
||||
@@ -74,6 +74,7 @@ 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}
|
||||
@@ -233,7 +234,11 @@ 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]}")
|
||||
|
||||
|
||||
@@ -0,0 +1,596 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import html
|
||||
import math
|
||||
from typing import Optional
|
||||
|
||||
from aiogram import Dispatcher, F, types
|
||||
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.models import PaymentMethod, User
|
||||
from app.localization.texts import get_texts
|
||||
from app.services.payment_service import PaymentService
|
||||
from app.services.payment_verification_service import (
|
||||
PendingPayment,
|
||||
SUPPORTED_MANUAL_CHECK_METHODS,
|
||||
get_payment_record,
|
||||
list_recent_pending_payments,
|
||||
run_manual_check,
|
||||
)
|
||||
from app.utils.decorators import admin_required, error_handler
|
||||
from app.utils.formatters import format_datetime, format_time_ago, format_username
|
||||
|
||||
|
||||
PAGE_SIZE = 6
|
||||
|
||||
|
||||
def _method_display(method: PaymentMethod) -> str:
|
||||
if method == PaymentMethod.MULENPAY:
|
||||
return settings.get_mulenpay_display_name()
|
||||
if method == PaymentMethod.PAL24:
|
||||
return "PayPalych"
|
||||
if method == PaymentMethod.WATA:
|
||||
return "WATA"
|
||||
if method == PaymentMethod.HELEKET:
|
||||
return "Heleket"
|
||||
if method == PaymentMethod.YOOKASSA:
|
||||
return "YooKassa"
|
||||
if method == PaymentMethod.CRYPTOBOT:
|
||||
return "CryptoBot"
|
||||
if method == PaymentMethod.TELEGRAM_STARS:
|
||||
return "Telegram Stars"
|
||||
return method.value
|
||||
|
||||
|
||||
def _status_info(
|
||||
record: PendingPayment,
|
||||
*,
|
||||
texts,
|
||||
) -> tuple[str, str]:
|
||||
status = (record.status or "").lower()
|
||||
|
||||
if record.is_paid:
|
||||
return "✅", texts.t("ADMIN_PAYMENT_STATUS_PAID", "✅ Paid")
|
||||
|
||||
if record.method == PaymentMethod.PAL24:
|
||||
mapping = {
|
||||
"new": ("⏳", texts.t("ADMIN_PAYMENT_STATUS_PENDING", "⏳ Pending")),
|
||||
"process": ("⌛", texts.t("ADMIN_PAYMENT_STATUS_PROCESSING", "⌛ Processing")),
|
||||
"success": ("✅", texts.t("ADMIN_PAYMENT_STATUS_PAID", "✅ Paid")),
|
||||
"fail": ("❌", texts.t("ADMIN_PAYMENT_STATUS_FAILED", "❌ Failed")),
|
||||
"canceled": ("❌", texts.t("ADMIN_PAYMENT_STATUS_CANCELED", "❌ Cancelled")),
|
||||
"cancel": ("❌", texts.t("ADMIN_PAYMENT_STATUS_CANCELED", "❌ Cancelled")),
|
||||
}
|
||||
return mapping.get(status, ("❓", texts.t("ADMIN_PAYMENT_STATUS_UNKNOWN", "❓ Unknown")))
|
||||
|
||||
if record.method == PaymentMethod.MULENPAY:
|
||||
mapping = {
|
||||
"created": ("⏳", texts.t("ADMIN_PAYMENT_STATUS_PENDING", "⏳ Pending")),
|
||||
"processing": ("⌛", texts.t("ADMIN_PAYMENT_STATUS_PROCESSING", "⌛ Processing")),
|
||||
"hold": ("🔒", texts.t("ADMIN_PAYMENT_STATUS_ON_HOLD", "🔒 Hold")),
|
||||
"success": ("✅", texts.t("ADMIN_PAYMENT_STATUS_PAID", "✅ Paid")),
|
||||
"canceled": ("❌", texts.t("ADMIN_PAYMENT_STATUS_CANCELED", "❌ Cancelled")),
|
||||
"cancel": ("❌", texts.t("ADMIN_PAYMENT_STATUS_CANCELED", "❌ Cancelled")),
|
||||
"error": ("⚠️", texts.t("ADMIN_PAYMENT_STATUS_FAILED", "❌ Failed")),
|
||||
}
|
||||
return mapping.get(status, ("❓", texts.t("ADMIN_PAYMENT_STATUS_UNKNOWN", "❓ Unknown")))
|
||||
|
||||
if record.method == PaymentMethod.WATA:
|
||||
mapping = {
|
||||
"opened": ("⏳", texts.t("ADMIN_PAYMENT_STATUS_PENDING", "⏳ Pending")),
|
||||
"pending": ("⏳", texts.t("ADMIN_PAYMENT_STATUS_PENDING", "⏳ Pending")),
|
||||
"processing": ("⌛", texts.t("ADMIN_PAYMENT_STATUS_PROCESSING", "⌛ Processing")),
|
||||
"paid": ("✅", texts.t("ADMIN_PAYMENT_STATUS_PAID", "✅ Paid")),
|
||||
"closed": ("✅", texts.t("ADMIN_PAYMENT_STATUS_PAID", "✅ Paid")),
|
||||
"declined": ("❌", texts.t("ADMIN_PAYMENT_STATUS_FAILED", "❌ Failed")),
|
||||
"canceled": ("❌", 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")
|
||||
if status in {"paid", "paid_over"}:
|
||||
return "✅", texts.t("ADMIN_PAYMENT_STATUS_PAID", "✅ Paid")
|
||||
if status in {"cancel", "canceled", "fail", "failed", "expired"}:
|
||||
return "❌", texts.t("ADMIN_PAYMENT_STATUS_CANCELED", "❌ Cancelled")
|
||||
return "❓", texts.t("ADMIN_PAYMENT_STATUS_UNKNOWN", "❓ Unknown")
|
||||
|
||||
if record.method == PaymentMethod.YOOKASSA:
|
||||
mapping = {
|
||||
"pending": ("⏳", texts.t("ADMIN_PAYMENT_STATUS_PENDING", "⏳ Pending")),
|
||||
"waiting_for_capture": ("⌛", texts.t("ADMIN_PAYMENT_STATUS_PROCESSING", "⌛ Processing")),
|
||||
"succeeded": ("✅", texts.t("ADMIN_PAYMENT_STATUS_PAID", "✅ Paid")),
|
||||
"canceled": ("❌", texts.t("ADMIN_PAYMENT_STATUS_CANCELED", "❌ Cancelled")),
|
||||
}
|
||||
return mapping.get(status, ("❓", texts.t("ADMIN_PAYMENT_STATUS_UNKNOWN", "❓ Unknown")))
|
||||
|
||||
if record.method == PaymentMethod.CRYPTOBOT:
|
||||
mapping = {
|
||||
"active": ("⏳", texts.t("ADMIN_PAYMENT_STATUS_PENDING", "⏳ Pending")),
|
||||
"paid": ("✅", texts.t("ADMIN_PAYMENT_STATUS_PAID", "✅ Paid")),
|
||||
"expired": ("⌛", texts.t("ADMIN_PAYMENT_STATUS_EXPIRED", "⌛ Expired")),
|
||||
}
|
||||
return mapping.get(status, ("❓", texts.t("ADMIN_PAYMENT_STATUS_UNKNOWN", "❓ Unknown")))
|
||||
|
||||
if record.method == PaymentMethod.TELEGRAM_STARS:
|
||||
if record.is_paid:
|
||||
return "✅", texts.t("ADMIN_PAYMENT_STATUS_PAID", "✅ Paid")
|
||||
return "⏳", texts.t("ADMIN_PAYMENT_STATUS_PENDING", "⏳ Pending")
|
||||
|
||||
return "❓", texts.t("ADMIN_PAYMENT_STATUS_UNKNOWN", "❓ Unknown")
|
||||
|
||||
|
||||
def _is_checkable(record: PendingPayment) -> bool:
|
||||
if record.method not in SUPPORTED_MANUAL_CHECK_METHODS:
|
||||
return False
|
||||
if not record.is_recent():
|
||||
return False
|
||||
status = (record.status or "").lower()
|
||||
if record.method == PaymentMethod.PAL24:
|
||||
return status in {"new", "process"}
|
||||
if record.method == PaymentMethod.MULENPAY:
|
||||
return status in {"created", "processing", "hold"}
|
||||
if record.method == PaymentMethod.WATA:
|
||||
return status in {"opened", "pending", "processing", "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:
|
||||
return status in {"pending", "waiting_for_capture"}
|
||||
if record.method == PaymentMethod.CRYPTOBOT:
|
||||
return status in {"active"}
|
||||
return False
|
||||
|
||||
|
||||
def _record_display_number(record: PendingPayment) -> str:
|
||||
if record.identifier:
|
||||
return str(record.identifier)
|
||||
return str(record.local_id)
|
||||
|
||||
|
||||
def _build_list_keyboard(
|
||||
records: list[PendingPayment],
|
||||
*,
|
||||
page: int,
|
||||
total_pages: int,
|
||||
language: str,
|
||||
) -> InlineKeyboardMarkup:
|
||||
buttons: list[list[InlineKeyboardButton]] = []
|
||||
texts = get_texts(language)
|
||||
|
||||
for record in records:
|
||||
number = _record_display_number(record)
|
||||
details_template = texts.t("ADMIN_PAYMENTS_ITEM_DETAILS", "📄 #{number}")
|
||||
try:
|
||||
button_text = details_template.format(number=number)
|
||||
except Exception: # pragma: no cover - fallback for broken localization
|
||||
button_text = f"📄 {number}"
|
||||
buttons.append(
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=button_text,
|
||||
callback_data=f"admin_payment_{record.method.value}_{record.local_id}",
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
if total_pages > 1:
|
||||
navigation_row: list[InlineKeyboardButton] = []
|
||||
if page > 1:
|
||||
navigation_row.append(
|
||||
InlineKeyboardButton(
|
||||
text="⬅️",
|
||||
callback_data=f"admin_payments_page_{page - 1}",
|
||||
)
|
||||
)
|
||||
|
||||
navigation_row.append(
|
||||
InlineKeyboardButton(
|
||||
text=f"{page}/{total_pages}",
|
||||
callback_data="admin_payments_page_current",
|
||||
)
|
||||
)
|
||||
|
||||
if page < total_pages:
|
||||
navigation_row.append(
|
||||
InlineKeyboardButton(
|
||||
text="➡️",
|
||||
callback_data=f"admin_payments_page_{page + 1}",
|
||||
)
|
||||
)
|
||||
|
||||
buttons.append(navigation_row)
|
||||
|
||||
buttons.append([InlineKeyboardButton(text=texts.BACK, callback_data="admin_panel")])
|
||||
|
||||
return InlineKeyboardMarkup(inline_keyboard=buttons)
|
||||
|
||||
|
||||
def _build_detail_keyboard(
|
||||
record: PendingPayment,
|
||||
*,
|
||||
language: str,
|
||||
) -> InlineKeyboardMarkup:
|
||||
texts = get_texts(language)
|
||||
rows: list[list[InlineKeyboardButton]] = []
|
||||
|
||||
payment = record.payment
|
||||
payment_url = getattr(payment, "payment_url", None)
|
||||
if record.method == PaymentMethod.PAL24:
|
||||
payment_url = payment.link_url or payment.link_page_url or payment_url
|
||||
elif record.method == PaymentMethod.WATA:
|
||||
payment_url = payment.url or payment_url
|
||||
elif record.method == PaymentMethod.YOOKASSA:
|
||||
payment_url = getattr(payment, "confirmation_url", None) or payment_url
|
||||
elif record.method == PaymentMethod.CRYPTOBOT:
|
||||
payment_url = (
|
||||
payment.bot_invoice_url
|
||||
or payment.mini_app_invoice_url
|
||||
or payment.web_app_invoice_url
|
||||
or payment_url
|
||||
)
|
||||
|
||||
if payment_url:
|
||||
rows.append(
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t("ADMIN_PAYMENT_OPEN_LINK", "🔗 Open link"),
|
||||
url=payment_url,
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
if _is_checkable(record):
|
||||
rows.append(
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t("ADMIN_PAYMENT_CHECK_BUTTON", "🔁 Check status"),
|
||||
callback_data=f"admin_payment_check_{record.method.value}_{record.local_id}",
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
rows.append([InlineKeyboardButton(text=texts.BACK, callback_data="admin_payments")])
|
||||
return InlineKeyboardMarkup(inline_keyboard=rows)
|
||||
|
||||
|
||||
def _format_user_line(user: User) -> str:
|
||||
username = format_username(user.username, user.telegram_id, user.full_name)
|
||||
return f"👤 {html.escape(username)} (<code>{user.telegram_id}</code>)"
|
||||
|
||||
|
||||
def _build_record_lines(
|
||||
record: PendingPayment,
|
||||
*,
|
||||
index: int,
|
||||
texts,
|
||||
language: str,
|
||||
) -> list[str]:
|
||||
amount = settings.format_price(record.amount_kopeks)
|
||||
if record.method == PaymentMethod.CRYPTOBOT:
|
||||
crypto_amount = getattr(record.payment, "amount", None)
|
||||
crypto_asset = getattr(record.payment, "asset", None)
|
||||
if crypto_amount and crypto_asset:
|
||||
amount = f"{crypto_amount} {crypto_asset}"
|
||||
method_name = _method_display(record.method)
|
||||
emoji, status_text = _status_info(record, texts=texts)
|
||||
created = format_datetime(record.created_at)
|
||||
age = format_time_ago(record.created_at, language)
|
||||
identifier = (
|
||||
html.escape(str(record.identifier)) if record.identifier else ""
|
||||
)
|
||||
display_number = html.escape(_record_display_number(record))
|
||||
|
||||
lines = [
|
||||
f"{index}. <b>{html.escape(method_name)}</b> — {amount}",
|
||||
f" {emoji} {status_text}",
|
||||
f" 🕒 {created} ({age})",
|
||||
_format_user_line(record.user),
|
||||
]
|
||||
|
||||
if identifier:
|
||||
lines.append(f" 🆔 ID: <code>{identifier}</code>")
|
||||
else:
|
||||
lines.append(f" 🆔 ID: <code>{display_number}</code>")
|
||||
|
||||
return lines
|
||||
|
||||
|
||||
def _build_payment_details_text(record: PendingPayment, *, texts, language: str) -> str:
|
||||
method_name = _method_display(record.method)
|
||||
emoji, status_text = _status_info(record, texts=texts)
|
||||
amount = settings.format_price(record.amount_kopeks)
|
||||
if record.method == PaymentMethod.CRYPTOBOT:
|
||||
crypto_amount = getattr(record.payment, "amount", None)
|
||||
crypto_asset = getattr(record.payment, "asset", None)
|
||||
if crypto_amount and crypto_asset:
|
||||
amount = f"{crypto_amount} {crypto_asset}"
|
||||
created = format_datetime(record.created_at)
|
||||
age = format_time_ago(record.created_at, language)
|
||||
raw_identifier = record.identifier if record.identifier else record.local_id
|
||||
identifier = html.escape(str(raw_identifier)) if raw_identifier is not None else "—"
|
||||
lines = [
|
||||
texts.t("ADMIN_PAYMENT_DETAILS_TITLE", "💳 <b>Payment details</b>"),
|
||||
"",
|
||||
f"<b>{html.escape(method_name)}</b>",
|
||||
f"{emoji} {status_text}",
|
||||
"",
|
||||
f"💰 {texts.t('ADMIN_PAYMENT_AMOUNT', 'Amount')}: {amount}",
|
||||
f"🕒 {texts.t('ADMIN_PAYMENT_CREATED', 'Created')}: {created} ({age})",
|
||||
f"🆔 ID: <code>{identifier}</code>",
|
||||
_format_user_line(record.user),
|
||||
]
|
||||
|
||||
if record.expires_at:
|
||||
expires_at = format_datetime(record.expires_at)
|
||||
lines.append(f"⏳ {texts.t('ADMIN_PAYMENT_EXPIRES', 'Expires')}: {expires_at}")
|
||||
|
||||
payment = record.payment
|
||||
|
||||
if record.method == PaymentMethod.PAL24:
|
||||
if getattr(payment, "payment_status", None):
|
||||
lines.append(
|
||||
f"💳 {texts.t('ADMIN_PAYMENT_GATEWAY_STATUS', 'Gateway status')}: "
|
||||
f"{html.escape(str(payment.payment_status))}"
|
||||
)
|
||||
if getattr(payment, "payment_method", None):
|
||||
lines.append(
|
||||
f"🏦 {texts.t('ADMIN_PAYMENT_GATEWAY_METHOD', 'Method')}: "
|
||||
f"{html.escape(str(payment.payment_method))}"
|
||||
)
|
||||
if getattr(payment, "balance_amount", None):
|
||||
lines.append(
|
||||
f"💱 {texts.t('ADMIN_PAYMENT_GATEWAY_AMOUNT', 'Gateway amount')}: "
|
||||
f"{html.escape(str(payment.balance_amount))}"
|
||||
)
|
||||
if getattr(payment, "payer_account", None):
|
||||
lines.append(
|
||||
f"👛 {texts.t('ADMIN_PAYMENT_GATEWAY_ACCOUNT', 'Payer account')}: "
|
||||
f"{html.escape(str(payment.payer_account))}"
|
||||
)
|
||||
|
||||
if record.method == PaymentMethod.MULENPAY:
|
||||
if getattr(payment, "mulen_payment_id", None):
|
||||
lines.append(
|
||||
f"🧾 {texts.t('ADMIN_PAYMENT_GATEWAY_ID', 'Gateway ID')}: "
|
||||
f"{html.escape(str(payment.mulen_payment_id))}"
|
||||
)
|
||||
|
||||
if record.method == PaymentMethod.WATA:
|
||||
if getattr(payment, "order_id", None):
|
||||
lines.append(
|
||||
f"🧾 {texts.t('ADMIN_PAYMENT_GATEWAY_ID', 'Gateway ID')}: "
|
||||
f"{html.escape(str(payment.order_id))}"
|
||||
)
|
||||
if getattr(payment, "terminal_public_id", None):
|
||||
lines.append(
|
||||
f"🏦 Terminal: {html.escape(str(payment.terminal_public_id))}"
|
||||
)
|
||||
|
||||
if record.method == PaymentMethod.HELEKET:
|
||||
if getattr(payment, "order_id", None):
|
||||
lines.append(
|
||||
f"🧾 {texts.t('ADMIN_PAYMENT_GATEWAY_ID', 'Gateway ID')}: "
|
||||
f"{html.escape(str(payment.order_id))}"
|
||||
)
|
||||
if getattr(payment, "payer_amount", None) and getattr(payment, "payer_currency", None):
|
||||
lines.append(
|
||||
f"🪙 {texts.t('ADMIN_PAYMENT_PAYER_AMOUNT', 'Paid amount')}: "
|
||||
f"{html.escape(str(payment.payer_amount))} {html.escape(str(payment.payer_currency))}"
|
||||
)
|
||||
|
||||
if record.method == PaymentMethod.YOOKASSA:
|
||||
if getattr(payment, "payment_method_type", None):
|
||||
lines.append(
|
||||
f"💳 {texts.t('ADMIN_PAYMENT_GATEWAY_METHOD', 'Method')}: "
|
||||
f"{html.escape(str(payment.payment_method_type))}"
|
||||
)
|
||||
if getattr(payment, "confirmation_url", None):
|
||||
lines.append(texts.t("ADMIN_PAYMENT_HAS_LINK", "🔗 Payment link is available above."))
|
||||
|
||||
if record.method == PaymentMethod.CRYPTOBOT:
|
||||
if getattr(payment, "amount", None) and getattr(payment, "asset", None):
|
||||
lines.append(
|
||||
f"🪙 {texts.t('ADMIN_PAYMENT_CRYPTO_AMOUNT', 'Crypto amount')}: "
|
||||
f"{html.escape(str(payment.amount))} {html.escape(str(payment.asset))}"
|
||||
)
|
||||
if getattr(payment, "bot_invoice_url", None) or getattr(payment, "mini_app_invoice_url", None):
|
||||
lines.append(
|
||||
texts.t("ADMIN_PAYMENT_HAS_LINK", "🔗 Payment link is available above.")
|
||||
)
|
||||
if getattr(payment, "status", None):
|
||||
lines.append(
|
||||
f"📊 {texts.t('ADMIN_PAYMENT_GATEWAY_STATUS', 'Gateway status')}: "
|
||||
f"{html.escape(str(payment.status))}"
|
||||
)
|
||||
|
||||
if record.method == PaymentMethod.TELEGRAM_STARS:
|
||||
description = getattr(payment, "description", "") or ""
|
||||
if description:
|
||||
lines.append(f"📝 {html.escape(description)}")
|
||||
if getattr(payment, "external_id", None):
|
||||
lines.append(
|
||||
f"🧾 {texts.t('ADMIN_PAYMENT_GATEWAY_ID', 'Gateway ID')}: "
|
||||
f"{html.escape(str(payment.external_id))}"
|
||||
)
|
||||
|
||||
if _is_checkable(record):
|
||||
lines.append("")
|
||||
lines.append(texts.t("ADMIN_PAYMENT_CHECK_HINT", "ℹ️ You can trigger a manual status check."))
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _parse_method_and_id(payload: str, *, prefix: str) -> Optional[tuple[PaymentMethod, int]]:
|
||||
suffix = payload[len(prefix) :]
|
||||
try:
|
||||
method_str, identifier = suffix.rsplit("_", 1)
|
||||
method = PaymentMethod(method_str)
|
||||
payment_id = int(identifier)
|
||||
return method, payment_id
|
||||
except (ValueError, KeyError):
|
||||
return None
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_payments_overview(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
) -> None:
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
page = 1
|
||||
if callback.data.startswith("admin_payments_page_"):
|
||||
try:
|
||||
page = int(callback.data.split("_")[-1])
|
||||
except ValueError:
|
||||
page = 1
|
||||
|
||||
records = await list_recent_pending_payments(db)
|
||||
total = len(records)
|
||||
total_pages = max(1, math.ceil(total / PAGE_SIZE))
|
||||
if page < 1:
|
||||
page = 1
|
||||
if page > total_pages:
|
||||
page = total_pages
|
||||
|
||||
start_index = (page - 1) * PAGE_SIZE
|
||||
page_records = records[start_index : start_index + PAGE_SIZE]
|
||||
|
||||
header = texts.t("ADMIN_PAYMENTS_TITLE", "💳 <b>Top-up verification</b>")
|
||||
description = texts.t(
|
||||
"ADMIN_PAYMENTS_DESCRIPTION",
|
||||
"Pending invoices created during the last 24 hours.",
|
||||
)
|
||||
notice = texts.t(
|
||||
"ADMIN_PAYMENTS_NOTICE",
|
||||
"Only invoices younger than 24 hours and waiting for payment can be checked.",
|
||||
)
|
||||
|
||||
lines = [header, "", description]
|
||||
|
||||
if page_records:
|
||||
for idx, record in enumerate(page_records, start=start_index + 1):
|
||||
lines.extend(_build_record_lines(record, index=idx, texts=texts, language=db_user.language))
|
||||
lines.append("")
|
||||
lines.append(notice)
|
||||
else:
|
||||
empty_text = texts.t("ADMIN_PAYMENTS_EMPTY", "No pending top-ups in the last 24 hours.")
|
||||
lines.append("")
|
||||
lines.append(empty_text)
|
||||
|
||||
keyboard = _build_list_keyboard(
|
||||
page_records,
|
||||
page=page,
|
||||
total_pages=total_pages,
|
||||
language=db_user.language,
|
||||
)
|
||||
|
||||
await callback.message.edit_text(
|
||||
"\n".join(line for line in lines if line is not None),
|
||||
parse_mode="HTML",
|
||||
reply_markup=keyboard,
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
async def _render_payment_details(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
record: PendingPayment,
|
||||
) -> None:
|
||||
texts = get_texts(db_user.language)
|
||||
text = _build_payment_details_text(record, texts=texts, language=db_user.language)
|
||||
keyboard = _build_detail_keyboard(record, language=db_user.language)
|
||||
await callback.message.edit_text(text, parse_mode="HTML", reply_markup=keyboard)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_payment_details(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
) -> None:
|
||||
parsed = _parse_method_and_id(callback.data, prefix="admin_payment_")
|
||||
if not parsed:
|
||||
await callback.answer("❌ Invalid payment reference", show_alert=True)
|
||||
return
|
||||
|
||||
method, payment_id = parsed
|
||||
record = await get_payment_record(db, method, payment_id)
|
||||
if not record:
|
||||
await callback.answer("❌ Платеж не найден", show_alert=True)
|
||||
return
|
||||
|
||||
await _render_payment_details(callback, db_user, record)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def manual_check_payment(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
) -> None:
|
||||
parsed = _parse_method_and_id(callback.data, prefix="admin_payment_check_")
|
||||
if not parsed:
|
||||
await callback.answer("❌ Invalid payment reference", show_alert=True)
|
||||
return
|
||||
|
||||
method, payment_id = parsed
|
||||
record = await get_payment_record(db, method, payment_id)
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
if not record:
|
||||
await callback.answer(texts.t("ADMIN_PAYMENT_NOT_FOUND", "Payment not found."), show_alert=True)
|
||||
return
|
||||
|
||||
if not _is_checkable(record):
|
||||
await callback.answer(
|
||||
texts.t("ADMIN_PAYMENT_CHECK_NOT_AVAILABLE", "Manual check is not available for this invoice."),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
payment_service = PaymentService(callback.bot)
|
||||
updated = await run_manual_check(db, method, payment_id, payment_service)
|
||||
|
||||
if not updated:
|
||||
await callback.answer(
|
||||
texts.t("ADMIN_PAYMENT_CHECK_FAILED", "Failed to refresh the payment status."),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
await _render_payment_details(callback, db_user, updated)
|
||||
|
||||
if updated.status != record.status or updated.is_paid != record.is_paid:
|
||||
emoji, status_text = _status_info(updated, texts=texts)
|
||||
message = texts.t(
|
||||
"ADMIN_PAYMENT_CHECK_SUCCESS",
|
||||
"Status updated: {status}",
|
||||
).format(status=f"{emoji} {status_text}")
|
||||
else:
|
||||
message = texts.t(
|
||||
"ADMIN_PAYMENT_CHECK_NO_CHANGES",
|
||||
"Status is unchanged after the check.",
|
||||
)
|
||||
|
||||
await callback.answer(message, show_alert=True)
|
||||
|
||||
|
||||
def register_handlers(dp: Dispatcher) -> None:
|
||||
dp.callback_query.register(manual_check_payment, F.data.startswith("admin_payment_check_"))
|
||||
dp.callback_query.register(
|
||||
show_payment_details,
|
||||
F.data.startswith("admin_payment_") & ~F.data.startswith("admin_payment_check_"),
|
||||
)
|
||||
dp.callback_query.register(show_payments_overview, F.data.startswith("admin_payments_page_"))
|
||||
dp.callback_query.register(show_payments_overview, F.data == "admin_payments")
|
||||
File diff suppressed because it is too large
Load Diff
@@ -68,7 +68,6 @@ 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 '❌ Отключены'}
|
||||
|
||||
@@ -105,7 +104,6 @@ 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>
|
||||
@@ -190,7 +188,6 @@ 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}%
|
||||
|
||||
@@ -7,6 +7,7 @@ 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
|
||||
@@ -174,12 +175,24 @@ 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_display = (ticket.user.username or "отсутствует") if ticket.user else "отсутствует"
|
||||
username_value = ticket.user.username if ticket.user else None
|
||||
|
||||
ticket_text = f"🎫 Тикет #{ticket.id}\n\n"
|
||||
ticket_text += f"👤 Пользователь: {user_name}\n"
|
||||
ticket_text += f"🆔 Telegram ID: <code>{telegram_id_display}</code>\n"
|
||||
ticket_text += f"📱 Username: @{username_display}\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"📝 Заголовок: {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"
|
||||
@@ -221,15 +234,11 @@ async def view_admin_ticket(
|
||||
pass
|
||||
# Кнопки ЛС и профиль
|
||||
try:
|
||||
if ticket.user and ticket.user.telegram_id:
|
||||
if ticket.user and ticket.user.telegram_id and ticket.user.username:
|
||||
safe_username = html.escape(ticket.user.username)
|
||||
buttons_row = []
|
||||
# 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}"
|
||||
pm_url = f"tg://resolve?domain={safe_username}"
|
||||
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:
|
||||
@@ -750,7 +759,21 @@ 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\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"
|
||||
if updated.is_user_reply_blocked:
|
||||
if updated.user_reply_block_permanent:
|
||||
ticket_text += "🚫 Пользователь заблокирован навсегда для ответов в этом тикете\n"
|
||||
@@ -778,12 +801,10 @@ async def handle_admin_block_duration_input(
|
||||
pass
|
||||
# Кнопки ЛС и профиль при обновлении карточки
|
||||
try:
|
||||
if updated.user and updated.user.telegram_id:
|
||||
if updated.user and updated.user.telegram_id and updated.user.username:
|
||||
safe_username = html.escape(updated.user.username)
|
||||
buttons_row = []
|
||||
if updated.user.username:
|
||||
pm_url = f"tg://resolve?domain={updated.user.username}"
|
||||
else:
|
||||
pm_url = f"tg://user?id={updated.user.telegram_id}"
|
||||
pm_url = f"tg://resolve?domain={safe_username}"
|
||||
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))
|
||||
|
||||
+460
-13
@@ -1,6 +1,7 @@
|
||||
import logging
|
||||
import re
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional
|
||||
from typing import Optional, List, Tuple
|
||||
from aiogram import Dispatcher, types, F
|
||||
from aiogram.exceptions import TelegramForbiddenError, TelegramBadRequest
|
||||
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
|
||||
@@ -10,7 +11,12 @@ 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
|
||||
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.campaign import (
|
||||
get_campaign_registration_by_user,
|
||||
get_campaign_statistics,
|
||||
@@ -35,6 +41,9 @@ 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__)
|
||||
|
||||
@@ -1486,6 +1495,395 @@ 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 ""
|
||||
)
|
||||
items.append(
|
||||
texts.t(
|
||||
"ADMIN_USER_REFERRALS_LIST_ITEM",
|
||||
"• {name} (ID: <code>{telegram_id}</code>{username_part})",
|
||||
).format(
|
||||
name=referral.full_name,
|
||||
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,
|
||||
@@ -3338,7 +3736,15 @@ async def _grant_trial_subscription(db: AsyncSession, user_id: int, admin_id: in
|
||||
logger.error(f"У пользователя {user_id} уже есть подписка")
|
||||
return False
|
||||
|
||||
subscription = await create_trial_subscription(db, user_id)
|
||||
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_service = SubscriptionService()
|
||||
await subscription_service.create_remnawave_user(db, subscription)
|
||||
@@ -3382,12 +3788,20 @@ async def _grant_paid_subscription(db: AsyncSession, user_id: int, days: int, ad
|
||||
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
|
||||
|
||||
subscription = await create_paid_subscription(
|
||||
db=db,
|
||||
user_id=user_id,
|
||||
duration_days=days,
|
||||
traffic_limit_gb=settings.DEFAULT_TRAFFIC_LIMIT_GB,
|
||||
device_limit=settings.DEFAULT_DEVICE_LIMIT,
|
||||
device_limit=device_limit,
|
||||
connected_squads=trial_squads,
|
||||
update_server_counters=True,
|
||||
)
|
||||
@@ -3594,7 +4008,9 @@ async def admin_buy_subscription(
|
||||
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 or settings.DEFAULT_DEVICE_LIMIT
|
||||
devices_limit = subscription.device_limit
|
||||
if devices_limit is None:
|
||||
devices_limit = settings.DEFAULT_DEVICE_LIMIT
|
||||
servers_count = len(subscription.connected_squads or [])
|
||||
text += f"📶 Трафик: {traffic_text}\n"
|
||||
text += f"📱 Устройства: {devices_limit}\n"
|
||||
@@ -3685,7 +4101,9 @@ async def admin_buy_subscription_confirm(
|
||||
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 or settings.DEFAULT_DEVICE_LIMIT
|
||||
devices_limit = subscription.device_limit
|
||||
if devices_limit is None:
|
||||
devices_limit = settings.DEFAULT_DEVICE_LIMIT
|
||||
servers_count = len(subscription.connected_squads or [])
|
||||
text += f"📶 Трафик: {traffic_text}\n"
|
||||
text += f"📱 Устройства: {devices_limit}\n"
|
||||
@@ -3832,40 +4250,54 @@ 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:
|
||||
remnawave_user = await api.update_user(
|
||||
update_kwargs = dict(
|
||||
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 = f"user_{target_user.telegram_id}"
|
||||
username = settings.format_remnawave_username(
|
||||
full_name=target_user.full_name,
|
||||
username=target_user.username,
|
||||
telegram_id=target_user.telegram_id,
|
||||
)
|
||||
async with remnawave_service.get_api_client() as api:
|
||||
remnawave_user = await api.create_user(
|
||||
create_kwargs = dict(
|
||||
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
|
||||
@@ -4122,6 +4554,21 @@ 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_")
|
||||
|
||||
@@ -203,8 +203,56 @@ async def check_heleket_payment_status(
|
||||
await callback.answer("Платёж не найден", show_alert=True)
|
||||
return
|
||||
|
||||
language = getattr(payment.user, "language", None) or settings.DEFAULT_LANGUAGE
|
||||
texts = get_texts(language)
|
||||
|
||||
if payment.is_paid:
|
||||
await callback.answer("✅ Платёж уже оплачен", show_alert=True)
|
||||
message = texts.t("HELEKET_PAYMENT_ALREADY_PAID", "✅ Платёж уже зачислен")
|
||||
await callback.answer(message, show_alert=True)
|
||||
return
|
||||
|
||||
await callback.answer("Платёж ещё не оплачен", show_alert=True)
|
||||
payment_service = PaymentService(callback.bot)
|
||||
updated_payment = await payment_service.sync_heleket_payment_status(
|
||||
db,
|
||||
local_payment_id=local_payment_id,
|
||||
)
|
||||
|
||||
if updated_payment:
|
||||
payment = updated_payment
|
||||
|
||||
if payment.is_paid:
|
||||
message = texts.t("HELEKET_PAYMENT_SUCCESS", "✅ Платёж зачислен на баланс")
|
||||
await callback.answer(message, show_alert=True)
|
||||
return
|
||||
|
||||
status_normalized = (payment.status or "").lower()
|
||||
status_messages = {
|
||||
"check": texts.t("HELEKET_STATUS_CHECK", "⏳ Ожидание оплаты"),
|
||||
"process": texts.t("HELEKET_STATUS_PROCESS", "⚙️ Платёж обрабатывается"),
|
||||
"confirm_check": texts.t("HELEKET_STATUS_CONFIRM_CHECK", "⛓ Ожидание подтверждений сети"),
|
||||
"wrong_amount": texts.t("HELEKET_STATUS_WRONG_AMOUNT", "❗️ Оплачена неверная сумма"),
|
||||
"wrong_amount_waiting": texts.t(
|
||||
"HELEKET_STATUS_WRONG_AMOUNT_WAITING",
|
||||
"❗️ Недостаточная сумма, ожидаем доплату",
|
||||
),
|
||||
"paid_over": texts.t("HELEKET_STATUS_PAID_OVER", "✅ Платёж зачислен (с переплатой)"),
|
||||
"paid": texts.t("HELEKET_STATUS_PAID", "✅ Платёж зачислен"),
|
||||
"cancel": texts.t("HELEKET_STATUS_CANCEL", "🚫 Платёж отменён"),
|
||||
"fail": texts.t("HELEKET_STATUS_FAIL", "❌ Ошибка при оплате"),
|
||||
"system_fail": texts.t("HELEKET_STATUS_SYSTEM_FAIL", "❌ Системная ошибка Heleket"),
|
||||
"refund_process": texts.t("HELEKET_STATUS_REFUND_PROCESS", "↩️ Возврат обрабатывается"),
|
||||
"refund_fail": texts.t("HELEKET_STATUS_REFUND_FAIL", "⚠️ Ошибка возврата"),
|
||||
"refund_paid": texts.t("HELEKET_STATUS_REFUND_PAID", "✅ Возврат выполнен"),
|
||||
"locked": texts.t("HELEKET_STATUS_LOCKED", "🔒 Средства заблокированы"),
|
||||
}
|
||||
|
||||
message = status_messages.get(status_normalized)
|
||||
if message is None:
|
||||
template = texts.t("HELEKET_STATUS_UNKNOWN", "ℹ️ Статус платежа: {status}")
|
||||
status_value = payment.status or status_normalized or "—"
|
||||
try:
|
||||
message = template.format(status=status_value)
|
||||
except Exception: # pragma: no cover - defensive formatting
|
||||
message = f"ℹ️ Статус платежа: {status_value}"
|
||||
|
||||
await callback.answer(message, show_alert=True)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import html
|
||||
import logging
|
||||
from aiogram import Dispatcher, types, F
|
||||
from aiogram.exceptions import TelegramBadRequest
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
@@ -182,15 +183,36 @@ async def show_payment_methods(
|
||||
state: FSMContext
|
||||
):
|
||||
from app.utils.payment_utils import get_payment_methods_text
|
||||
|
||||
|
||||
texts = get_texts(db_user.language)
|
||||
payment_text = get_payment_methods_text(db_user.language)
|
||||
|
||||
await callback.message.edit_text(
|
||||
payment_text,
|
||||
reply_markup=get_payment_methods_keyboard(0, db_user.language),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
|
||||
keyboard = get_payment_methods_keyboard(0, db_user.language)
|
||||
|
||||
try:
|
||||
await callback.message.edit_text(
|
||||
payment_text,
|
||||
reply_markup=keyboard,
|
||||
parse_mode="HTML"
|
||||
)
|
||||
except TelegramBadRequest:
|
||||
try:
|
||||
await callback.message.edit_caption(
|
||||
payment_text,
|
||||
reply_markup=keyboard,
|
||||
parse_mode="HTML"
|
||||
)
|
||||
except TelegramBadRequest:
|
||||
try:
|
||||
await callback.message.delete()
|
||||
except TelegramBadRequest:
|
||||
pass
|
||||
await callback.message.answer(
|
||||
payment_text,
|
||||
reply_markup=keyboard,
|
||||
parse_mode="HTML"
|
||||
)
|
||||
|
||||
await callback.answer()
|
||||
|
||||
|
||||
|
||||
@@ -38,6 +38,7 @@ 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__)
|
||||
@@ -952,7 +953,8 @@ def _get_subscription_status(user: User, texts) -> str:
|
||||
|
||||
current_time = datetime.utcnow()
|
||||
actual_status = (subscription.actual_status or "").lower()
|
||||
end_date_text = subscription.end_date.strftime("%d.%m.%Y")
|
||||
end_date = getattr(subscription, "end_date", None)
|
||||
end_date_text = format_local_datetime(end_date, "%d.%m.%Y") if end_date else None
|
||||
days_left = 0
|
||||
|
||||
if subscription.end_date > current_time:
|
||||
@@ -968,10 +970,10 @@ def _get_subscription_status(user: User, texts) -> str:
|
||||
return texts.t(
|
||||
"SUB_STATUS_EXPIRED",
|
||||
"🔴 Истекла\n📅 {end_date}",
|
||||
).format(end_date=end_date_text)
|
||||
).format(end_date=end_date_text or "—")
|
||||
|
||||
if actual_status == "trial":
|
||||
if days_left > 1:
|
||||
if days_left > 1 and end_date_text:
|
||||
return texts.t(
|
||||
"SUB_STATUS_TRIAL_ACTIVE",
|
||||
"🎁 Тестовая подписка\n📅 до {end_date} ({days} дн.)",
|
||||
@@ -990,7 +992,7 @@ def _get_subscription_status(user: User, texts) -> str:
|
||||
)
|
||||
|
||||
if actual_status == "active":
|
||||
if days_left > 7:
|
||||
if days_left > 7 and end_date_text:
|
||||
return texts.t(
|
||||
"SUB_STATUS_ACTIVE_LONG",
|
||||
"💎 Активна\n📅 до {end_date} ({days} дн.)",
|
||||
|
||||
@@ -0,0 +1,256 @@
|
||||
import asyncio
|
||||
import logging
|
||||
from datetime import datetime
|
||||
|
||||
from aiogram import Dispatcher, F, types
|
||||
from aiogram.exceptions import TelegramBadRequest
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.crud.poll import (
|
||||
get_poll_response_by_id,
|
||||
record_poll_answer,
|
||||
)
|
||||
from app.database.models import PollQuestion, User
|
||||
from app.localization.texts import get_texts
|
||||
from app.services.poll_service import get_next_question, get_question_option, reward_user_for_poll
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def _delete_message_later(bot, chat_id: int, message_id: int, delay: int = 10) -> None:
|
||||
try:
|
||||
await asyncio.sleep(delay)
|
||||
await bot.delete_message(chat_id, message_id)
|
||||
except Exception as error: # pragma: no cover - cleanup best effort
|
||||
logger.debug("Не удалось удалить сообщение опроса %s: %s", message_id, error)
|
||||
|
||||
|
||||
async def _render_question_text(
|
||||
poll_title: str,
|
||||
question: PollQuestion,
|
||||
current_index: int,
|
||||
total: int,
|
||||
language: str,
|
||||
) -> str:
|
||||
texts = get_texts(language)
|
||||
header = texts.t("POLL_QUESTION_HEADER", "<b>Вопрос {current}/{total}</b>").format(
|
||||
current=current_index,
|
||||
total=total,
|
||||
)
|
||||
lines = [f"🗳️ <b>{poll_title}</b>", "", header, "", question.text]
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
async def _update_poll_message(
|
||||
message: types.Message,
|
||||
text: str,
|
||||
*,
|
||||
reply_markup: types.InlineKeyboardMarkup | None = None,
|
||||
parse_mode: str | None = "HTML",
|
||||
) -> bool:
|
||||
try:
|
||||
await message.edit_text(
|
||||
text,
|
||||
reply_markup=reply_markup,
|
||||
parse_mode=parse_mode,
|
||||
)
|
||||
return True
|
||||
except TelegramBadRequest as error:
|
||||
error_text = str(error).lower()
|
||||
if "message is not modified" in error_text:
|
||||
logger.debug(
|
||||
"Опросное сообщение уже актуально, пропускаем обновление: %s",
|
||||
error,
|
||||
)
|
||||
return True
|
||||
|
||||
logger.warning(
|
||||
"Не удалось обновить сообщение опроса %s: %s",
|
||||
message.message_id,
|
||||
error,
|
||||
)
|
||||
except Exception as error: # pragma: no cover - defensive logging
|
||||
logger.exception(
|
||||
"Непредвиденная ошибка при обновлении сообщения опроса %s: %s",
|
||||
message.message_id,
|
||||
error,
|
||||
)
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def _build_options_keyboard(response_id: int, question: PollQuestion) -> types.InlineKeyboardMarkup:
|
||||
buttons: list[list[types.InlineKeyboardButton]] = []
|
||||
for option in sorted(question.options, key=lambda o: o.order):
|
||||
buttons.append(
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=option.text,
|
||||
callback_data=f"poll_answer:{response_id}:{question.id}:{option.id}",
|
||||
)
|
||||
]
|
||||
)
|
||||
return types.InlineKeyboardMarkup(inline_keyboard=buttons)
|
||||
|
||||
|
||||
async def handle_poll_start(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
):
|
||||
try:
|
||||
response_id = int(callback.data.split(":")[1])
|
||||
except (IndexError, ValueError):
|
||||
await callback.answer("❌ Опрос не найден", show_alert=True)
|
||||
return
|
||||
|
||||
response = await get_poll_response_by_id(db, response_id)
|
||||
if not response or response.user_id != db_user.id:
|
||||
await callback.answer("❌ Опрос не найден", show_alert=True)
|
||||
return
|
||||
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
if response.completed_at:
|
||||
await callback.answer(texts.t("POLL_ALREADY_COMPLETED", "Вы уже прошли этот опрос."), show_alert=True)
|
||||
return
|
||||
|
||||
if not response.poll or not response.poll.questions:
|
||||
await callback.answer(texts.t("POLL_EMPTY", "Опрос пока недоступен."), show_alert=True)
|
||||
return
|
||||
|
||||
if not response.started_at:
|
||||
response.started_at = datetime.utcnow()
|
||||
await db.commit()
|
||||
|
||||
index, question = await get_next_question(response)
|
||||
if not question:
|
||||
await callback.answer(texts.t("POLL_ERROR", "Не удалось загрузить вопросы."), show_alert=True)
|
||||
return
|
||||
|
||||
question_text = await _render_question_text(
|
||||
response.poll.title,
|
||||
question,
|
||||
index,
|
||||
len(response.poll.questions),
|
||||
db_user.language,
|
||||
)
|
||||
|
||||
if not await _update_poll_message(
|
||||
callback.message,
|
||||
question_text,
|
||||
reply_markup=_build_options_keyboard(response.id, question),
|
||||
):
|
||||
await callback.answer(texts.t("POLL_ERROR", "Не удалось показать вопрос."), show_alert=True)
|
||||
return
|
||||
await callback.answer()
|
||||
|
||||
|
||||
async def handle_poll_answer(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
):
|
||||
try:
|
||||
_, response_id, question_id, option_id = callback.data.split(":", 3)
|
||||
response_id = int(response_id)
|
||||
question_id = int(question_id)
|
||||
option_id = int(option_id)
|
||||
except (ValueError, IndexError):
|
||||
await callback.answer("❌ Некорректные данные", show_alert=True)
|
||||
return
|
||||
|
||||
response = await get_poll_response_by_id(db, response_id)
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
if not response or response.user_id != db_user.id:
|
||||
await callback.answer("❌ Опрос не найден", show_alert=True)
|
||||
return
|
||||
|
||||
if not response.poll:
|
||||
await callback.answer(texts.t("POLL_ERROR", "Опрос недоступен."), show_alert=True)
|
||||
return
|
||||
|
||||
if response.completed_at:
|
||||
await callback.answer(texts.t("POLL_ALREADY_COMPLETED", "Вы уже прошли этот опрос."), show_alert=True)
|
||||
return
|
||||
|
||||
question = next((q for q in response.poll.questions if q.id == question_id), None)
|
||||
if not question:
|
||||
await callback.answer(texts.t("POLL_ERROR", "Вопрос не найден."), show_alert=True)
|
||||
return
|
||||
|
||||
option = await get_question_option(question, option_id)
|
||||
if not option:
|
||||
await callback.answer(texts.t("POLL_ERROR", "Вариант ответа не найден."), show_alert=True)
|
||||
return
|
||||
|
||||
await record_poll_answer(
|
||||
db,
|
||||
response_id=response.id,
|
||||
question_id=question.id,
|
||||
option_id=option.id,
|
||||
)
|
||||
|
||||
try:
|
||||
await db.refresh(response, attribute_names=["answers"])
|
||||
except Exception as error: # pragma: no cover - defensive cache busting
|
||||
logger.debug(
|
||||
"Не удалось обновить локальные ответы опроса %s: %s",
|
||||
response.id,
|
||||
error,
|
||||
)
|
||||
response = await get_poll_response_by_id(db, response.id)
|
||||
if not response:
|
||||
await callback.answer(texts.t("POLL_ERROR", "Опрос недоступен."), show_alert=True)
|
||||
return
|
||||
index, next_question = await get_next_question(response)
|
||||
|
||||
if next_question:
|
||||
question_text = await _render_question_text(
|
||||
response.poll.title,
|
||||
next_question,
|
||||
index,
|
||||
len(response.poll.questions),
|
||||
db_user.language,
|
||||
)
|
||||
if not await _update_poll_message(
|
||||
callback.message,
|
||||
question_text,
|
||||
reply_markup=_build_options_keyboard(response.id, next_question),
|
||||
):
|
||||
await callback.answer(texts.t("POLL_ERROR", "Не удалось показать вопрос."), show_alert=True)
|
||||
return
|
||||
await callback.answer()
|
||||
return
|
||||
|
||||
response.completed_at = datetime.utcnow()
|
||||
await db.commit()
|
||||
|
||||
reward_amount = await reward_user_for_poll(db, response)
|
||||
|
||||
thanks_lines = [texts.t("POLL_COMPLETED", "🙏 Спасибо за участие в опросе!")]
|
||||
if reward_amount:
|
||||
thanks_lines.append(
|
||||
texts.t(
|
||||
"POLL_REWARD_GRANTED",
|
||||
"Награда {amount} зачислена на ваш баланс.",
|
||||
).format(amount=settings.format_price(reward_amount))
|
||||
)
|
||||
|
||||
if not await _update_poll_message(
|
||||
callback.message,
|
||||
"\n\n".join(thanks_lines),
|
||||
):
|
||||
await callback.answer(texts.t("POLL_COMPLETED", "🙏 Спасибо за участие в опросе!"))
|
||||
return
|
||||
asyncio.create_task(
|
||||
_delete_message_later(callback.bot, callback.message.chat.id, callback.message.message_id)
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
def register_handlers(dp: Dispatcher):
|
||||
dp.callback_query.register(handle_poll_start, F.data.startswith("poll_start:"))
|
||||
dp.callback_query.register(handle_poll_answer, F.data.startswith("poll_answer:"))
|
||||
+208
-131
@@ -2,7 +2,7 @@
|
||||
import html
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Optional, Dict, Any
|
||||
from typing import Any, Dict, Optional, Tuple
|
||||
from aiogram import types, F
|
||||
from aiogram.exceptions import TelegramBadRequest
|
||||
from aiogram.fsm.context import FSMContext
|
||||
@@ -16,7 +16,11 @@ 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
|
||||
from app.utils.subscription_utils import (
|
||||
get_display_subscription_link,
|
||||
resolve_simple_subscription_device_limit,
|
||||
)
|
||||
from app.utils.pricing_utils import compute_simple_subscription_price
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -34,26 +38,24 @@ 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": settings.SIMPLE_SUBSCRIPTION_DEVICE_LIMIT,
|
||||
"device_limit": device_limit,
|
||||
"traffic_limit_gb": settings.SIMPLE_SUBSCRIPTION_TRAFFIC_GB,
|
||||
"squad_uuid": settings.SIMPLE_SUBSCRIPTION_SQUAD_UUID
|
||||
}
|
||||
|
||||
# Сохраняем параметры в состояние
|
||||
await state.update_data(subscription_params=subscription_params)
|
||||
|
||||
# Проверяем баланс пользователя
|
||||
user_balance_kopeks = getattr(db_user, "balance_kopeks", 0)
|
||||
# Рассчитываем цену подписки
|
||||
price_kopeks = _calculate_simple_subscription_price(subscription_params)
|
||||
|
||||
data = await state.get_data()
|
||||
resolved_squad_uuid = await _ensure_simple_subscription_squad_uuid(
|
||||
db,
|
||||
@@ -62,39 +64,38 @@ async def start_simple_subscription_purchase(
|
||||
user_id=db_user.id,
|
||||
state_data=data,
|
||||
)
|
||||
period_days = subscription_params["period_days"]
|
||||
recorded_price = getattr(settings, f"PRICE_{period_days}_DAYS", price_kopeks)
|
||||
direct_purchase_min_balance = recorded_price
|
||||
extra_components = []
|
||||
traffic_limit = subscription_params.get("traffic_limit_gb", 0)
|
||||
if traffic_limit and traffic_limit > 0:
|
||||
traffic_price = settings.get_traffic_price(traffic_limit)
|
||||
direct_purchase_min_balance += traffic_price
|
||||
extra_components.append(f"traffic={traffic_limit}GB->{traffic_price}")
|
||||
|
||||
device_limit = subscription_params.get("device_limit", 1)
|
||||
if device_limit and device_limit > settings.DEFAULT_DEVICE_LIMIT:
|
||||
additional_devices = device_limit - settings.DEFAULT_DEVICE_LIMIT
|
||||
devices_price = additional_devices * settings.PRICE_PER_DEVICE
|
||||
direct_purchase_min_balance += devices_price
|
||||
extra_components.append(f"devices+{additional_devices}->{devices_price}")
|
||||
logger.warning(
|
||||
"SIMPLE_SUBSCRIPTION_DEBUG_START | user=%s | period=%s | base_price=%s | recorded_price=%s | extras=%s | total=%s | env_PRICE_30=%s",
|
||||
db_user.id,
|
||||
period_days,
|
||||
price_kopeks,
|
||||
recorded_price,
|
||||
",".join(extra_components) if extra_components else "none",
|
||||
direct_purchase_min_balance,
|
||||
getattr(settings, "PRICE_30_DAYS", None),
|
||||
price_kopeks, price_breakdown = await _calculate_simple_subscription_price(
|
||||
db,
|
||||
subscription_params,
|
||||
user=db_user,
|
||||
resolved_squad_uuid=resolved_squad_uuid,
|
||||
)
|
||||
|
||||
can_pay_from_balance = user_balance_kopeks >= direct_purchase_min_balance
|
||||
period_days = subscription_params["period_days"]
|
||||
user_balance_kopeks = getattr(db_user, "balance_kopeks", 0)
|
||||
|
||||
logger.warning(
|
||||
"SIMPLE_SUBSCRIPTION_DEBUG_START | user=%s | period=%s | base=%s | traffic=%s | devices=%s | servers=%s | discount=%s | total=%s | squads=%s",
|
||||
db_user.id,
|
||||
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),
|
||||
price_kopeks,
|
||||
",".join(price_breakdown.get("resolved_squad_uuids", []))
|
||||
if price_breakdown.get("resolved_squad_uuids")
|
||||
else "none",
|
||||
)
|
||||
|
||||
can_pay_from_balance = user_balance_kopeks >= price_kopeks
|
||||
logger.warning(
|
||||
"SIMPLE_SUBSCRIPTION_DEBUG_START_BALANCE | user=%s | balance=%s | min_required=%s | can_pay=%s",
|
||||
db_user.id,
|
||||
user_balance_kopeks,
|
||||
direct_purchase_min_balance,
|
||||
price_kopeks,
|
||||
can_pay_from_balance,
|
||||
)
|
||||
|
||||
@@ -115,20 +116,32 @@ async def start_simple_subscription_purchase(
|
||||
subscription_params,
|
||||
resolved_squad_uuid,
|
||||
)
|
||||
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"
|
||||
+ (
|
||||
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 can_pay_from_balance
|
||||
else "Баланс пока недостаточный для мгновенной оплаты. Выберите подходящий способ оплаты:"
|
||||
)
|
||||
)
|
||||
),
|
||||
])
|
||||
|
||||
message_text = "\n".join(message_lines)
|
||||
|
||||
if trial_notice:
|
||||
message_text = f"{trial_notice}\n\n{message_text}"
|
||||
@@ -158,27 +171,23 @@ async def start_simple_subscription_purchase(
|
||||
await callback.answer()
|
||||
|
||||
|
||||
def _calculate_simple_subscription_price(params: dict) -> int:
|
||||
async def _calculate_simple_subscription_price(
|
||||
db: AsyncSession,
|
||||
params: dict,
|
||||
*,
|
||||
user: Optional[User] = None,
|
||||
resolved_squad_uuid: Optional[str] = None,
|
||||
) -> Tuple[int, Dict[str, Any]]:
|
||||
"""Рассчитывает цену простой подписки."""
|
||||
period_days = params.get("period_days", 30)
|
||||
attr_name = f"PRICE_{period_days}_DAYS"
|
||||
attr_value = getattr(settings, attr_name, None)
|
||||
|
||||
logger.warning(
|
||||
"SIMPLE_SUBSCRIPTION_DEBUG_PRICE_FUNC | period=%s | attr=%s | attr_value=%s | base_price=%s",
|
||||
period_days,
|
||||
attr_name,
|
||||
attr_value,
|
||||
settings.BASE_SUBSCRIPTION_PRICE,
|
||||
resolved_uuids = [resolved_squad_uuid] if resolved_squad_uuid else None
|
||||
return await compute_simple_subscription_price(
|
||||
db,
|
||||
params,
|
||||
user=user,
|
||||
resolved_squad_uuids=resolved_uuids,
|
||||
)
|
||||
|
||||
# Получаем цену для стандартного периода
|
||||
if attr_value is not None:
|
||||
return attr_value
|
||||
else:
|
||||
# Если нет цены для конкретного периода, используем базовую цену
|
||||
return settings.BASE_SUBSCRIPTION_PRICE
|
||||
|
||||
|
||||
def _get_simple_subscription_payment_keyboard(language: str) -> types.InlineKeyboardMarkup:
|
||||
"""Создает клавиатуру с методами оплаты для простой подписки."""
|
||||
@@ -335,27 +344,22 @@ async def handle_simple_subscription_pay_with_balance(
|
||||
)
|
||||
|
||||
# Рассчитываем цену подписки
|
||||
price_kopeks = _calculate_simple_subscription_price(subscription_params)
|
||||
recorded_price = getattr(settings, f"PRICE_{subscription_params['period_days']}_DAYS", price_kopeks)
|
||||
total_required = recorded_price
|
||||
extras = []
|
||||
traffic_limit = subscription_params.get("traffic_limit_gb", 0)
|
||||
if traffic_limit and traffic_limit > 0:
|
||||
traffic_price = settings.get_traffic_price(traffic_limit)
|
||||
total_required += traffic_price
|
||||
extras.append(f"traffic={traffic_limit}GB->{traffic_price}")
|
||||
device_limit = subscription_params.get("device_limit", 1)
|
||||
if device_limit and device_limit > settings.DEFAULT_DEVICE_LIMIT:
|
||||
additional_devices = device_limit - settings.DEFAULT_DEVICE_LIMIT
|
||||
devices_price = additional_devices * settings.PRICE_PER_DEVICE
|
||||
total_required += devices_price
|
||||
extras.append(f"devices+{additional_devices}->{devices_price}")
|
||||
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_PAY_BALANCE | user=%s | period=%s | base_price=%s | extras=%s | total_required=%s | balance=%s",
|
||||
"SIMPLE_SUBSCRIPTION_DEBUG_PAY_BALANCE | 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_kopeks,
|
||||
",".join(extras) if extras else "none",
|
||||
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),
|
||||
)
|
||||
@@ -446,16 +450,28 @@ async def handle_simple_subscription_pay_with_balance(
|
||||
subscription_params,
|
||||
resolved_squad_uuid,
|
||||
)
|
||||
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"🔗 Для подключения перейдите в раздел 'Подключиться'"
|
||||
)
|
||||
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)
|
||||
@@ -595,47 +611,68 @@ async def handle_simple_subscription_other_payment_methods(
|
||||
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 = _calculate_simple_subscription_price(subscription_params)
|
||||
price_kopeks, price_breakdown = await _calculate_simple_subscription_price(
|
||||
db,
|
||||
subscription_params,
|
||||
user=db_user,
|
||||
resolved_squad_uuid=resolved_squad_uuid,
|
||||
)
|
||||
|
||||
user_balance_kopeks = getattr(db_user, "balance_kopeks", 0)
|
||||
recorded_price = getattr(settings, f"PRICE_{subscription_params['period_days']}_DAYS", price_kopeks)
|
||||
total_required = recorded_price
|
||||
if subscription_params.get("traffic_limit_gb", 0) > 0:
|
||||
total_required += settings.get_traffic_price(subscription_params["traffic_limit_gb"])
|
||||
if subscription_params.get("device_limit", 1) > settings.DEFAULT_DEVICE_LIMIT:
|
||||
additional_devices = subscription_params["device_limit"] - settings.DEFAULT_DEVICE_LIMIT
|
||||
total_required += additional_devices * settings.PRICE_PER_DEVICE
|
||||
can_pay_from_balance = user_balance_kopeks >= total_required
|
||||
can_pay_from_balance = user_balance_kopeks >= price_kopeks
|
||||
logger.warning(
|
||||
"SIMPLE_SUBSCRIPTION_DEBUG_METHODS | user=%s | balance=%s | base_price=%s | total_required=%s | can_pay=%s",
|
||||
"SIMPLE_SUBSCRIPTION_DEBUG_METHODS | user=%s | balance=%s | base=%s | traffic=%s | devices=%s | servers=%s | discount=%s | total_required=%s | can_pay=%s",
|
||||
db_user.id,
|
||||
user_balance_kopeks,
|
||||
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),
|
||||
price_kopeks,
|
||||
total_required,
|
||||
can_pay_from_balance,
|
||||
)
|
||||
|
||||
# Отображаем доступные методы оплаты
|
||||
resolved_squad_uuid = data.get("resolved_squad_uuid")
|
||||
server_label = _get_simple_subscription_server_label(
|
||||
texts,
|
||||
subscription_params,
|
||||
resolved_squad_uuid,
|
||||
)
|
||||
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"
|
||||
+ (
|
||||
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)}",
|
||||
"",
|
||||
(
|
||||
"Вы можете оплатить подписку с баланса или выбрать другой способ оплаты:"
|
||||
if can_pay_from_balance
|
||||
else "Выберите подходящий способ оплаты:"
|
||||
)
|
||||
)
|
||||
),
|
||||
])
|
||||
|
||||
message_text = "\n".join(message_lines)
|
||||
|
||||
base_keyboard = _get_simple_subscription_payment_keyboard(db_user.language)
|
||||
keyboard_rows = []
|
||||
@@ -677,18 +714,47 @@ async def handle_simple_subscription_payment_method(
|
||||
await callback.answer("❌ Данные подписки устарели. Пожалуйста, начните сначала.", show_alert=True)
|
||||
return
|
||||
|
||||
# Рассчитываем цену подписки
|
||||
price_kopeks = _calculate_simple_subscription_price(subscription_params)
|
||||
|
||||
payment_method = callback.data.replace("simple_subscription_", "")
|
||||
|
||||
|
||||
try:
|
||||
payment_service = PaymentService(callback.bot)
|
||||
|
||||
purchase_service = SubscriptionPurchaseService()
|
||||
|
||||
resolved_squad_uuid = await _ensure_simple_subscription_squad_uuid(
|
||||
db,
|
||||
state,
|
||||
subscription_params,
|
||||
user_id=db_user.id,
|
||||
state_data=data,
|
||||
)
|
||||
|
||||
# Рассчитываем цену подписки
|
||||
price_kopeks, _ = await _calculate_simple_subscription_price(
|
||||
db,
|
||||
subscription_params,
|
||||
user=db_user,
|
||||
resolved_squad_uuid=resolved_squad_uuid,
|
||||
)
|
||||
|
||||
if payment_method == "stars":
|
||||
# Оплата через Telegram Stars
|
||||
order = await purchase_service.create_subscription_order(
|
||||
db=db,
|
||||
user_id=db_user.id,
|
||||
period_days=subscription_params["period_days"],
|
||||
device_limit=subscription_params["device_limit"],
|
||||
traffic_limit_gb=subscription_params["traffic_limit_gb"],
|
||||
squad_uuid=resolved_squad_uuid,
|
||||
payment_method="telegram_stars",
|
||||
total_price_kopeks=price_kopeks,
|
||||
)
|
||||
|
||||
if not order:
|
||||
await callback.answer("❌ Не удалось подготовить заказ. Попробуйте позже.", show_alert=True)
|
||||
return
|
||||
|
||||
stars_count = settings.rubles_to_stars(settings.kopeks_to_rubles(price_kopeks))
|
||||
|
||||
|
||||
await callback.bot.send_invoice(
|
||||
chat_id=callback.from_user.id,
|
||||
title=f"Подписка на {subscription_params['period_days']} дней",
|
||||
@@ -698,7 +764,9 @@ async def handle_simple_subscription_payment_method(
|
||||
f"Устройства: {subscription_params['device_limit']}\n"
|
||||
f"Трафик: {'Безлимит' if subscription_params['traffic_limit_gb'] == 0 else f'{subscription_params['traffic_limit_gb']} ГБ'}"
|
||||
),
|
||||
payload=f"simple_sub_{db_user.id}_{subscription_params['period_days']}",
|
||||
payload=(
|
||||
f"simple_sub_{db_user.id}_{order.id}_{subscription_params['period_days']}"
|
||||
),
|
||||
provider_token="", # Пустой токен для Telegram Stars
|
||||
currency="XTR", # Telegram Stars
|
||||
prices=[types.LabeledPrice(label="Подписка", amount=stars_count)]
|
||||
@@ -718,8 +786,6 @@ async def handle_simple_subscription_payment_method(
|
||||
return
|
||||
|
||||
# Создаем заказ на подписку
|
||||
purchase_service = SubscriptionPurchaseService()
|
||||
|
||||
order = await purchase_service.create_subscription_order(
|
||||
db=db,
|
||||
user_id=db_user.id,
|
||||
@@ -829,14 +895,25 @@ async def handle_simple_subscription_payment_method(
|
||||
keyboard = types.InlineKeyboardMarkup(inline_keyboard=keyboard_buttons)
|
||||
|
||||
# Подготавливаем текст сообщения
|
||||
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"
|
||||
)
|
||||
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)
|
||||
|
||||
# Добавляем инструкции в зависимости от доступных способов оплаты
|
||||
if not confirmation_url:
|
||||
|
||||
@@ -20,7 +20,7 @@ async def handle_pre_checkout_query(query: types.PreCheckoutQuery):
|
||||
try:
|
||||
logger.info(f"📋 Pre-checkout query от {query.from_user.id}: {query.total_amount} XTR, payload: {query.invoice_payload}")
|
||||
|
||||
allowed_prefixes = ("balance_", "admin_stars_test_")
|
||||
allowed_prefixes = ("balance_", "admin_stars_test_", "simple_sub_")
|
||||
|
||||
if not query.invoice_payload or not query.invoice_payload.startswith(allowed_prefixes):
|
||||
logger.warning(f"Невалидный payload: {query.invoice_payload}")
|
||||
|
||||
+59
-39
@@ -37,6 +37,7 @@ 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
|
||||
|
||||
@@ -44,6 +45,17 @@ 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,
|
||||
@@ -224,7 +236,11 @@ 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
|
||||
@@ -240,7 +256,7 @@ async def cmd_start(message: types.Message, state: FSMContext, db: AsyncSession,
|
||||
pending_start_payload,
|
||||
)
|
||||
|
||||
if pending_start_payload is not None:
|
||||
if state_needs_update:
|
||||
await state.set_data(data)
|
||||
|
||||
if start_parameter:
|
||||
@@ -266,7 +282,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:
|
||||
if campaign and not campaign_notification_sent:
|
||||
try:
|
||||
notification_service = AdminNotificationService(message.bot)
|
||||
await notification_service.send_campaign_link_visit_notification(
|
||||
@@ -338,11 +354,9 @@ async def cmd_start(message: types.Message, state: FSMContext, db: AsyncSession,
|
||||
f"Ошибка отправки уведомления о рекламной кампании: {e}"
|
||||
)
|
||||
|
||||
has_active_subscription = user.subscription is not None
|
||||
subscription_is_active = False
|
||||
|
||||
if user.subscription:
|
||||
subscription_is_active = user.subscription.is_active
|
||||
has_active_subscription, subscription_is_active = _calculate_subscription_flags(
|
||||
user.subscription
|
||||
)
|
||||
|
||||
menu_text = await get_main_menu_text(user, texts, db)
|
||||
|
||||
@@ -761,11 +775,9 @@ async def complete_registration_from_callback(
|
||||
|
||||
await db.refresh(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
|
||||
has_active_subscription, subscription_is_active = _calculate_subscription_flags(
|
||||
existing_user.subscription
|
||||
)
|
||||
|
||||
menu_text = await get_main_menu_text(existing_user, texts, db)
|
||||
|
||||
@@ -943,11 +955,9 @@ async def complete_registration_from_callback(
|
||||
else:
|
||||
logger.info(f"ℹ️ Приветственные сообщения отключены, показываем главное меню для пользователя {user.telegram_id}")
|
||||
|
||||
has_active_subscription = bool(getattr(user, "subscription", None))
|
||||
subscription_is_active = False
|
||||
|
||||
if getattr(user, "subscription", None):
|
||||
subscription_is_active = user.subscription.is_active
|
||||
has_active_subscription, subscription_is_active = _calculate_subscription_flags(
|
||||
getattr(user, "subscription", None)
|
||||
)
|
||||
|
||||
menu_text = await get_main_menu_text(user, texts, db)
|
||||
|
||||
@@ -1019,11 +1029,9 @@ async def complete_registration(
|
||||
|
||||
await db.refresh(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
|
||||
has_active_subscription, subscription_is_active = _calculate_subscription_flags(
|
||||
existing_user.subscription
|
||||
)
|
||||
|
||||
menu_text = await get_main_menu_text(existing_user, texts, db)
|
||||
|
||||
@@ -1201,11 +1209,9 @@ async def complete_registration(
|
||||
else:
|
||||
logger.info(f"ℹ️ Приветственные сообщения отключены, показываем главное меню для пользователя {user.telegram_id}")
|
||||
|
||||
has_active_subscription = bool(getattr(user, "subscription", None))
|
||||
subscription_is_active = False
|
||||
|
||||
if getattr(user, "subscription", None):
|
||||
subscription_is_active = user.subscription.is_active
|
||||
has_active_subscription, subscription_is_active = _calculate_subscription_flags(
|
||||
getattr(user, "subscription", None)
|
||||
)
|
||||
|
||||
menu_text = await get_main_menu_text(user, texts, db)
|
||||
|
||||
@@ -1258,30 +1264,43 @@ 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 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 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 not end_date:
|
||||
return texts.t("SUBSCRIPTION_ACTIVE", "✅ Активна")
|
||||
|
||||
days_left = (end_date - current_time).days
|
||||
is_trial = getattr(subscription, "is_trial", False)
|
||||
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", "❓ Статус неизвестен")
|
||||
|
||||
if is_trial:
|
||||
if days_left > 1:
|
||||
if days_left > 1 and end_date_display:
|
||||
return texts.t(
|
||||
"SUB_STATUS_TRIAL_ACTIVE",
|
||||
"🎁 Тестовая подписка\n📅 до {end_date} ({days} дн.)",
|
||||
).format(end_date=end_date.strftime('%d.%m.%Y'), days=days_left)
|
||||
).format(end_date=end_date_display, days=days_left)
|
||||
if days_left == 1:
|
||||
return texts.t(
|
||||
"SUB_STATUS_TRIAL_TOMORROW",
|
||||
@@ -1292,11 +1311,11 @@ def _get_subscription_status(user, texts):
|
||||
"🎁 Тестовая подписка\n⚠️ истекает сегодня!",
|
||||
)
|
||||
|
||||
if days_left > 7:
|
||||
if days_left > 7 and end_date_display:
|
||||
return texts.t(
|
||||
"SUB_STATUS_ACTIVE_LONG",
|
||||
"💎 Активна\n📅 до {end_date} ({days} дн.)",
|
||||
).format(end_date=end_date.strftime('%d.%m.%Y'), days=days_left)
|
||||
).format(end_date=end_date_display, days=days_left)
|
||||
if days_left > 1:
|
||||
return texts.t(
|
||||
"SUB_STATUS_ACTIVE_FEW_DAYS",
|
||||
@@ -1521,8 +1540,9 @@ async def required_sub_channel_check(
|
||||
logger.warning(f"Не удалось удалить сообщение: {e}")
|
||||
|
||||
if user and user.status != UserStatus.DELETED.value:
|
||||
has_active_subscription = bool(user.subscription)
|
||||
subscription_is_active = bool(user.subscription and user.subscription.is_active)
|
||||
has_active_subscription, subscription_is_active = _calculate_subscription_flags(
|
||||
user.subscription
|
||||
)
|
||||
|
||||
menu_text = await get_main_menu_text(user, texts, db)
|
||||
|
||||
|
||||
@@ -56,6 +56,7 @@ from app.services.subscription_checkout_service import (
|
||||
should_offer_checkout_resume,
|
||||
)
|
||||
from app.services.subscription_service import SubscriptionService
|
||||
from app.services.user_cart_service import user_cart_service
|
||||
from app.utils.miniapp_buttons import build_miniapp_or_callback_button
|
||||
from app.services.promo_offer_service import promo_offer_service
|
||||
from app.states import SubscriptionStates
|
||||
@@ -207,39 +208,20 @@ async def handle_subscription_config_back(
|
||||
await state.set_state(SubscriptionStates.selecting_period)
|
||||
|
||||
elif current_state == SubscriptionStates.selecting_devices.state:
|
||||
if await _should_show_countries_management(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)
|
||||
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 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)
|
||||
await _show_previous_configuration_step(callback, state, db_user, texts, db)
|
||||
|
||||
elif current_state == SubscriptionStates.confirming_purchase.state:
|
||||
data = await state.get_data()
|
||||
selected_devices = data.get('devices', settings.DEFAULT_DEVICE_LIMIT)
|
||||
if settings.is_devices_selection_enabled():
|
||||
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)
|
||||
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:
|
||||
await _show_previous_configuration_step(callback, state, db_user, texts, db)
|
||||
|
||||
else:
|
||||
from app.handlers.menu import show_main_menu
|
||||
@@ -259,7 +241,44 @@ async def handle_subscription_cancel(
|
||||
await state.clear()
|
||||
await clear_subscription_checkout_draft(db_user.id)
|
||||
|
||||
# Удаляем сохраненную корзину, чтобы не показывать кнопку возврата
|
||||
await user_cart_service.delete_user_cart(db_user.id)
|
||||
|
||||
from app.handlers.menu import show_main_menu
|
||||
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),
|
||||
parse_mode="HTML",
|
||||
)
|
||||
await state.set_state(SubscriptionStates.selecting_period)
|
||||
|
||||
|
||||
@@ -79,6 +79,7 @@ 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,
|
||||
@@ -588,6 +589,11 @@ 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,6 +183,13 @@ 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", "⚠️ Эта функция доступна только для платных подписок"),
|
||||
@@ -233,6 +240,13 @@ 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:
|
||||
@@ -379,6 +393,13 @@ 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(
|
||||
@@ -863,6 +884,13 @@ 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,6 +77,7 @@ 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
|
||||
@@ -158,7 +159,18 @@ async def _prepare_subscription_summary(
|
||||
total_servers_discount += total_discount_for_server
|
||||
selected_server_prices.append(total_price_for_server)
|
||||
|
||||
devices_selected = summary_data.get('devices', settings.DEFAULT_DEVICE_LIMIT)
|
||||
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
|
||||
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(
|
||||
@@ -275,7 +287,7 @@ async def _prepare_subscription_summary(
|
||||
f" -{texts.format_price(total_servers_discount)})"
|
||||
)
|
||||
details_lines.append(servers_line)
|
||||
if total_devices_price > 0:
|
||||
if devices_selection_enabled and total_devices_price > 0:
|
||||
devices_line = (
|
||||
f"- Доп. устройства: {texts.format_price(devices_price_per_month)}/мес × {months_in_period}"
|
||||
f" = {texts.format_price(total_devices_price)}"
|
||||
@@ -300,17 +312,28 @@ async def _prepare_subscription_summary(
|
||||
|
||||
details_text = "\n".join(details_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"
|
||||
"Подтверждаете покупку?"
|
||||
)
|
||||
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)
|
||||
|
||||
return summary_text, summary_data
|
||||
|
||||
@@ -382,7 +405,18 @@ async def get_subscription_cost(subscription, db: AsyncSession) -> int:
|
||||
)
|
||||
|
||||
traffic_cost = settings.get_traffic_price(subscription.traffic_limit_gb)
|
||||
devices_cost = max(0, subscription.device_limit - settings.DEFAULT_DEVICE_LIMIT) * settings.PRICE_PER_DEVICE
|
||||
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
|
||||
|
||||
total_cost = base_cost + servers_cost + traffic_cost + devices_cost
|
||||
|
||||
@@ -410,7 +444,12 @@ async def get_subscription_cost(subscription, db: AsyncSession) -> int:
|
||||
return 0
|
||||
|
||||
async def get_subscription_info_text(subscription, texts, db_user, db: AsyncSession):
|
||||
devices_used = await get_current_devices_count(db_user)
|
||||
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
|
||||
countries_info = await _get_countries_info(subscription.connected_squads)
|
||||
countries_text = ", ".join([c['name'] for c in countries_info]) if countries_info else "Нет"
|
||||
|
||||
@@ -439,10 +478,21 @@ async def get_subscription_info_text(subscription, texts, db_user, db: AsyncSess
|
||||
|
||||
subscription_cost = await get_subscription_cost(subscription, db)
|
||||
|
||||
info_text = texts.SUBSCRIPTION_INFO.format(
|
||||
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(
|
||||
status=status_text,
|
||||
type=type_text,
|
||||
end_date=subscription.end_date.strftime("%d.%m.%Y %H:%M"),
|
||||
end_date=format_local_datetime(subscription.end_date, "%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,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,59 @@
|
||||
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,6 +80,7 @@ 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,
|
||||
@@ -352,12 +353,15 @@ async def select_traffic(
|
||||
reply_markup=get_countries_keyboard(countries, [], db_user.language)
|
||||
)
|
||||
await state.set_state(SubscriptionStates.selecting_countries)
|
||||
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)
|
||||
await callback.answer()
|
||||
return
|
||||
|
||||
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(
|
||||
@@ -365,8 +369,11 @@ 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
|
||||
|
||||
await callback.answer()
|
||||
if await present_subscription_summary(callback, state, db_user, texts):
|
||||
await callback.answer()
|
||||
|
||||
async def add_traffic(
|
||||
callback: types.CallbackQuery,
|
||||
|
||||
@@ -53,6 +53,12 @@ def get_admin_main_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
|
||||
callback_data="admin_submenu_system",
|
||||
),
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=_t(texts, "ADMIN_MAIN_PAYMENTS", "💳 Пополнения"),
|
||||
callback_data="admin_payments",
|
||||
)
|
||||
],
|
||||
[InlineKeyboardButton(text=texts.BACK, callback_data="back_to_menu")]
|
||||
])
|
||||
|
||||
@@ -101,6 +107,12 @@ def get_admin_communications_submenu_keyboard(language: str = "ru") -> InlineKey
|
||||
[
|
||||
InlineKeyboardButton(text=texts.ADMIN_MESSAGES, callback_data="admin_messages")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=_t(texts, "ADMIN_COMMUNICATIONS_POLLS", "🗳️ Опросы"),
|
||||
callback_data="admin_polls",
|
||||
)
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=_t(texts, "ADMIN_COMMUNICATIONS_PROMO_OFFERS", "🎯 Промо-предложения"),
|
||||
@@ -748,6 +760,12 @@ 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", "📊 Статистика"),
|
||||
|
||||
+31
-25
@@ -9,7 +9,10 @@ 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.pricing_utils import (
|
||||
format_period_description,
|
||||
apply_percentage_discount,
|
||||
)
|
||||
from app.utils.subscription_utils import (
|
||||
get_display_subscription_link,
|
||||
get_happ_cryptolink_redirect_link,
|
||||
@@ -329,7 +332,7 @@ def get_main_menu_keyboard(
|
||||
and getattr(current_subscription, "is_active", False)
|
||||
)
|
||||
simple_purchase_button = None
|
||||
if settings.SIMPLE_SUBSCRIPTION_ENABLED and not has_active_paid_subscription:
|
||||
if settings.SIMPLE_SUBSCRIPTION_ENABLED:
|
||||
simple_purchase_button = InlineKeyboardButton(
|
||||
text=texts.MENU_SIMPLE_SUBSCRIPTION,
|
||||
callback_data="simple_subscription_purchase",
|
||||
@@ -346,9 +349,6 @@ def get_main_menu_keyboard(
|
||||
subscription_buttons.append(
|
||||
InlineKeyboardButton(text=texts.MENU_BUY_SUBSCRIPTION, callback_data="menu_buy")
|
||||
)
|
||||
if simple_purchase_button:
|
||||
subscription_buttons.append(simple_purchase_button)
|
||||
simple_purchase_button = None
|
||||
|
||||
if subscription_buttons:
|
||||
paired_buttons.extend(subscription_buttons)
|
||||
@@ -1410,10 +1410,10 @@ def get_extend_subscription_keyboard(language: str = DEFAULT_LANGUAGE) -> Inline
|
||||
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)}")
|
||||
(14, texts.PERIOD_14_DAYS),
|
||||
(30, texts.PERIOD_30_DAYS),
|
||||
(60, texts.PERIOD_60_DAYS),
|
||||
(90, texts.PERIOD_90_DAYS)
|
||||
]
|
||||
|
||||
for days, text in periods:
|
||||
@@ -2085,33 +2085,39 @@ 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")
|
||||
])
|
||||
|
||||
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, [
|
||||
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.append([
|
||||
InlineKeyboardButton(text=texts.BACK, callback_data="menu_subscription")
|
||||
])
|
||||
|
||||
|
||||
return InlineKeyboardMarkup(inline_keyboard=keyboard)
|
||||
|
||||
|
||||
|
||||
@@ -130,6 +130,7 @@
|
||||
"ADMIN_MAIN_SETTINGS": "⚙️ Settings",
|
||||
"ADMIN_MAIN_SUPPORT": "🛟 Support",
|
||||
"ADMIN_MAIN_SYSTEM": "🛠️ System",
|
||||
"ADMIN_MAIN_PAYMENTS": "💳 Top-ups",
|
||||
"ADMIN_MAIN_USERS_SUBSCRIPTIONS": "👥 Users / Subscriptions",
|
||||
"ADMIN_MESSAGES": "📨 Broadcasts",
|
||||
"ADMIN_MESSAGES_ALL_USERS": "📨 All users",
|
||||
@@ -164,6 +165,39 @@
|
||||
"ADMIN_MONITORING_STOP": "⏸️ Stop",
|
||||
"ADMIN_MONITORING_STOP_HARD": "⏹️ Stop",
|
||||
"ADMIN_MONITORING_TEST_NOTIFICATIONS": "🧪 Test notifications",
|
||||
"ADMIN_PAYMENTS_TITLE": "💳 <b>Top-up verification</b>",
|
||||
"ADMIN_PAYMENTS_DESCRIPTION": "Pending top-up invoices created during the last 24 hours.",
|
||||
"ADMIN_PAYMENTS_NOTICE": "Only invoices younger than 24 hours and waiting for payment can be checked.",
|
||||
"ADMIN_PAYMENTS_EMPTY": "No pending top-up invoices found in the last 24 hours.",
|
||||
"ADMIN_PAYMENTS_ITEM_DETAILS": "📄 #{number}",
|
||||
"ADMIN_PAYMENT_STATUS_PENDING": "Pending",
|
||||
"ADMIN_PAYMENT_STATUS_PROCESSING": "Processing",
|
||||
"ADMIN_PAYMENT_STATUS_PAID": "Paid",
|
||||
"ADMIN_PAYMENT_STATUS_FAILED": "Failed",
|
||||
"ADMIN_PAYMENT_STATUS_CANCELED": "Cancelled",
|
||||
"ADMIN_PAYMENT_STATUS_UNKNOWN": "Unknown status",
|
||||
"ADMIN_PAYMENT_STATUS_ON_HOLD": "On hold",
|
||||
"ADMIN_PAYMENT_STATUS_EXPIRED": "Expired",
|
||||
"ADMIN_PAYMENT_DETAILS_TITLE": "💳 <b>Payment details</b>",
|
||||
"ADMIN_PAYMENT_AMOUNT": "Amount",
|
||||
"ADMIN_PAYMENT_CREATED": "Created",
|
||||
"ADMIN_PAYMENT_EXPIRES": "Expires",
|
||||
"ADMIN_PAYMENT_GATEWAY_STATUS": "Gateway status",
|
||||
"ADMIN_PAYMENT_GATEWAY_METHOD": "Method",
|
||||
"ADMIN_PAYMENT_GATEWAY_AMOUNT": "Gateway amount",
|
||||
"ADMIN_PAYMENT_GATEWAY_ACCOUNT": "Payer account",
|
||||
"ADMIN_PAYMENT_GATEWAY_ID": "Gateway ID",
|
||||
"ADMIN_PAYMENT_PAYER_AMOUNT": "Paid amount",
|
||||
"ADMIN_PAYMENT_CRYPTO_AMOUNT": "Crypto amount",
|
||||
"ADMIN_PAYMENT_HAS_LINK": "🔗 A payment link is available via the button above.",
|
||||
"ADMIN_PAYMENT_OPEN_LINK": "🔗 Open link",
|
||||
"ADMIN_PAYMENT_CHECK_BUTTON": "🔁 Check status",
|
||||
"ADMIN_PAYMENT_CHECK_HINT": "ℹ️ You can trigger a manual status check.",
|
||||
"ADMIN_PAYMENT_CHECK_NOT_AVAILABLE": "Manual status check is not available for this invoice.",
|
||||
"ADMIN_PAYMENT_CHECK_FAILED": "Failed to refresh the payment status.",
|
||||
"ADMIN_PAYMENT_CHECK_SUCCESS": "Status updated: {status}",
|
||||
"ADMIN_PAYMENT_CHECK_NO_CHANGES": "Status did not change after the check.",
|
||||
"ADMIN_PAYMENT_NOT_FOUND": "Payment not found.",
|
||||
"ADMIN_NODE_DISABLE": "⏸️ Disable",
|
||||
"ADMIN_NODE_ENABLE": "▶️ Enable",
|
||||
"ADMIN_NODE_RESTART": "🔄 Restart",
|
||||
@@ -746,6 +780,10 @@
|
||||
"BALANCE_SUPPORT_REQUEST": "🛠️ Request via support",
|
||||
"BALANCE_TOPUP": "💳 Top up balance",
|
||||
"BALANCE_TOPUP_CART_REMINDER_DETAILED": "\\n💡 <b>Balance top-up required</b>\\n\\nYour cart contains items totaling {total_amount}, but your current balance is insufficient.\\n\\n💳 <b>Top up your balance</b> to complete the purchase.\\n\\nChoose a top-up method:",
|
||||
"AUTO_PURCHASE_SUBSCRIPTION_SUCCESS": "✅ Your {period} subscription was purchased automatically after topping up your balance.",
|
||||
"AUTO_PURCHASE_SUBSCRIPTION_EXTENDED": "✅ Subscription automatically extended for {period}.",
|
||||
"AUTO_PURCHASE_SUBSCRIPTION_EXTENDED_DETAILS": "⏰ New expiration date: {date}.",
|
||||
"AUTO_PURCHASE_SUBSCRIPTION_HINT": "Open the ‘My subscription’ section to access your link and setup instructions.",
|
||||
"BALANCE_TOP_UP": "💳 Top up",
|
||||
"BLOCK_BY_TIME": "⏳ Temporary block",
|
||||
"BLOCK_FOREVER": "🚫 Block permanently",
|
||||
@@ -818,6 +856,9 @@
|
||||
"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",
|
||||
@@ -977,6 +1018,23 @@
|
||||
"PAYMENT_CHARGE_ERROR": "⚠️ Failed to charge the payment",
|
||||
"PAYMENT_CRYPTOBOT": "🪙 Cryptocurrency (CryptoBot)",
|
||||
"PAYMENT_HELEKET": "🪙 Cryptocurrency (Heleket)",
|
||||
"HELEKET_PAYMENT_ALREADY_PAID": "✅ Payment has already been credited",
|
||||
"HELEKET_PAYMENT_SUCCESS": "✅ Payment credited to your balance",
|
||||
"HELEKET_STATUS_CHECK": "⏳ Waiting for payment",
|
||||
"HELEKET_STATUS_PROCESS": "⚙️ Payment is being processed",
|
||||
"HELEKET_STATUS_CONFIRM_CHECK": "⛓ Waiting for network confirmations",
|
||||
"HELEKET_STATUS_WRONG_AMOUNT": "❗️ Incorrect amount paid",
|
||||
"HELEKET_STATUS_WRONG_AMOUNT_WAITING": "❗️ Not enough amount, waiting for additional payment",
|
||||
"HELEKET_STATUS_PAID_OVER": "✅ Payment credited (overpaid)",
|
||||
"HELEKET_STATUS_PAID": "✅ Payment credited",
|
||||
"HELEKET_STATUS_CANCEL": "🚫 Payment was cancelled",
|
||||
"HELEKET_STATUS_FAIL": "❌ Payment failed",
|
||||
"HELEKET_STATUS_SYSTEM_FAIL": "❌ Heleket system error",
|
||||
"HELEKET_STATUS_REFUND_PROCESS": "↩️ Refund in progress",
|
||||
"HELEKET_STATUS_REFUND_FAIL": "⚠️ Refund failed",
|
||||
"HELEKET_STATUS_REFUND_PAID": "✅ Refund completed",
|
||||
"HELEKET_STATUS_LOCKED": "🔒 Funds are locked",
|
||||
"HELEKET_STATUS_UNKNOWN": "ℹ️ Payment status: {status}",
|
||||
"PAYMENT_METHODS_FOOTER": "Choose a top-up method:",
|
||||
"PAYMENT_METHODS_ONLY_SUPPORT": "💳 <b>Balance top-up methods</b>\n\n⚠️ Automated payment methods are temporarily unavailable.\nContact support to top up your balance.\n\nChoose a top-up method:",
|
||||
"PAYMENT_METHODS_PROMPT": "Choose the payment method that suits you:",
|
||||
@@ -1250,6 +1308,8 @@
|
||||
"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!",
|
||||
@@ -1323,7 +1383,8 @@
|
||||
"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 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_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{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_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!",
|
||||
@@ -1358,5 +1419,50 @@
|
||||
"SIMPLE_SUBSCRIPTION_SERVER_ANY": "Any available",
|
||||
"SIMPLE_SUBSCRIPTION_SERVER_SELECTED": "Selected",
|
||||
"SIMPLE_SUBSCRIPTION_SERVER_ASSIGNED": "Assigned automatically",
|
||||
"MENU_SIMPLE_SUBSCRIPTION": "⚡ Quick purchase"
|
||||
"MENU_SIMPLE_SUBSCRIPTION": "⚡ Quick purchase",
|
||||
"ADMIN_COMMUNICATIONS_POLLS": "🗳️ Polls",
|
||||
"ADMIN_POLLS_CREATE": "➕ Create poll",
|
||||
"ADMIN_POLLS_REWARD_ENABLED": "Reward: {amount}",
|
||||
"ADMIN_POLLS_REWARD_DISABLED": "Reward disabled",
|
||||
"ADMIN_POLLS_SEND": "📤 Send",
|
||||
"ADMIN_POLLS_STATS": "📊 Stats",
|
||||
"ADMIN_POLLS_DELETE": "🗑️ Delete",
|
||||
"ADMIN_POLLS_BACK": "⬅️ Back to list",
|
||||
"ADMIN_POLLS_CUSTOM_TARGET": "⚙️ Custom filters",
|
||||
"ADMIN_POLLS_SEND_CONFIRM_BUTTON": "✅ Send",
|
||||
"ADMIN_POLLS_LIST_TITLE": "🗳️ <b>Polls</b>",
|
||||
"ADMIN_POLLS_LIST_EMPTY": "No polls yet.",
|
||||
"ADMIN_POLLS_QUESTIONS_COUNT": "Questions: {count}",
|
||||
"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_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.",
|
||||
"ADMIN_POLLS_CREATION_QUESTION_PROMPT": "Send the question and answer options.\nEach line is a separate option.\nThe first line is the question text.\nSend /done when finished.",
|
||||
"ADMIN_POLLS_CREATION_NEEDS_QUESTION": "❌ Add at least one question.",
|
||||
"ADMIN_POLLS_CREATION_FINISHED": "✅ Poll “{title}” created. Questions: {count}. {reward}",
|
||||
"ADMIN_POLLS_CREATION_MIN_OPTIONS": "❌ Provide a question and at least two answer options.",
|
||||
"ADMIN_POLLS_CREATION_ADDED_QUESTION": "Question added: “{question}”. Add another question or send /done.",
|
||||
"ADMIN_POLLS_QUESTION_LIST_HEADER": "<b>Questions:</b>",
|
||||
"ADMIN_POLLS_OPTION_BULLET": " • {option}",
|
||||
"ADMIN_POLLS_SEND_CHOOSE_TARGET": "🎯 Select audience for the poll:",
|
||||
"ADMIN_POLLS_CUSTOM_PROMPT": "Choose an additional audience filter:",
|
||||
"ADMIN_POLLS_SEND_CONFIRM": "📤 Send poll “{title}” to “{audience}”? Users: {count}",
|
||||
"ADMIN_POLLS_SENDING": "📤 Sending poll...",
|
||||
"ADMIN_POLLS_SEND_RESULT": "📤 Poll finished\nDelivered: {sent}\nFailed: {failed}\nSkipped: {skipped}\nTotal: {total}",
|
||||
"ADMIN_POLLS_STATS_HEADER": "📊 <b>Poll statistics</b>",
|
||||
"ADMIN_POLLS_STATS_OVERVIEW": "Invited: {total}\nCompleted: {completed}\nRewards paid: {reward}",
|
||||
"ADMIN_POLLS_STATS_OPTION_LINE": "• {option}: {count}",
|
||||
"ADMIN_POLLS_CONFIRM_DELETE": "Delete poll “{title}”?",
|
||||
"ADMIN_POLLS_DELETED": "🗑️ Poll deleted.",
|
||||
"POLL_INVITATION_REWARD": "🎁 You will receive {amount} for participating.",
|
||||
"POLL_INVITATION_START": "Tap the button below to answer the poll.",
|
||||
"POLL_START_BUTTON": "📝 Take the poll",
|
||||
"POLL_QUESTION_HEADER": "<b>Question {current}/{total}</b>",
|
||||
"POLL_ALREADY_COMPLETED": "You have already completed this poll.",
|
||||
"POLL_EMPTY": "Poll is not available yet.",
|
||||
"POLL_ERROR": "Unable to process the poll. Please try again later.",
|
||||
"POLL_COMPLETED": "🙏 Thanks for completing the poll!",
|
||||
"POLL_REWARD_GRANTED": "Reward {amount} has been credited to your balance."
|
||||
}
|
||||
|
||||
@@ -130,6 +130,7 @@
|
||||
"ADMIN_MAIN_SETTINGS": "⚙️ Настройки",
|
||||
"ADMIN_MAIN_SUPPORT": "🛟 Поддержка",
|
||||
"ADMIN_MAIN_SYSTEM": "🛠️ Система",
|
||||
"ADMIN_MAIN_PAYMENTS": "💳 Пополнения",
|
||||
"ADMIN_MAIN_USERS_SUBSCRIPTIONS": "👥 Юзеры/Подписки",
|
||||
"ADMIN_MESSAGES": "📨 Рассылки",
|
||||
"ADMIN_MESSAGES_ALL_USERS": "📨 Всем пользователям",
|
||||
@@ -164,6 +165,39 @@
|
||||
"ADMIN_MONITORING_STOP": "⏸️ Остановить",
|
||||
"ADMIN_MONITORING_STOP_HARD": "⏹️ Остановить",
|
||||
"ADMIN_MONITORING_TEST_NOTIFICATIONS": "🧪 Тест уведомлений",
|
||||
"ADMIN_PAYMENTS_TITLE": "💳 <b>Проверка пополнений</b>",
|
||||
"ADMIN_PAYMENTS_DESCRIPTION": "Список счетов на пополнение, созданных за последние 24 часа и ожидающих оплаты.",
|
||||
"ADMIN_PAYMENTS_NOTICE": "Проверять можно только счета моложе 24 часов и со статусом ожидания.",
|
||||
"ADMIN_PAYMENTS_EMPTY": "За последние 24 часа не найдено счетов на пополнение в ожидании.",
|
||||
"ADMIN_PAYMENTS_ITEM_DETAILS": "📄 №{number}",
|
||||
"ADMIN_PAYMENT_STATUS_PENDING": "Ожидает оплаты",
|
||||
"ADMIN_PAYMENT_STATUS_PROCESSING": "Обрабатывается",
|
||||
"ADMIN_PAYMENT_STATUS_PAID": "Оплачен",
|
||||
"ADMIN_PAYMENT_STATUS_FAILED": "Ошибка",
|
||||
"ADMIN_PAYMENT_STATUS_CANCELED": "Отменён",
|
||||
"ADMIN_PAYMENT_STATUS_UNKNOWN": "Статус неизвестен",
|
||||
"ADMIN_PAYMENT_STATUS_ON_HOLD": "На удержании",
|
||||
"ADMIN_PAYMENT_STATUS_EXPIRED": "Просрочен",
|
||||
"ADMIN_PAYMENT_DETAILS_TITLE": "💳 <b>Детали платежа</b>",
|
||||
"ADMIN_PAYMENT_AMOUNT": "Сумма",
|
||||
"ADMIN_PAYMENT_CREATED": "Создан",
|
||||
"ADMIN_PAYMENT_EXPIRES": "Истекает",
|
||||
"ADMIN_PAYMENT_GATEWAY_STATUS": "Статус в платёжке",
|
||||
"ADMIN_PAYMENT_GATEWAY_METHOD": "Метод оплаты",
|
||||
"ADMIN_PAYMENT_GATEWAY_AMOUNT": "Сумма в платёжке",
|
||||
"ADMIN_PAYMENT_GATEWAY_ACCOUNT": "Счёт плательщика",
|
||||
"ADMIN_PAYMENT_GATEWAY_ID": "ID в платёжке",
|
||||
"ADMIN_PAYMENT_PAYER_AMOUNT": "Оплачено",
|
||||
"ADMIN_PAYMENT_CRYPTO_AMOUNT": "Сумма в криптовалюте",
|
||||
"ADMIN_PAYMENT_HAS_LINK": "🔗 Ссылка на оплату доступна в кнопке выше.",
|
||||
"ADMIN_PAYMENT_OPEN_LINK": "🔗 Открыть ссылку",
|
||||
"ADMIN_PAYMENT_CHECK_BUTTON": "🔁 Проверить статус",
|
||||
"ADMIN_PAYMENT_CHECK_HINT": "ℹ️ Можно запустить ручную проверку статуса.",
|
||||
"ADMIN_PAYMENT_CHECK_NOT_AVAILABLE": "Для этого счёта ручная проверка недоступна.",
|
||||
"ADMIN_PAYMENT_CHECK_FAILED": "Не удалось обновить статус платежа.",
|
||||
"ADMIN_PAYMENT_CHECK_SUCCESS": "Статус обновлён: {status}",
|
||||
"ADMIN_PAYMENT_CHECK_NO_CHANGES": "Статус не изменился после проверки.",
|
||||
"ADMIN_PAYMENT_NOT_FOUND": "Платёж не найден.",
|
||||
"ADMIN_NODE_DISABLE": "⏸️ Отключить",
|
||||
"ADMIN_NODE_ENABLE": "▶️ Включить",
|
||||
"ADMIN_NODE_RESTART": "🔄 Перезагрузить",
|
||||
@@ -679,6 +713,26 @@
|
||||
"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}%",
|
||||
@@ -746,6 +800,10 @@
|
||||
"BALANCE_SUPPORT_REQUEST": "🛠️ Запрос через поддержку",
|
||||
"BALANCE_TOPUP": "💳 Пополнить баланс",
|
||||
"BALANCE_TOPUP_CART_REMINDER_DETAILED": "\n💡 <b>Требуется пополнение баланса</b>\n\nВ вашей корзине находятся товары на общую сумму {total_amount}, но на балансе недостаточно средств.\n\n💳 <b>Пополните баланс</b>, чтобы завершить покупку.\n\nВыберите способ пополнения:",
|
||||
"AUTO_PURCHASE_SUBSCRIPTION_SUCCESS": "✅ Подписка на {period} автоматически оформлена после пополнения баланса.",
|
||||
"AUTO_PURCHASE_SUBSCRIPTION_EXTENDED": "✅ Подписка автоматически продлена на {period}.",
|
||||
"AUTO_PURCHASE_SUBSCRIPTION_EXTENDED_DETAILS": "⏰ Новая дата окончания: {date}.",
|
||||
"AUTO_PURCHASE_SUBSCRIPTION_HINT": "Перейдите в раздел «Моя подписка», чтобы получить ссылку и инструкции.",
|
||||
"BALANCE_TOP_UP": "💳 Пополнить",
|
||||
"BLOCK_BY_TIME": "⏳ Блокировка по времени",
|
||||
"BLOCK_FOREVER": "🚫 Заблокировать",
|
||||
@@ -818,6 +876,9 @@
|
||||
"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": "❌ Ошибка получения информации об устройствах",
|
||||
@@ -977,6 +1038,23 @@
|
||||
"PAYMENT_CHARGE_ERROR": "⚠️ Ошибка списания средств",
|
||||
"PAYMENT_CRYPTOBOT": "🪙 Криптовалюта (CryptoBot)",
|
||||
"PAYMENT_HELEKET": "🪙 Криптовалюта (Heleket)",
|
||||
"HELEKET_PAYMENT_ALREADY_PAID": "✅ Платёж уже зачислен",
|
||||
"HELEKET_PAYMENT_SUCCESS": "✅ Платёж зачислен на баланс",
|
||||
"HELEKET_STATUS_CHECK": "⏳ Ожидаем оплату",
|
||||
"HELEKET_STATUS_PROCESS": "⚙️ Платёж обрабатывается",
|
||||
"HELEKET_STATUS_CONFIRM_CHECK": "⛓ Ожидание подтверждений сети",
|
||||
"HELEKET_STATUS_WRONG_AMOUNT": "❗️ Оплачена неверная сумма",
|
||||
"HELEKET_STATUS_WRONG_AMOUNT_WAITING": "❗️ Недостаточная сумма, ожидаем доплату",
|
||||
"HELEKET_STATUS_PAID_OVER": "✅ Платёж зачислен (с переплатой)",
|
||||
"HELEKET_STATUS_PAID": "✅ Платёж зачислен",
|
||||
"HELEKET_STATUS_CANCEL": "🚫 Платёж отменён",
|
||||
"HELEKET_STATUS_FAIL": "❌ Ошибка при оплате",
|
||||
"HELEKET_STATUS_SYSTEM_FAIL": "❌ Системная ошибка Heleket",
|
||||
"HELEKET_STATUS_REFUND_PROCESS": "↩️ Возврат обрабатывается",
|
||||
"HELEKET_STATUS_REFUND_FAIL": "⚠️ Ошибка возврата",
|
||||
"HELEKET_STATUS_REFUND_PAID": "✅ Возврат выполнен",
|
||||
"HELEKET_STATUS_LOCKED": "🔒 Средства заблокированы",
|
||||
"HELEKET_STATUS_UNKNOWN": "ℹ️ Статус платежа: {status}",
|
||||
"PAYMENT_METHODS_FOOTER": "Выберите способ пополнения:",
|
||||
"PAYMENT_METHODS_ONLY_SUPPORT": "💳 <b>Способы пополнения баланса</b>\n\n⚠️ В данный момент автоматические способы оплаты временно недоступны.\nОбратитесь в техподдержку для пополнения баланса.\n\nВыберите способ пополнения:",
|
||||
"PAYMENT_METHODS_PROMPT": "Выберите удобный для вас способ оплаты:",
|
||||
@@ -1250,6 +1328,8 @@
|
||||
"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⚠️ истекает сегодня!",
|
||||
@@ -1323,7 +1403,8 @@
|
||||
"TRIAL_ACTIVATED": "🎉 Тестовая подписка активирована!",
|
||||
"TRIAL_ACTIVATE_BUTTON": "🎁 Активировать",
|
||||
"TRIAL_ALREADY_USED": "❌ Тестовая подписка уже была использована",
|
||||
"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_AVAILABLE": "\n🎁 <b>Тестовая подписка</b>\n\nВы можете получить бесплатную тестовую подписку:\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_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Если возникли сложности — откройте инструкцию и следуйте шагам. Мы всегда готовы помочь!",
|
||||
@@ -1358,5 +1439,50 @@
|
||||
"SIMPLE_SUBSCRIPTION_SERVER_ANY": "Любой доступный",
|
||||
"SIMPLE_SUBSCRIPTION_SERVER_SELECTED": "Выбранный",
|
||||
"SIMPLE_SUBSCRIPTION_SERVER_ASSIGNED": "Назначен автоматически",
|
||||
"MENU_SIMPLE_SUBSCRIPTION": "⚡ Простая покупка"
|
||||
"MENU_SIMPLE_SUBSCRIPTION": "⚡ Простая покупка",
|
||||
"ADMIN_COMMUNICATIONS_POLLS": "🗳️ Опросы",
|
||||
"ADMIN_POLLS_CREATE": "➕ Создать опрос",
|
||||
"ADMIN_POLLS_REWARD_ENABLED": "Награда: {amount}",
|
||||
"ADMIN_POLLS_REWARD_DISABLED": "Награда отключена",
|
||||
"ADMIN_POLLS_SEND": "📤 Отправить",
|
||||
"ADMIN_POLLS_STATS": "📊 Статистика",
|
||||
"ADMIN_POLLS_DELETE": "🗑️ Удалить",
|
||||
"ADMIN_POLLS_BACK": "⬅️ К списку",
|
||||
"ADMIN_POLLS_CUSTOM_TARGET": "⚙️ По критериям",
|
||||
"ADMIN_POLLS_SEND_CONFIRM_BUTTON": "✅ Отправить",
|
||||
"ADMIN_POLLS_LIST_TITLE": "🗳️ <b>Опросы</b>",
|
||||
"ADMIN_POLLS_LIST_EMPTY": "Опросов пока нет.",
|
||||
"ADMIN_POLLS_QUESTIONS_COUNT": "Вопросов: {count}",
|
||||
"ADMIN_POLLS_CREATION_TITLE_PROMPT": "🗳️ <b>Создание опроса</b>\n\nВведите заголовок опроса:",
|
||||
"ADMIN_POLLS_CREATION_CANCELLED": "❌ Создание опроса отменено.",
|
||||
"ADMIN_POLLS_CREATION_DESCRIPTION_PROMPT": "Введите описание опроса. HTML разрешён.\nОтправьте /skip, чтобы пропустить.",
|
||||
"ADMIN_POLLS_CREATION_INVALID_HTML": "❌ Ошибка в HTML: {error}",
|
||||
"ADMIN_POLLS_CREATION_REWARD_PROMPT": "Введите сумму награды в рублях. Отправьте 0 чтобы отключить награду.",
|
||||
"ADMIN_POLLS_CREATION_REWARD_INVALID": "❌ Некорректная сумма. Попробуйте ещё раз.",
|
||||
"ADMIN_POLLS_CREATION_QUESTION_PROMPT": "Введите вопрос и варианты ответов.\nКаждая строка — отдельный вариант.\nПервая строка — текст вопроса.\nОтправьте /done, когда вопросы будут добавлены.",
|
||||
"ADMIN_POLLS_CREATION_NEEDS_QUESTION": "❌ Добавьте хотя бы один вопрос.",
|
||||
"ADMIN_POLLS_CREATION_FINISHED": "✅ Опрос «{title}» создан. Вопросов: {count}. {reward}",
|
||||
"ADMIN_POLLS_CREATION_MIN_OPTIONS": "❌ Нужен вопрос и минимум два варианта ответа.",
|
||||
"ADMIN_POLLS_CREATION_ADDED_QUESTION": "Вопрос добавлен: «{question}». Добавьте следующий вопрос или отправьте /done.",
|
||||
"ADMIN_POLLS_QUESTION_LIST_HEADER": "<b>Вопросы:</b>",
|
||||
"ADMIN_POLLS_OPTION_BULLET": " • {option}",
|
||||
"ADMIN_POLLS_SEND_CHOOSE_TARGET": "🎯 Выберите аудиторию для отправки опроса:",
|
||||
"ADMIN_POLLS_CUSTOM_PROMPT": "Выберите дополнительный критерий аудитории:",
|
||||
"ADMIN_POLLS_SEND_CONFIRM": "📤 Отправить опрос «{title}» аудитории «{audience}»? Пользователей: {count}",
|
||||
"ADMIN_POLLS_SENDING": "📤 Запускаю отправку опроса...",
|
||||
"ADMIN_POLLS_SEND_RESULT": "📤 Отправка завершена\nУспешно: {sent}\nОшибок: {failed}\nПропущено: {skipped}\nВсего: {total}",
|
||||
"ADMIN_POLLS_STATS_HEADER": "📊 <b>Статистика опроса</b>",
|
||||
"ADMIN_POLLS_STATS_OVERVIEW": "Всего приглашено: {total}\nЗавершили: {completed}\nВыплачено наград: {reward}",
|
||||
"ADMIN_POLLS_STATS_OPTION_LINE": "• {option}: {count}",
|
||||
"ADMIN_POLLS_CONFIRM_DELETE": "Вы уверены, что хотите удалить опрос «{title}»?",
|
||||
"ADMIN_POLLS_DELETED": "🗑️ Опрос удалён.",
|
||||
"POLL_INVITATION_REWARD": "🎁 За участие вы получите {amount}.",
|
||||
"POLL_INVITATION_START": "Нажмите кнопку ниже, чтобы пройти опрос.",
|
||||
"POLL_START_BUTTON": "📝 Пройти опрос",
|
||||
"POLL_QUESTION_HEADER": "<b>Вопрос {current}/{total}</b>",
|
||||
"POLL_ALREADY_COMPLETED": "Вы уже прошли этот опрос.",
|
||||
"POLL_EMPTY": "Опрос пока недоступен.",
|
||||
"POLL_ERROR": "Не удалось обработать опрос. Попробуйте позже.",
|
||||
"POLL_COMPLETED": "🙏 Спасибо за участие в опросе!",
|
||||
"POLL_REWARD_GRANTED": "Награда {amount} зачислена на ваш баланс."
|
||||
}
|
||||
|
||||
+13
-12
@@ -5,6 +5,7 @@ import logging
|
||||
from typing import Any, Dict
|
||||
|
||||
from app.config import settings
|
||||
from app.utils.pricing_utils import format_period_option_label
|
||||
from app.localization.loader import (
|
||||
DEFAULT_LANGUAGE,
|
||||
clear_locale_cache,
|
||||
@@ -30,12 +31,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)}",
|
||||
"PERIOD_14_DAYS": format_period_option_label("📅 14 дней", settings.PRICE_14_DAYS),
|
||||
"PERIOD_30_DAYS": format_period_option_label("📅 30 дней", settings.PRICE_30_DAYS),
|
||||
"PERIOD_60_DAYS": format_period_option_label("📅 60 дней", settings.PRICE_60_DAYS),
|
||||
"PERIOD_90_DAYS": format_period_option_label("📅 90 дней", settings.PRICE_90_DAYS),
|
||||
"PERIOD_180_DAYS": format_period_option_label("📅 180 дней", settings.PRICE_180_DAYS),
|
||||
"PERIOD_360_DAYS": format_period_option_label("📅 360 дней", 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)}",
|
||||
@@ -55,12 +56,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)}",
|
||||
"PERIOD_14_DAYS": format_period_option_label("📅 14 days", settings.PRICE_14_DAYS),
|
||||
"PERIOD_30_DAYS": format_period_option_label("📅 30 days", settings.PRICE_30_DAYS),
|
||||
"PERIOD_60_DAYS": format_period_option_label("📅 60 days", settings.PRICE_60_DAYS),
|
||||
"PERIOD_90_DAYS": format_period_option_label("📅 90 days", settings.PRICE_90_DAYS),
|
||||
"PERIOD_180_DAYS": format_period_option_label("📅 180 days", settings.PRICE_180_DAYS),
|
||||
"PERIOD_360_DAYS": format_period_option_label("📅 360 days", 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
|
||||
from aiogram import BaseMiddleware, Bot, types
|
||||
from aiogram.exceptions import TelegramForbiddenError, TelegramBadRequest
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from aiogram.types import TelegramObject, Update, Message, CallbackQuery
|
||||
@@ -8,6 +8,7 @@ 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
|
||||
@@ -16,6 +17,7 @@ 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__)
|
||||
|
||||
@@ -104,7 +106,7 @@ class ChannelCheckerMiddleware(BaseMiddleware):
|
||||
if telegram_id:
|
||||
await self._deactivate_trial_subscription(telegram_id)
|
||||
|
||||
await self._capture_start_payload(state, event)
|
||||
await self._capture_start_payload(state, event, bot)
|
||||
|
||||
if isinstance(event, CallbackQuery) and event.data == "sub_channel_check":
|
||||
await event.answer("❌ Вы еще не подписались на канал! Подпишитесь и попробуйте снова.", show_alert=True)
|
||||
@@ -113,12 +115,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)
|
||||
await self._capture_start_payload(state, event, bot)
|
||||
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)
|
||||
await self._capture_start_payload(state, event, bot)
|
||||
return await self._deny_message(event, bot, channel_link)
|
||||
except TelegramBadRequest as e:
|
||||
if "chat not found" in str(e).lower():
|
||||
@@ -127,13 +129,18 @@ class ChannelCheckerMiddleware(BaseMiddleware):
|
||||
logger.error(f"❌ Пользователь {telegram_id} не найден: {e}")
|
||||
else:
|
||||
logger.error(f"❌ Ошибка запроса к каналу {channel_id}: {e}")
|
||||
await self._capture_start_payload(state, event)
|
||||
await self._capture_start_payload(state, event, bot)
|
||||
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) -> None:
|
||||
async def _capture_start_payload(
|
||||
self,
|
||||
state: Optional[FSMContext],
|
||||
event: TelegramObject,
|
||||
bot: Optional[Bot] = None,
|
||||
) -> None:
|
||||
if not state:
|
||||
return
|
||||
|
||||
@@ -159,12 +166,69 @@ class ChannelCheckerMiddleware(BaseMiddleware):
|
||||
payload = parts[1]
|
||||
|
||||
data = await state.get_data() or {}
|
||||
if data.get("pending_start_payload") == payload:
|
||||
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,
|
||||
)
|
||||
return
|
||||
|
||||
data["pending_start_payload"] = payload
|
||||
await state.set_data(data)
|
||||
logger.debug("💾 Сохранен start payload %s для последующей обработки", payload)
|
||||
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
|
||||
|
||||
async def _deactivate_trial_subscription(self, telegram_id: int) -> None:
|
||||
async for db in get_db():
|
||||
|
||||
@@ -19,6 +19,7 @@ from app.database.models import (
|
||||
TransactionType,
|
||||
User,
|
||||
)
|
||||
from app.utils.timezone import format_local_datetime
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -77,6 +78,20 @@ 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] = []
|
||||
|
||||
@@ -185,12 +200,21 @@ 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
|
||||
|
||||
message = f"""🎯 <b>АКТИВАЦИЯ ТРИАЛА</b>
|
||||
|
||||
👤 <b>Пользователь:</b> {user.full_name}
|
||||
👤 <b>Пользователь:</b> {user_display}
|
||||
🆔 <b>Telegram ID:</b> <code>{user.telegram_id}</code>
|
||||
📱 <b>Username:</b> @{user.username or 'отсутствует'}
|
||||
📱 <b>Username:</b> @{getattr(user, 'username', None) or 'отсутствует'}
|
||||
👥 <b>Статус:</b> {user_status}
|
||||
|
||||
{promo_block}
|
||||
@@ -198,13 +222,13 @@ class AdminNotificationService:
|
||||
⏰ <b>Параметры триала:</b>
|
||||
📅 Период: {settings.TRIAL_DURATION_DAYS} дней
|
||||
📊 Трафик: {settings.TRIAL_TRAFFIC_LIMIT_GB} ГБ
|
||||
📱 Устройства: {settings.TRIAL_DEVICE_LIMIT}
|
||||
📱 Устройства: {trial_device_limit}
|
||||
🌐 Сервер: {subscription.connected_squads[0] if subscription.connected_squads else 'По умолчанию'}
|
||||
|
||||
📆 <b>Действует до:</b> {subscription.end_date.strftime('%d.%m.%Y %H:%M')}
|
||||
📆 <b>Действует до:</b> {format_local_datetime(subscription.end_date, '%d.%m.%Y %H:%M')}
|
||||
🔗 <b>Реферер:</b> {referrer_info}
|
||||
|
||||
⏰ <i>{datetime.now().strftime('%d.%m.%Y %H:%M:%S')}</i>"""
|
||||
⏰ <i>{format_local_datetime(datetime.utcnow(), '%d.%m.%Y %H:%M:%S')}</i>"""
|
||||
|
||||
return await self._send_message(message)
|
||||
|
||||
@@ -227,28 +251,29 @@ 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.full_name}
|
||||
👤 <b>Пользователь:</b> {user_display}
|
||||
🆔 <b>Telegram ID:</b> <code>{user.telegram_id}</code>
|
||||
📱 <b>Username:</b> @{user.username or 'отсутствует'}
|
||||
📱 <b>Username:</b> @{getattr(user, 'username', None) or 'отсутствует'}
|
||||
👥 <b>Статус:</b> {user_status}
|
||||
|
||||
{promo_block}
|
||||
@@ -264,11 +289,11 @@ class AdminNotificationService:
|
||||
📱 Устройства: {subscription.device_limit}
|
||||
🌐 Серверы: {servers_info}
|
||||
|
||||
📆 <b>Действует до:</b> {subscription.end_date.strftime('%d.%m.%Y %H:%M')}
|
||||
📆 <b>Действует до:</b> {format_local_datetime(subscription.end_date, '%d.%m.%Y %H:%M')}
|
||||
💰 <b>Баланс после покупки:</b> {settings.format_price(user.balance_kopeks)}
|
||||
🔗 <b>Реферер:</b> {referrer_info}
|
||||
|
||||
⏰ <i>{datetime.now().strftime('%d.%m.%Y %H:%M:%S')}</i>"""
|
||||
⏰ <i>{format_local_datetime(datetime.utcnow(), '%d.%m.%Y %H:%M:%S')}</i>"""
|
||||
|
||||
return await self._send_message(message)
|
||||
|
||||
@@ -315,7 +340,7 @@ class AdminNotificationService:
|
||||
|
||||
ℹ️ Для обновления перезапустите контейнер с новым тегом или обновите код из репозитория.
|
||||
|
||||
⚙️ <i>Автоматическая проверка обновлений • {datetime.now().strftime('%d.%m.%Y %H:%M:%S')}</i>"""
|
||||
⚙️ <i>Автоматическая проверка обновлений • {format_local_datetime(datetime.utcnow(), '%d.%m.%Y %H:%M:%S')}</i>"""
|
||||
|
||||
return await self._send_message(message)
|
||||
|
||||
@@ -340,7 +365,7 @@ class AdminNotificationService:
|
||||
🔄 Следующая попытка через час.
|
||||
⚙️ Проверьте доступность GitHub API и настройки сети.
|
||||
|
||||
⚙️ <i>Система автоматических обновлений • {datetime.now().strftime('%d.%m.%Y %H:%M:%S')}</i>"""
|
||||
⚙️ <i>Система автоматических обновлений • {format_local_datetime(datetime.utcnow(), '%d.%m.%Y %H:%M:%S')}</i>"""
|
||||
|
||||
return await self._send_message(message)
|
||||
|
||||
@@ -363,13 +388,14 @@ 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 = datetime.now().strftime('%d.%m.%Y %H:%M:%S')
|
||||
timestamp = format_local_datetime(datetime.utcnow(), '%d.%m.%Y %H:%M:%S')
|
||||
user_display = self._get_user_display(user)
|
||||
|
||||
return f"""💰 <b>ПОПОЛНЕНИЕ БАЛАНСА</b>
|
||||
|
||||
👤 <b>Пользователь:</b> {user.full_name}
|
||||
👤 <b>Пользователь:</b> {user_display}
|
||||
🆔 <b>Telegram ID:</b> <code>{user.telegram_id}</code>
|
||||
📱 <b>Username:</b> @{user.username or 'отсутствует'}
|
||||
📱 <b>Username:</b> @{getattr(user, 'username', None) or 'отсутствует'}
|
||||
💳 <b>Статус:</b> {topup_status}
|
||||
|
||||
{promo_block}
|
||||
@@ -540,15 +566,16 @@ 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.full_name}
|
||||
👤 <b>Пользователь:</b> {user_display}
|
||||
🆔 <b>Telegram ID:</b> <code>{user.telegram_id}</code>
|
||||
📱 <b>Username:</b> @{user.username or 'отсутствует'}
|
||||
📱 <b>Username:</b> @{getattr(user, 'username', None) or 'отсутствует'}
|
||||
|
||||
{promo_block}
|
||||
|
||||
@@ -559,8 +586,8 @@ class AdminNotificationService:
|
||||
|
||||
📅 <b>Продление:</b>
|
||||
➕ Добавлено дней: {extended_days}
|
||||
📆 Было до: {old_end_date.strftime('%d.%m.%Y %H:%M')}
|
||||
📆 Стало до: {current_end_date.strftime('%d.%m.%Y %H:%M')}
|
||||
📆 Было до: {format_local_datetime(old_end_date, '%d.%m.%Y %H:%M')}
|
||||
📆 Стало до: {format_local_datetime(current_end_date, '%d.%m.%Y %H:%M')}
|
||||
|
||||
📱 <b>Текущие параметры:</b>
|
||||
📊 Трафик: {self._format_traffic(subscription.traffic_limit_gb)}
|
||||
@@ -569,7 +596,7 @@ class AdminNotificationService:
|
||||
|
||||
💰 <b>Баланс после операции:</b> {settings.format_price(current_balance)}
|
||||
|
||||
⏰ <i>{datetime.now().strftime('%d.%m.%Y %H:%M:%S')}</i>"""
|
||||
⏰ <i>{format_local_datetime(datetime.utcnow(), '%d.%m.%Y %H:%M:%S')}</i>"""
|
||||
|
||||
return await self._send_message(message)
|
||||
|
||||
@@ -592,13 +619,14 @@ 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.full_name}",
|
||||
f"👤 <b>Пользователь:</b> {user_display}",
|
||||
f"🆔 <b>Telegram ID:</b> <code>{user.telegram_id}</code>",
|
||||
f"📱 <b>Username:</b> @{user.username or 'отсутствует'}",
|
||||
f"📱 <b>Username:</b> @{getattr(user, 'username', None) or 'отсутствует'}",
|
||||
"",
|
||||
promo_block,
|
||||
"",
|
||||
@@ -621,7 +649,7 @@ class AdminNotificationService:
|
||||
valid_until = promocode_data.get("valid_until")
|
||||
if valid_until:
|
||||
message_lines.append(
|
||||
f"⏳ Действует до: {valid_until.strftime('%d.%m.%Y %H:%M')}"
|
||||
f"⏳ Действует до: {format_local_datetime(valid_until, '%d.%m.%Y %H:%M')}"
|
||||
if isinstance(valid_until, datetime)
|
||||
else f"⏳ Действует до: {valid_until}"
|
||||
)
|
||||
@@ -632,7 +660,7 @@ class AdminNotificationService:
|
||||
"📝 <b>Эффект:</b>",
|
||||
effect_description.strip() or "✅ Промокод активирован",
|
||||
"",
|
||||
f"⏰ <i>{datetime.now().strftime('%d.%m.%Y %H:%M:%S')}</i>",
|
||||
f"⏰ <i>{format_local_datetime(datetime.utcnow(), '%d.%m.%Y %H:%M:%S')}</i>",
|
||||
]
|
||||
)
|
||||
|
||||
@@ -686,7 +714,7 @@ class AdminNotificationService:
|
||||
message_lines.extend(
|
||||
[
|
||||
"",
|
||||
f"⏰ <i>{datetime.now().strftime('%d.%m.%Y %H:%M:%S')}</i>",
|
||||
f"⏰ <i>{format_local_datetime(datetime.utcnow(), '%d.%m.%Y %H:%M:%S')}</i>",
|
||||
]
|
||||
)
|
||||
|
||||
@@ -719,13 +747,14 @@ class AdminNotificationService:
|
||||
)
|
||||
elif automatic:
|
||||
initiator_line = "🤖 Автоматическое назначение"
|
||||
user_display = self._get_user_display(user)
|
||||
|
||||
message_lines = [
|
||||
f"{title}",
|
||||
"",
|
||||
f"👤 <b>Пользователь:</b> {user.full_name}",
|
||||
f"👤 <b>Пользователь:</b> {user_display}",
|
||||
f"🆔 <b>Telegram ID:</b> <code>{user.telegram_id}</code>",
|
||||
f"📱 <b>Username:</b> @{user.username or 'отсутствует'}",
|
||||
f"📱 <b>Username:</b> @{getattr(user, 'username', None) or 'отсутствует'}",
|
||||
"",
|
||||
self._format_promo_group_block(new_group, title="Новая промогруппа", icon="🏆"),
|
||||
]
|
||||
@@ -750,7 +779,7 @@ class AdminNotificationService:
|
||||
[
|
||||
"",
|
||||
f"💰 Баланс пользователя: {settings.format_price(user.balance_kopeks)}",
|
||||
f"⏰ <i>{datetime.now().strftime('%d.%m.%Y %H:%M:%S')}</i>",
|
||||
f"⏰ <i>{format_local_datetime(datetime.utcnow(), '%d.%m.%Y %H:%M:%S')}</i>",
|
||||
]
|
||||
)
|
||||
|
||||
@@ -828,9 +857,9 @@ class AdminNotificationService:
|
||||
return "❌ Нет подписки"
|
||||
|
||||
if subscription.is_trial:
|
||||
return f"🎯 Триал (до {subscription.end_date.strftime('%d.%m')})"
|
||||
return f"🎯 Триал (до {format_local_datetime(subscription.end_date, '%d.%m')})"
|
||||
elif subscription.is_active:
|
||||
return f"✅ Активна (до {subscription.end_date.strftime('%d.%m')})"
|
||||
return f"✅ Активна (до {format_local_datetime(subscription.end_date, '%d.%m')})"
|
||||
else:
|
||||
return "❌ Неактивна"
|
||||
|
||||
@@ -901,7 +930,9 @@ class AdminNotificationService:
|
||||
if isinstance(enabled_at, str):
|
||||
from datetime import datetime
|
||||
enabled_at = datetime.fromisoformat(enabled_at)
|
||||
message_parts.append(f"🕐 <b>Время включения:</b> {enabled_at.strftime('%d.%m.%Y %H:%M:%S')}")
|
||||
message_parts.append(
|
||||
f"🕐 <b>Время включения:</b> {format_local_datetime(enabled_at, '%d.%m.%Y %H:%M:%S')}"
|
||||
)
|
||||
|
||||
message_parts.append(f"🤖 <b>Автоматически:</b> {'Да' if details.get('auto_enabled', False) else 'Нет'}")
|
||||
message_parts.append("")
|
||||
@@ -913,7 +944,9 @@ class AdminNotificationService:
|
||||
if isinstance(disabled_at, str):
|
||||
from datetime import datetime
|
||||
disabled_at = datetime.fromisoformat(disabled_at)
|
||||
message_parts.append(f"🕐 <b>Время отключения:</b> {disabled_at.strftime('%d.%m.%Y %H:%M:%S')}")
|
||||
message_parts.append(
|
||||
f"🕐 <b>Время отключения:</b> {format_local_datetime(disabled_at, '%d.%m.%Y %H:%M:%S')}"
|
||||
)
|
||||
|
||||
if details.get("duration"):
|
||||
duration = details["duration"]
|
||||
@@ -972,9 +1005,10 @@ class AdminNotificationService:
|
||||
else:
|
||||
message_parts.append("Автоматический мониторинг API остановлен.")
|
||||
|
||||
from datetime import datetime
|
||||
message_parts.append("")
|
||||
message_parts.append(f"⏰ <i>{datetime.now().strftime('%d.%m.%Y %H:%M:%S')}</i>")
|
||||
message_parts.append(
|
||||
f"⏰ <i>{format_local_datetime(datetime.utcnow(), '%d.%m.%Y %H:%M:%S')}</i>"
|
||||
)
|
||||
|
||||
message = "\n".join(message_parts)
|
||||
|
||||
@@ -1020,7 +1054,9 @@ class AdminNotificationService:
|
||||
if isinstance(last_check, str):
|
||||
from datetime import datetime
|
||||
last_check = datetime.fromisoformat(last_check)
|
||||
message_parts.append(f"🕐 <b>Последняя проверка:</b> {last_check.strftime('%H:%M:%S')}")
|
||||
message_parts.append(
|
||||
f"🕐 <b>Последняя проверка:</b> {format_local_datetime(last_check, '%H:%M:%S')}"
|
||||
)
|
||||
|
||||
if status == "online":
|
||||
if details.get("uptime"):
|
||||
@@ -1066,9 +1102,10 @@ class AdminNotificationService:
|
||||
message_parts.append("")
|
||||
message_parts.append("Панель временно недоступна для обслуживания.")
|
||||
|
||||
from datetime import datetime
|
||||
message_parts.append("")
|
||||
message_parts.append(f"⏰ <i>{datetime.now().strftime('%d.%m.%Y %H:%M:%S')}</i>")
|
||||
message_parts.append(
|
||||
f"⏰ <i>{format_local_datetime(datetime.utcnow(), '%d.%m.%Y %H:%M:%S')}</i>"
|
||||
)
|
||||
|
||||
message = "\n".join(message_parts)
|
||||
|
||||
@@ -1095,6 +1132,7 @@ 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": ("📊 ИЗМЕНЕНИЕ ТРАФИКА", "трафик"),
|
||||
@@ -1107,9 +1145,9 @@ class AdminNotificationService:
|
||||
message_lines = [
|
||||
f"{title}",
|
||||
"",
|
||||
f"👤 <b>Пользователь:</b> {user.full_name}",
|
||||
f"👤 <b>Пользователь:</b> {user_display}",
|
||||
f"🆔 <b>Telegram ID:</b> <code>{user.telegram_id}</code>",
|
||||
f"📱 <b>Username:</b> @{user.username or 'отсутствует'}",
|
||||
f"📱 <b>Username:</b> @{getattr(user, 'username', None) or 'отсутствует'}",
|
||||
"",
|
||||
promo_block,
|
||||
"",
|
||||
@@ -1142,11 +1180,11 @@ class AdminNotificationService:
|
||||
message_lines.extend(
|
||||
[
|
||||
"",
|
||||
f"📅 <b>Подписка действует до:</b> {subscription.end_date.strftime('%d.%m.%Y %H:%M')}",
|
||||
f"📅 <b>Подписка действует до:</b> {format_local_datetime(subscription.end_date, '%d.%m.%Y %H:%M')}",
|
||||
f"💰 <b>Баланс после операции:</b> {settings.format_price(user.balance_kopeks)}",
|
||||
f"🔗 <b>Рефер:</b> {referrer_info}",
|
||||
"",
|
||||
f"⏰ <i>{datetime.now().strftime('%d.%m.%Y %H:%M:%S')}</i>",
|
||||
f"⏰ <i>{format_local_datetime(datetime.utcnow(), '%d.%m.%Y %H:%M:%S')}</i>",
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
@@ -120,9 +120,9 @@ class AdvertisingCampaignService:
|
||||
return CampaignBonusResult(success=False)
|
||||
|
||||
traffic_limit = campaign.subscription_traffic_gb
|
||||
device_limit = (
|
||||
campaign.subscription_device_limit or settings.DEFAULT_DEVICE_LIMIT
|
||||
)
|
||||
device_limit = campaign.subscription_device_limit
|
||||
if device_limit is None:
|
||||
device_limit = settings.DEFAULT_DEVICE_LIMIT
|
||||
squads = list(campaign.subscription_squads or [])
|
||||
|
||||
if not squads:
|
||||
|
||||
@@ -7,6 +7,7 @@ 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__)
|
||||
|
||||
@@ -45,6 +46,9 @@ 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"""
|
||||
🔧 Технические работы!
|
||||
|
||||
@@ -52,7 +56,7 @@ class MaintenanceService:
|
||||
|
||||
⏰ Мы работаем над восстановлением. Попробуйте через несколько минут.
|
||||
|
||||
🔄 Последняя проверка: {self._status.last_check.strftime('%H:%M:%S') if self._status.last_check else 'неизвестно'}
|
||||
🔄 Последняя проверка: {last_check_display}
|
||||
"""
|
||||
else:
|
||||
return settings.get_maintenance_message()
|
||||
@@ -79,7 +83,12 @@ class MaintenanceService:
|
||||
}
|
||||
emoji = emoji_map.get(alert_type, "ℹ️")
|
||||
|
||||
formatted_message = f"{emoji} <b>ТЕХНИЧЕСКИЕ РАБОТЫ</b>\n\n{message}\n\n⏰ <i>{datetime.now().strftime('%d.%m.%Y %H:%M:%S')}</i>"
|
||||
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>"
|
||||
)
|
||||
|
||||
return await notification_service._send_message(formatted_message)
|
||||
|
||||
@@ -152,11 +161,14 @@ 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> {self._status.enabled_at.strftime('%d.%m.%Y %H:%M:%S')}
|
||||
🕐 <b>Время:</b> {enabled_time}
|
||||
|
||||
Обычные пользователи временно не смогут использовать бота."""
|
||||
|
||||
@@ -197,10 +209,13 @@ 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> {datetime.utcnow().strftime('%d.%m.%Y %H:%M:%S')}
|
||||
🕐 <b>Время:</b> {notification_time}
|
||||
{duration_str}
|
||||
|
||||
Сервис снова доступен для пользователей."""
|
||||
@@ -223,15 +238,23 @@ class MaintenanceService:
|
||||
await self._load_status_from_cache()
|
||||
|
||||
self._check_task = asyncio.create_task(self._monitoring_loop())
|
||||
logger.info(f"🔄 Запущен мониторинг API Remnawave (интервал: {settings.get_maintenance_check_interval()}с)")
|
||||
|
||||
await self._notify_admins(f"""Мониторинг технических работ запущен
|
||||
logger.info(
|
||||
"🔄 Запущен мониторинг API Remnawave (интервал: %sс, попыток: %s)",
|
||||
settings.get_maintenance_check_interval(),
|
||||
settings.get_maintenance_retry_attempts(),
|
||||
)
|
||||
|
||||
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
|
||||
|
||||
@@ -260,10 +283,10 @@ 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()
|
||||
api = RemnaWaveAPI(
|
||||
base_url=auth_params["base_url"],
|
||||
@@ -272,63 +295,100 @@ class MaintenanceService:
|
||||
username=auth_params["username"],
|
||||
password=auth_params["password"]
|
||||
)
|
||||
|
||||
|
||||
attempts = settings.get_maintenance_retry_attempts()
|
||||
|
||||
async with api:
|
||||
is_connected = await test_api_connection(api)
|
||||
|
||||
if is_connected:
|
||||
if not self._status.api_status:
|
||||
await self._notify_admins(f"""API Remnawave восстановлено!
|
||||
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 восстановлено!
|
||||
|
||||
✅ <b>Статус:</b> Доступно
|
||||
🕐 <b>Время восстановления:</b> {self._status.last_check.strftime('%H:%M:%S')}
|
||||
🕐 <b>Время восстановления:</b> {recovery_time}
|
||||
🔄 <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
|
||||
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 недоступно!
|
||||
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 недоступно!
|
||||
|
||||
❌ <b>Статус:</b> Недоступно
|
||||
🕐 <b>Время обнаружения:</b> {self._status.last_check.strftime('%H:%M:%S')}
|
||||
🕐 <b>Время обнаружения:</b> {detection_time}
|
||||
🔄 <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:
|
||||
await self._notify_admins(f"""Ошибка при проверке API Remnawave
|
||||
error_time = format_local_datetime(datetime.utcnow(), "%H:%M:%S %Z")
|
||||
await self._notify_admins(
|
||||
f"""Ошибка при проверке API Remnawave
|
||||
|
||||
❌ <b>Ошибка:</b> {str(e)}
|
||||
🕐 <b>Время:</b> {datetime.utcnow().strftime('%H:%M:%S')}
|
||||
🕐 <b>Время:</b> {error_time}
|
||||
|
||||
Не удалось выполнить проверку доступности API.""", "error")
|
||||
Не удалось выполнить проверку доступности API.""",
|
||||
"error",
|
||||
)
|
||||
|
||||
self._status.api_status = False
|
||||
self._status.consecutive_failures += 1
|
||||
@@ -398,6 +458,7 @@ API снова отвечает на запросы.""", "success")
|
||||
"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,6 +38,10 @@ 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
|
||||
from app.localization.texts import get_texts
|
||||
from app.services.notification_settings_service import NotificationSettingsService
|
||||
@@ -283,20 +287,26 @@ class MonitoringService:
|
||||
logger.info(f"📝 Статус подписки {subscription.id} обновлен на 'expired'")
|
||||
|
||||
async with self.api as api:
|
||||
updated_user = await api.update_user(
|
||||
hwid_limit = resolve_hwid_device_limit_for_payload(subscription)
|
||||
|
||||
update_kwargs = dict(
|
||||
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
|
||||
@@ -1026,7 +1036,7 @@ class MonitoringService:
|
||||
message = f"""
|
||||
⚠️ <b>Подписка истекает через {days_text}!</b>
|
||||
|
||||
Ваша платная подписка истекает {subscription.end_date.strftime("%d.%m.%Y %H:%M")}.
|
||||
Ваша платная подписка истекает {format_local_datetime(subscription.end_date, "%d.%m.%Y %H:%M")}.
|
||||
|
||||
💳 <b>Автоплатеж:</b> {autopay_status}
|
||||
|
||||
@@ -1142,7 +1152,7 @@ class MonitoringService:
|
||||
|
||||
message = template.format(
|
||||
price=settings.format_price(settings.PRICE_30_DAYS),
|
||||
end_date=subscription.end_date.strftime("%d.%m.%Y %H:%M"),
|
||||
end_date=format_local_datetime(subscription.end_date, "%d.%m.%Y %H:%M"),
|
||||
)
|
||||
|
||||
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
|
||||
@@ -1258,7 +1268,7 @@ class MonitoringService:
|
||||
),
|
||||
)
|
||||
message = template.format(
|
||||
end_date=subscription.end_date.strftime("%d.%m.%Y %H:%M"),
|
||||
end_date=format_local_datetime(subscription.end_date, "%d.%m.%Y %H:%M"),
|
||||
price=settings.format_price(settings.PRICE_30_DAYS),
|
||||
)
|
||||
|
||||
@@ -1335,7 +1345,7 @@ class MonitoringService:
|
||||
|
||||
message = template.format(
|
||||
percent=percent,
|
||||
expires_at=expires_at.strftime("%d.%m.%Y %H:%M"),
|
||||
expires_at=format_local_datetime(expires_at, "%d.%m.%Y %H:%M"),
|
||||
trigger_days=trigger_days or "",
|
||||
)
|
||||
|
||||
@@ -1759,26 +1769,43 @@ class MonitoringService:
|
||||
return []
|
||||
|
||||
async def get_monitoring_logs_count(
|
||||
self,
|
||||
self,
|
||||
db: AsyncSession,
|
||||
event_type: Optional[str] = None
|
||||
) -> int:
|
||||
try:
|
||||
from sqlalchemy import select, func
|
||||
|
||||
|
||||
query = select(func.count(MonitoringLog.id))
|
||||
|
||||
|
||||
if event_type:
|
||||
query = query.where(MonitoringLog.event_type == event_type)
|
||||
|
||||
|
||||
result = await db.execute(query)
|
||||
count = result.scalar()
|
||||
|
||||
|
||||
return count or 0
|
||||
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка получения количества логов: {e}")
|
||||
return 0
|
||||
|
||||
async def get_monitoring_event_types(self, db: AsyncSession) -> List[str]:
|
||||
try:
|
||||
from sqlalchemy import select
|
||||
|
||||
result = await db.execute(
|
||||
select(MonitoringLog.event_type)
|
||||
.where(MonitoringLog.event_type.isnot(None))
|
||||
.distinct()
|
||||
.order_by(MonitoringLog.event_type)
|
||||
)
|
||||
|
||||
return [row[0] for row in result.fetchall() if row[0]]
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка получения списка типов событий мониторинга: {e}")
|
||||
return []
|
||||
|
||||
async def cleanup_old_logs(self, db: AsyncSession, days: int = 30) -> int:
|
||||
try:
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import asyncio
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
from typing import Optional, Dict, Any
|
||||
from typing import Optional, Dict, Any, Tuple
|
||||
|
||||
import aiohttp
|
||||
|
||||
@@ -17,6 +19,10 @@ class MulenPayService:
|
||||
self.shop_id = settings.MULENPAY_SHOP_ID
|
||||
self.secret_key = settings.MULENPAY_SECRET_KEY
|
||||
self.base_url = settings.MULENPAY_BASE_URL.rstrip("/")
|
||||
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:
|
||||
@@ -45,31 +51,115 @@ class MulenPayService:
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
try:
|
||||
timeout = aiohttp.ClientTimeout(total=30)
|
||||
async with aiohttp.ClientSession(timeout=timeout) as session:
|
||||
async with session.request(
|
||||
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,
|
||||
headers=headers,
|
||||
json=json_data,
|
||||
params=params,
|
||||
) as response:
|
||||
data, raw_text = await self._deserialize_response(response)
|
||||
|
||||
if response.status >= 400:
|
||||
logger.error(
|
||||
"MulenPay API error %s %s: %s",
|
||||
response.status,
|
||||
endpoint,
|
||||
raw_text,
|
||||
)
|
||||
if (
|
||||
response.status in self._retryable_statuses
|
||||
and attempt < self._max_retries
|
||||
):
|
||||
await self._sleep_with_backoff(attempt)
|
||||
continue
|
||||
return None
|
||||
|
||||
if data is None:
|
||||
if raw_text:
|
||||
logger.warning(
|
||||
"MulenPay returned unexpected payload for %s: %s",
|
||||
endpoint,
|
||||
raw_text,
|
||||
)
|
||||
return None
|
||||
|
||||
return data
|
||||
except asyncio.CancelledError:
|
||||
logger.debug("MulenPay request cancelled: %s %s", method, endpoint)
|
||||
raise
|
||||
except asyncio.TimeoutError as error:
|
||||
last_error = error
|
||||
logger.warning(
|
||||
"MulenPay request timeout (%s %s) attempt %s/%s",
|
||||
method,
|
||||
url,
|
||||
headers=headers,
|
||||
json=json_data,
|
||||
params=params,
|
||||
) as response:
|
||||
data = await response.json(content_type=None)
|
||||
endpoint,
|
||||
attempt,
|
||||
self._max_retries,
|
||||
)
|
||||
except aiohttp.ClientError as error:
|
||||
last_error = error
|
||||
logger.warning(
|
||||
"MulenPay client error (%s %s) attempt %s/%s: %s",
|
||||
method,
|
||||
endpoint,
|
||||
attempt,
|
||||
self._max_retries,
|
||||
error,
|
||||
)
|
||||
except Exception as error: # pragma: no cover - safety
|
||||
logger.error("Unexpected MulenPay error: %s", error, exc_info=True)
|
||||
return None
|
||||
|
||||
if response.status >= 400:
|
||||
logger.error(
|
||||
"MulenPay API error %s %s: %s", response.status, endpoint, data
|
||||
)
|
||||
return None
|
||||
if attempt < self._max_retries:
|
||||
await self._sleep_with_backoff(attempt)
|
||||
|
||||
return data
|
||||
except aiohttp.ClientError as error:
|
||||
logger.error("MulenPay API request error: %s", error)
|
||||
return None
|
||||
except Exception as error: # pragma: no cover - safety
|
||||
logger.error("Unexpected MulenPay error: %s", error, exc_info=True)
|
||||
return None
|
||||
if isinstance(last_error, asyncio.TimeoutError):
|
||||
logger.error(
|
||||
"MulenPay request timed out after %s attempts: %s %s",
|
||||
self._max_retries,
|
||||
method,
|
||||
endpoint,
|
||||
)
|
||||
elif last_error is not None:
|
||||
logger.error(
|
||||
"MulenPay request failed after %s attempts (%s %s): %s",
|
||||
self._max_retries,
|
||||
method,
|
||||
endpoint,
|
||||
last_error,
|
||||
)
|
||||
|
||||
return None
|
||||
|
||||
async def _sleep_with_backoff(self, attempt: int) -> None:
|
||||
await asyncio.sleep(self._retry_delay * attempt)
|
||||
|
||||
async def _deserialize_response(
|
||||
self, 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 MulenPay JSON response %s: %s",
|
||||
response.url,
|
||||
error,
|
||||
)
|
||||
return None, raw_text
|
||||
|
||||
return None, raw_text
|
||||
|
||||
@staticmethod
|
||||
def _format_amount(amount_kopeks: int) -> str:
|
||||
@@ -124,3 +214,21 @@ class MulenPayService:
|
||||
|
||||
async def get_payment(self, payment_id: int) -> Optional[Dict[str, Any]]:
|
||||
return await self._request("GET", f"/v2/payments/{payment_id}")
|
||||
|
||||
async def list_payments(
|
||||
self,
|
||||
*,
|
||||
offset: int = 0,
|
||||
limit: int = 100,
|
||||
uuid: Optional[str] = None,
|
||||
status: Optional[int] = None,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
params = {
|
||||
"offset": max(0, offset),
|
||||
"limit": max(1, min(limit, 1000)),
|
||||
}
|
||||
if uuid:
|
||||
params["uuid"] = uuid
|
||||
if status is not None:
|
||||
params["status"] = status
|
||||
return await self._request("GET", "/v2/payments", params=params)
|
||||
|
||||
@@ -83,6 +83,12 @@ 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_postback(payload: Dict[str, Any]) -> Dict[str, Any]:
|
||||
required_fields = ["InvId", "OutSum", "Status", "SignatureValue"]
|
||||
|
||||
@@ -11,6 +11,9 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.models import PaymentMethod, TransactionType
|
||||
from app.services.subscription_auto_purchase_service import (
|
||||
auto_purchase_saved_cart_after_topup,
|
||||
)
|
||||
from app.utils.currency_converter import currency_converter
|
||||
from app.utils.user_utils import format_referrer_info
|
||||
|
||||
@@ -294,9 +297,29 @@ class CryptoBotPaymentMixin:
|
||||
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
|
||||
|
||||
@@ -326,7 +349,10 @@ class CryptoBotPaymentMixin:
|
||||
text=f"✅ Баланс пополнен на {settings.format_price(payment.amount_kopeks)}!\n\n{cart_message}",
|
||||
reply_markup=keyboard
|
||||
)
|
||||
logger.info(f"Отправлено уведомление с кнопкой возврата к оформлению подписки пользователю {user.id}")
|
||||
logger.info(
|
||||
"Отправлено уведомление с кнопкой возврата к оформлению подписки пользователю %s",
|
||||
user.id,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при работе с сохраненной корзиной для пользователя {user.id}: {e}", exc_info=True)
|
||||
|
||||
@@ -337,3 +363,83 @@ class CryptoBotPaymentMixin:
|
||||
"Ошибка обработки CryptoBot webhook: %s", error, exc_info=True
|
||||
)
|
||||
return False
|
||||
|
||||
async def get_cryptobot_payment_status(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
local_payment_id: int,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""Запрашивает актуальный статус CryptoBot invoice и синхронизирует его."""
|
||||
|
||||
cryptobot_crud = import_module("app.database.crud.cryptobot")
|
||||
payment = await cryptobot_crud.get_cryptobot_payment_by_id(db, local_payment_id)
|
||||
if not payment:
|
||||
logger.warning("CryptoBot платеж %s не найден", local_payment_id)
|
||||
return None
|
||||
|
||||
if not self.cryptobot_service:
|
||||
logger.warning("CryptoBot сервис не инициализирован для ручной проверки")
|
||||
return {"payment": payment}
|
||||
|
||||
invoice_id = payment.invoice_id
|
||||
try:
|
||||
invoices = await self.cryptobot_service.get_invoices(
|
||||
invoice_ids=[invoice_id]
|
||||
)
|
||||
except Exception as error: # pragma: no cover - network errors
|
||||
logger.error(
|
||||
"Ошибка запроса статуса CryptoBot invoice %s: %s",
|
||||
invoice_id,
|
||||
error,
|
||||
)
|
||||
return {"payment": payment}
|
||||
|
||||
remote_invoice: Optional[Dict[str, Any]] = None
|
||||
if invoices:
|
||||
for item in invoices:
|
||||
if str(item.get("invoice_id")) == str(invoice_id):
|
||||
remote_invoice = item
|
||||
break
|
||||
|
||||
if not remote_invoice:
|
||||
logger.info(
|
||||
"CryptoBot invoice %s не найден через API при ручной проверке",
|
||||
invoice_id,
|
||||
)
|
||||
refreshed = await cryptobot_crud.get_cryptobot_payment_by_id(db, local_payment_id)
|
||||
return {"payment": refreshed or payment}
|
||||
|
||||
status = (remote_invoice.get("status") or "").lower()
|
||||
paid_at_str = remote_invoice.get("paid_at")
|
||||
paid_at = None
|
||||
if paid_at_str:
|
||||
try:
|
||||
paid_at = datetime.fromisoformat(paid_at_str.replace("Z", "+00:00")).replace(
|
||||
tzinfo=None
|
||||
)
|
||||
except Exception: # pragma: no cover - defensive parsing
|
||||
paid_at = None
|
||||
|
||||
if status == "paid":
|
||||
webhook_payload = {
|
||||
"update_type": "invoice_paid",
|
||||
"payload": {
|
||||
"invoice_id": remote_invoice.get("invoice_id") or invoice_id,
|
||||
"amount": remote_invoice.get("amount") or payment.amount,
|
||||
"asset": remote_invoice.get("asset") or payment.asset,
|
||||
"paid_at": paid_at_str,
|
||||
"payload": remote_invoice.get("payload") or payment.payload,
|
||||
},
|
||||
}
|
||||
await self.process_cryptobot_webhook(db, webhook_payload)
|
||||
else:
|
||||
if status and status != (payment.status or "").lower():
|
||||
await cryptobot_crud.update_cryptobot_payment_status(
|
||||
db,
|
||||
invoice_id,
|
||||
status,
|
||||
paid_at,
|
||||
)
|
||||
|
||||
refreshed = await cryptobot_crud.get_cryptobot_payment_by_id(db, local_payment_id)
|
||||
return {"payment": refreshed or payment}
|
||||
|
||||
+155
-14
@@ -5,7 +5,7 @@ from __future__ import annotations
|
||||
import logging
|
||||
import secrets
|
||||
import time
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timezone, timedelta
|
||||
from importlib import import_module
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
@@ -170,14 +170,16 @@ class HeleketPaymentMixin:
|
||||
"discount_percent": discount_percent,
|
||||
}
|
||||
|
||||
async def process_heleket_webhook(
|
||||
async def _process_heleket_payload(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
payload: Dict[str, Any],
|
||||
) -> bool:
|
||||
*,
|
||||
metadata_key: str,
|
||||
) -> Optional["HeleketPayment"]:
|
||||
if not isinstance(payload, dict):
|
||||
logger.error("Heleket webhook payload не является словарём: %s", payload)
|
||||
return False
|
||||
return None
|
||||
|
||||
heleket_crud = import_module("app.database.crud.heleket")
|
||||
payment_module = import_module("app.services.payment_service")
|
||||
@@ -188,7 +190,7 @@ class HeleketPaymentMixin:
|
||||
|
||||
if not uuid and not order_id:
|
||||
logger.error("Heleket webhook без uuid/order_id: %s", payload)
|
||||
return False
|
||||
return None
|
||||
|
||||
payment = None
|
||||
if uuid:
|
||||
@@ -202,7 +204,7 @@ class HeleketPaymentMixin:
|
||||
uuid,
|
||||
order_id,
|
||||
)
|
||||
return False
|
||||
return None
|
||||
|
||||
payer_amount = payload.get("payer_amount") or payload.get("payment_amount")
|
||||
payer_currency = payload.get("payer_currency") or payload.get("currency")
|
||||
@@ -234,6 +236,9 @@ class HeleketPaymentMixin:
|
||||
except (ValueError, TypeError):
|
||||
paid_at = None
|
||||
|
||||
if paid_at and paid_at.tzinfo is not None:
|
||||
paid_at = paid_at.astimezone(timezone.utc).replace(tzinfo=None)
|
||||
|
||||
updated_payment = await heleket_crud.update_heleket_payment(
|
||||
db,
|
||||
payment.uuid,
|
||||
@@ -244,11 +249,11 @@ class HeleketPaymentMixin:
|
||||
discount_percent=int(discount_percent) if isinstance(discount_percent, (int, float)) else None,
|
||||
paid_at=paid_at,
|
||||
payment_url=payment_url,
|
||||
metadata={"last_webhook": payload},
|
||||
metadata={metadata_key: payload},
|
||||
)
|
||||
|
||||
if updated_payment is None:
|
||||
return False
|
||||
return None
|
||||
|
||||
if updated_payment.transaction_id:
|
||||
logger.info(
|
||||
@@ -256,17 +261,17 @@ class HeleketPaymentMixin:
|
||||
updated_payment.uuid,
|
||||
updated_payment.transaction_id,
|
||||
)
|
||||
return True
|
||||
return updated_payment
|
||||
|
||||
status_normalized = (status or "").lower()
|
||||
if status_normalized not in {"paid", "paid_over"}:
|
||||
logger.info("Heleket платеж %s в статусе %s, зачисление не требуется", updated_payment.uuid, status)
|
||||
return True
|
||||
return updated_payment
|
||||
|
||||
amount_kopeks = updated_payment.amount_kopeks
|
||||
if amount_kopeks <= 0:
|
||||
logger.error("Heleket платеж %s имеет некорректную сумму: %s", updated_payment.uuid, updated_payment.amount)
|
||||
return False
|
||||
return None
|
||||
|
||||
transaction = await payment_module.create_transaction(
|
||||
db,
|
||||
@@ -286,13 +291,19 @@ class HeleketPaymentMixin:
|
||||
is_completed=True,
|
||||
)
|
||||
|
||||
await heleket_crud.link_heleket_payment_to_transaction(db, updated_payment.uuid, transaction.id)
|
||||
linked_payment = await heleket_crud.link_heleket_payment_to_transaction(
|
||||
db,
|
||||
updated_payment.uuid,
|
||||
transaction.id,
|
||||
)
|
||||
if linked_payment:
|
||||
updated_payment = linked_payment
|
||||
|
||||
get_user_by_id = payment_module.get_user_by_id
|
||||
user = await get_user_by_id(db, updated_payment.user_id)
|
||||
if not user:
|
||||
logger.error("Пользователь %s не найден для Heleket платежа", updated_payment.user_id)
|
||||
return False
|
||||
return None
|
||||
|
||||
old_balance = user.balance_kopeks
|
||||
was_first_topup = not user.has_made_first_topup
|
||||
@@ -374,4 +385,134 @@ class HeleketPaymentMixin:
|
||||
except Exception as error: # pragma: no cover
|
||||
logger.error("Ошибка отправки уведомления пользователю Heleket: %s", error)
|
||||
|
||||
return True
|
||||
return updated_payment
|
||||
|
||||
async def process_heleket_webhook(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
payload: Dict[str, Any],
|
||||
) -> bool:
|
||||
result = await self._process_heleket_payload(
|
||||
db,
|
||||
payload,
|
||||
metadata_key="last_webhook",
|
||||
)
|
||||
|
||||
return result is not None
|
||||
|
||||
async def sync_heleket_payment_status(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
*,
|
||||
local_payment_id: int,
|
||||
) -> Optional["HeleketPayment"]:
|
||||
if not getattr(self, "heleket_service", None):
|
||||
logger.error("Heleket сервис не инициализирован")
|
||||
return None
|
||||
|
||||
heleket_crud = import_module("app.database.crud.heleket")
|
||||
|
||||
payment = await heleket_crud.get_heleket_payment_by_id(db, local_payment_id)
|
||||
if not payment:
|
||||
logger.error("Heleket платеж с id=%s не найден", local_payment_id)
|
||||
return None
|
||||
|
||||
payload: Optional[Dict[str, Any]] = None
|
||||
try:
|
||||
response = await self.heleket_service.get_payment_info( # type: ignore[union-attr]
|
||||
uuid=payment.uuid,
|
||||
order_id=payment.order_id,
|
||||
)
|
||||
except Exception as error: # pragma: no cover - defensive
|
||||
logger.exception(
|
||||
"Ошибка получения статуса Heleket платежа %s: %s",
|
||||
payment.uuid,
|
||||
error,
|
||||
)
|
||||
else:
|
||||
if response:
|
||||
result = response.get("result") if isinstance(response, dict) else None
|
||||
if isinstance(result, dict):
|
||||
payload = dict(result)
|
||||
else:
|
||||
logger.error(
|
||||
"Некорректный ответ Heleket API при проверке платежа %s: %s",
|
||||
payment.uuid,
|
||||
response,
|
||||
)
|
||||
|
||||
if payload is None:
|
||||
fallback = await self._lookup_heleket_payment_history(payment)
|
||||
if not fallback:
|
||||
logger.warning(
|
||||
"Heleket API не вернул информацию по платежу %s",
|
||||
payment.uuid,
|
||||
)
|
||||
return payment
|
||||
payload = dict(fallback)
|
||||
|
||||
payload.setdefault("uuid", payment.uuid)
|
||||
payload.setdefault("order_id", payment.order_id)
|
||||
|
||||
updated_payment = await self._process_heleket_payload(
|
||||
db,
|
||||
payload,
|
||||
metadata_key="last_status_check",
|
||||
)
|
||||
|
||||
return updated_payment or payment
|
||||
|
||||
async def _lookup_heleket_payment_history(
|
||||
self,
|
||||
payment: "HeleketPayment",
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
service = getattr(self, "heleket_service", None)
|
||||
if not service:
|
||||
return None
|
||||
|
||||
created_at = getattr(payment, "created_at", None)
|
||||
date_from_str: Optional[str] = None
|
||||
date_to_str: Optional[str] = None
|
||||
if isinstance(created_at, datetime):
|
||||
start = created_at - timedelta(days=2)
|
||||
end = created_at + timedelta(days=2)
|
||||
date_from_str = start.strftime("%Y-%m-%d %H:%M:%S")
|
||||
date_to_str = end.strftime("%Y-%m-%d %H:%M:%S")
|
||||
|
||||
cursor: Optional[str] = None
|
||||
for _ in range(10):
|
||||
response = await service.list_payments(
|
||||
date_from=date_from_str,
|
||||
date_to=date_to_str,
|
||||
cursor=cursor,
|
||||
)
|
||||
if not response or not isinstance(response, dict):
|
||||
return None
|
||||
|
||||
result = response.get("result")
|
||||
if not isinstance(result, dict):
|
||||
return None
|
||||
|
||||
items = result.get("items")
|
||||
if isinstance(items, list):
|
||||
for item in items:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
uuid = str(item.get("uuid") or "").strip()
|
||||
order_id = str(item.get("order_id") or "").strip()
|
||||
if uuid and uuid == str(payment.uuid):
|
||||
return item
|
||||
if order_id and order_id == str(payment.order_id):
|
||||
return item
|
||||
|
||||
paginate = result.get("paginate")
|
||||
cursor = None
|
||||
if isinstance(paginate, dict):
|
||||
next_cursor = paginate.get("nextCursor")
|
||||
if isinstance(next_cursor, str) and next_cursor:
|
||||
cursor = next_cursor
|
||||
|
||||
if not cursor:
|
||||
break
|
||||
|
||||
return None
|
||||
|
||||
@@ -11,6 +11,9 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.models import PaymentMethod, TransactionType
|
||||
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__)
|
||||
@@ -244,8 +247,25 @@ class MulenPayPaymentMixin:
|
||||
user,
|
||||
payment.amount_kopeks,
|
||||
f"Пополнение {display_name}: {payment.amount_kopeks // 100}₽",
|
||||
create_transaction=False,
|
||||
)
|
||||
|
||||
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(
|
||||
"Ошибка обработки реферального пополнения %s: %s",
|
||||
display_name,
|
||||
error,
|
||||
)
|
||||
|
||||
if was_first_topup and not user.has_made_first_topup:
|
||||
user.has_made_first_topup = True
|
||||
await db.commit()
|
||||
@@ -309,9 +329,29 @@ class MulenPayPaymentMixin:
|
||||
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
|
||||
|
||||
@@ -343,7 +383,10 @@ class MulenPayPaymentMixin:
|
||||
text=f"✅ Баланс пополнен на {settings.format_price(payment.amount_kopeks)}!\n\n{cart_message}",
|
||||
reply_markup=keyboard
|
||||
)
|
||||
logger.info(f"Отправлено уведомление с кнопкой возврата к оформлению подписки пользователю {user.id}")
|
||||
logger.info(
|
||||
"Отправлено уведомление с кнопкой возврата к оформлению подписки пользователю %s",
|
||||
user.id,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при работе с сохраненной корзиной для пользователя {user.id}: {e}", exc_info=True)
|
||||
|
||||
@@ -424,35 +467,59 @@ class MulenPayPaymentMixin:
|
||||
response = await self.mulenpay_service.get_payment(
|
||||
payment.mulen_payment_id
|
||||
)
|
||||
if response and response.get("success"):
|
||||
remote_data = response.get("payment")
|
||||
if isinstance(remote_data, dict):
|
||||
remote_status_code = remote_data.get("status")
|
||||
mapped_status = self._map_mulenpay_status(remote_status_code)
|
||||
if response:
|
||||
if isinstance(response, dict) and response.get("success"):
|
||||
remote_data = response.get("payment")
|
||||
elif isinstance(response, dict) and "status" in response and "id" in response:
|
||||
remote_data = response
|
||||
if not remote_data and getattr(self, "mulenpay_service", None):
|
||||
list_response = await self.mulenpay_service.list_payments(
|
||||
limit=100,
|
||||
uuid=payment.uuid,
|
||||
)
|
||||
items = []
|
||||
if isinstance(list_response, dict):
|
||||
items = list_response.get("items") or []
|
||||
if items:
|
||||
for candidate in items:
|
||||
if not isinstance(candidate, dict):
|
||||
continue
|
||||
candidate_id = candidate.get("id")
|
||||
candidate_uuid = candidate.get("uuid")
|
||||
if (
|
||||
(candidate_id is not None and candidate_id == payment.mulen_payment_id)
|
||||
or (candidate_uuid and candidate_uuid == payment.uuid)
|
||||
):
|
||||
remote_data = candidate
|
||||
break
|
||||
|
||||
if mapped_status == "success" and not payment.is_paid:
|
||||
await self.process_mulenpay_callback(
|
||||
db,
|
||||
{
|
||||
"uuid": payment.uuid,
|
||||
"payment_status": "success",
|
||||
"id": remote_data.get("id"),
|
||||
"amount": remote_data.get("amount"),
|
||||
},
|
||||
)
|
||||
payment = await payment_module.get_mulenpay_payment_by_local_id(
|
||||
db, local_payment_id
|
||||
)
|
||||
elif mapped_status and mapped_status != payment.status:
|
||||
await payment_module.update_mulenpay_payment_status(
|
||||
db,
|
||||
payment=payment,
|
||||
status=mapped_status,
|
||||
mulen_payment_id=remote_data.get("id"),
|
||||
)
|
||||
payment = await payment_module.get_mulenpay_payment_by_local_id(
|
||||
db, local_payment_id
|
||||
)
|
||||
if isinstance(remote_data, dict):
|
||||
remote_status_code = remote_data.get("status")
|
||||
mapped_status = self._map_mulenpay_status(remote_status_code)
|
||||
|
||||
if mapped_status == "success" and not payment.is_paid:
|
||||
await self.process_mulenpay_callback(
|
||||
db,
|
||||
{
|
||||
"uuid": payment.uuid,
|
||||
"payment_status": "success",
|
||||
"id": remote_data.get("id"),
|
||||
"amount": remote_data.get("amount"),
|
||||
},
|
||||
)
|
||||
payment = await payment_module.get_mulenpay_payment_by_local_id(
|
||||
db, local_payment_id
|
||||
)
|
||||
elif mapped_status and mapped_status != payment.status:
|
||||
await payment_module.update_mulenpay_payment_status(
|
||||
db,
|
||||
payment=payment,
|
||||
status=mapped_status,
|
||||
mulen_payment_id=remote_data.get("id"),
|
||||
)
|
||||
payment = await payment_module.get_mulenpay_payment_by_local_id(
|
||||
db, local_payment_id
|
||||
)
|
||||
|
||||
return {
|
||||
"payment": payment,
|
||||
|
||||
+219
-44
@@ -6,13 +6,16 @@ import logging
|
||||
from datetime import datetime
|
||||
from importlib import import_module
|
||||
import uuid
|
||||
from typing import Any, Dict, Optional
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.models import PaymentMethod, TransactionType
|
||||
from app.services.pal24_service import Pal24APIError
|
||||
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__)
|
||||
@@ -434,6 +437,25 @@ class Pal24PaymentMixin:
|
||||
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
|
||||
|
||||
@@ -475,6 +497,11 @@ class Pal24PaymentMixin:
|
||||
"Отправлено уведомление с кнопкой возврата к оформлению подписки пользователю %s",
|
||||
user.id,
|
||||
)
|
||||
else:
|
||||
logger.info(
|
||||
"У пользователя %s нет сохраненной корзины или автопокупка выполнена",
|
||||
user.id,
|
||||
)
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
"Ошибка при работе с сохраненной корзиной для пользователя %s: %s",
|
||||
@@ -507,53 +534,135 @@ class Pal24PaymentMixin:
|
||||
return None
|
||||
|
||||
remote_status: Optional[str] = None
|
||||
remote_data: Optional[Dict[str, Any]] = None
|
||||
remote_payloads: Dict[str, Any] = {}
|
||||
payment_info_candidates: List[Dict[str, Optional[str]]] = []
|
||||
|
||||
service = getattr(self, "pal24_service", None)
|
||||
if service and payment.bill_id:
|
||||
bill_id_str = str(payment.bill_id)
|
||||
try:
|
||||
response = await service.get_bill_status(payment.bill_id)
|
||||
remote_data = response
|
||||
remote_status = response.get("status") or response.get("bill", {}).get("status")
|
||||
|
||||
payment_info = self._extract_remote_payment_info(response)
|
||||
|
||||
if remote_status:
|
||||
normalized_remote = str(remote_status).upper()
|
||||
update_kwargs: Dict[str, Any] = {
|
||||
"status": normalized_remote,
|
||||
"payment_status": payment_info.get("status") or remote_status,
|
||||
}
|
||||
|
||||
if payment_info.get("id"):
|
||||
update_kwargs["payment_id"] = payment_info["id"]
|
||||
if payment_info.get("method"):
|
||||
update_kwargs["payment_method"] = payment_info["method"]
|
||||
if payment_info.get("balance_amount"):
|
||||
update_kwargs["balance_amount"] = payment_info["balance_amount"]
|
||||
if payment_info.get("balance_currency"):
|
||||
update_kwargs["balance_currency"] = payment_info["balance_currency"]
|
||||
if payment_info.get("account"):
|
||||
update_kwargs["payer_account"] = payment_info["account"]
|
||||
|
||||
if normalized_remote in getattr(service, "BILL_SUCCESS_STATES", {"SUCCESS"}):
|
||||
update_kwargs["is_paid"] = True
|
||||
if not payment.paid_at:
|
||||
update_kwargs["paid_at"] = datetime.utcnow()
|
||||
elif normalized_remote in getattr(service, "BILL_FAILED_STATES", {"FAIL"}):
|
||||
update_kwargs["is_paid"] = False
|
||||
elif normalized_remote in getattr(service, "BILL_PENDING_STATES", {"NEW", "PROCESS"}):
|
||||
update_kwargs.setdefault("is_paid", False)
|
||||
|
||||
payment = await payment_module.update_pal24_payment_status(
|
||||
db,
|
||||
payment,
|
||||
**update_kwargs,
|
||||
)
|
||||
response = await service.get_bill_status(bill_id_str)
|
||||
except Pal24APIError as error:
|
||||
logger.error(
|
||||
"Ошибка Pal24 API при получении статуса: %s", error
|
||||
)
|
||||
logger.error("Ошибка Pal24 API при получении статуса счёта: %s", error)
|
||||
else:
|
||||
if response:
|
||||
remote_payloads["bill_status"] = response
|
||||
status_value = response.get("status") or (response.get("bill") or {}).get("status")
|
||||
if status_value:
|
||||
remote_status = str(status_value).upper()
|
||||
extracted = self._extract_remote_payment_info(response)
|
||||
if extracted:
|
||||
payment_info_candidates.append(extracted)
|
||||
|
||||
if payment.payment_id:
|
||||
payment_id_str = str(payment.payment_id)
|
||||
try:
|
||||
payment_response = await service.get_payment_status(payment_id_str)
|
||||
except Pal24APIError as error:
|
||||
logger.error("Ошибка Pal24 API при получении статуса платежа: %s", error)
|
||||
else:
|
||||
if payment_response:
|
||||
remote_payloads["payment_status"] = payment_response
|
||||
extracted = self._extract_remote_payment_info(payment_response)
|
||||
if extracted:
|
||||
payment_info_candidates.append(extracted)
|
||||
|
||||
try:
|
||||
payments_response = await service.get_bill_payments(bill_id_str)
|
||||
except Pal24APIError as error:
|
||||
logger.error("Ошибка Pal24 API при получении списка платежей: %s", error)
|
||||
else:
|
||||
if payments_response:
|
||||
remote_payloads["bill_payments"] = payments_response
|
||||
for candidate in self._collect_payment_candidates(payments_response):
|
||||
extracted = self._extract_remote_payment_info(candidate)
|
||||
if extracted:
|
||||
payment_info_candidates.append(extracted)
|
||||
|
||||
payment_info = self._select_best_payment_info(payment, payment_info_candidates)
|
||||
if payment_info:
|
||||
remote_payloads.setdefault("selected_payment", payment_info)
|
||||
|
||||
bill_success = getattr(service, "BILL_SUCCESS_STATES", {"SUCCESS"}) if service else {"SUCCESS"}
|
||||
bill_failed = getattr(service, "BILL_FAILED_STATES", {"FAIL"}) if service else {"FAIL"}
|
||||
bill_pending = getattr(service, "BILL_PENDING_STATES", {"NEW", "PROCESS"}) if service else {"NEW", "PROCESS"}
|
||||
|
||||
update_status = payment.status or "NEW"
|
||||
update_kwargs: Dict[str, Any] = {}
|
||||
is_paid_update: Optional[bool] = None
|
||||
|
||||
if remote_status:
|
||||
update_status = remote_status
|
||||
if remote_status in bill_success:
|
||||
is_paid_update = True
|
||||
elif remote_status in bill_failed:
|
||||
is_paid_update = False
|
||||
elif remote_status in bill_pending and is_paid_update is None:
|
||||
is_paid_update = False
|
||||
|
||||
payment_status_code: Optional[str] = None
|
||||
if payment_info:
|
||||
payment_status_code = (payment_info.get("status") or "").upper() or None
|
||||
if payment_status_code:
|
||||
existing_status = (getattr(payment, "payment_status", "") or "").upper()
|
||||
if payment_status_code != existing_status:
|
||||
update_kwargs["payment_status"] = payment_status_code
|
||||
|
||||
payment_id_value = payment_info.get("id")
|
||||
if payment_id_value and payment_id_value != (payment.payment_id or ""):
|
||||
update_kwargs["payment_id"] = payment_id_value
|
||||
|
||||
method_value = payment_info.get("method")
|
||||
if method_value:
|
||||
normalized_method = self._normalize_payment_method(method_value)
|
||||
if normalized_method != (payment.payment_method or ""):
|
||||
update_kwargs["payment_method"] = normalized_method
|
||||
|
||||
balance_amount = payment_info.get("balance_amount")
|
||||
if balance_amount and balance_amount != (payment.balance_amount or ""):
|
||||
update_kwargs["balance_amount"] = balance_amount
|
||||
|
||||
balance_currency = payment_info.get("balance_currency")
|
||||
if balance_currency and balance_currency != (payment.balance_currency or ""):
|
||||
update_kwargs["balance_currency"] = balance_currency
|
||||
|
||||
payer_account = payment_info.get("account")
|
||||
if payer_account and payer_account != (payment.payer_account or ""):
|
||||
update_kwargs["payer_account"] = payer_account
|
||||
|
||||
if payment_status_code:
|
||||
success_states = {"SUCCESS", "OVERPAID"}
|
||||
failed_states = {"FAIL"}
|
||||
pending_states = {"NEW", "PROCESS", "UNDERPAID"}
|
||||
if payment_status_code in success_states:
|
||||
is_paid_update = True
|
||||
elif payment_status_code in failed_states and is_paid_update is not True:
|
||||
is_paid_update = False
|
||||
elif payment_status_code in pending_states and is_paid_update is None:
|
||||
is_paid_update = False
|
||||
|
||||
if not remote_status and payment_status_code:
|
||||
update_status = payment_status_code
|
||||
|
||||
if is_paid_update is not None and is_paid_update != bool(payment.is_paid):
|
||||
update_kwargs["is_paid"] = is_paid_update
|
||||
if is_paid_update and not payment.paid_at:
|
||||
update_kwargs.setdefault("paid_at", datetime.utcnow())
|
||||
|
||||
current_status = payment.status or ""
|
||||
effective_status = update_status or current_status or "NEW"
|
||||
needs_update = bool(update_kwargs) or effective_status != current_status
|
||||
|
||||
if needs_update:
|
||||
payment = await payment_module.update_pal24_payment_status(
|
||||
db,
|
||||
payment,
|
||||
status=effective_status,
|
||||
**update_kwargs,
|
||||
)
|
||||
|
||||
remote_status_for_return = remote_status or payment_status_code
|
||||
remote_data = remote_payloads or None
|
||||
|
||||
if payment.is_paid and not payment.transaction_id:
|
||||
try:
|
||||
@@ -576,7 +685,7 @@ class Pal24PaymentMixin:
|
||||
"payment": payment,
|
||||
"status": payment.status,
|
||||
"is_paid": payment.is_paid,
|
||||
"remote_status": remote_status,
|
||||
"remote_status": remote_status_for_return,
|
||||
"remote_data": remote_data,
|
||||
}
|
||||
|
||||
@@ -621,11 +730,26 @@ class Pal24PaymentMixin:
|
||||
or candidate.get("payer_account")
|
||||
or candidate.get("AccountNumber")
|
||||
),
|
||||
"bill_id": _stringify(
|
||||
candidate.get("bill_id")
|
||||
or candidate.get("BillId")
|
||||
or candidate.get("billId")
|
||||
),
|
||||
}
|
||||
|
||||
if not isinstance(remote_data, dict):
|
||||
return {}
|
||||
|
||||
lower_keys = {str(key).lower() for key in remote_data.keys()}
|
||||
has_status = any(key in lower_keys for key in ("status", "payment_status"))
|
||||
has_identifier = any(
|
||||
key in lower_keys
|
||||
for key in ("payment_id", "from_card", "account_amount", "id")
|
||||
) or "bill_id" in lower_keys
|
||||
|
||||
if has_status and has_identifier and "bill" not in lower_keys:
|
||||
return _normalize(remote_data)
|
||||
|
||||
search_spaces = [remote_data]
|
||||
bill_section = remote_data.get("bill") or remote_data.get("Bill")
|
||||
if isinstance(bill_section, dict):
|
||||
@@ -641,8 +765,59 @@ class Pal24PaymentMixin:
|
||||
if candidate:
|
||||
return _normalize(candidate)
|
||||
|
||||
data_section = remote_data.get("data") or remote_data.get("Data")
|
||||
candidate = _pick_candidate(data_section)
|
||||
if candidate:
|
||||
return _normalize(candidate)
|
||||
|
||||
return {}
|
||||
|
||||
@staticmethod
|
||||
def _collect_payment_candidates(remote_data: Any) -> List[Dict[str, Any]]:
|
||||
candidates: List[Dict[str, Any]] = []
|
||||
|
||||
def _visit(value: Any) -> None:
|
||||
if isinstance(value, dict):
|
||||
lower_keys = {str(key).lower() for key in value.keys()}
|
||||
has_status = any(key in lower_keys for key in ("status", "payment_status"))
|
||||
has_identifier = any(
|
||||
key in lower_keys
|
||||
for key in ("id", "payment_id", "bill_id", "from_card", "account_amount")
|
||||
)
|
||||
if has_status and has_identifier and value not in candidates:
|
||||
candidates.append(value)
|
||||
for nested in value.values():
|
||||
_visit(nested)
|
||||
elif isinstance(value, list):
|
||||
for item in value:
|
||||
_visit(item)
|
||||
|
||||
_visit(remote_data)
|
||||
return candidates
|
||||
|
||||
@staticmethod
|
||||
def _select_best_payment_info(
|
||||
payment: Any,
|
||||
candidates: List[Dict[str, Optional[str]]],
|
||||
) -> Dict[str, Optional[str]]:
|
||||
if not candidates:
|
||||
return {}
|
||||
|
||||
payment_id = str(getattr(payment, "payment_id", "") or "")
|
||||
bill_id = str(getattr(payment, "bill_id", "") or "")
|
||||
|
||||
for candidate in candidates:
|
||||
candidate_id = str(candidate.get("id") or "")
|
||||
if payment_id and candidate_id == payment_id:
|
||||
return candidate
|
||||
|
||||
for candidate in candidates:
|
||||
candidate_bill = str(candidate.get("bill_id") or "")
|
||||
if bill_id and candidate_bill == bill_id:
|
||||
return candidate
|
||||
|
||||
return candidates[0]
|
||||
|
||||
@staticmethod
|
||||
def _normalize_payment_method(payment_method: Optional[str]) -> str:
|
||||
mapping = {
|
||||
|
||||
+507
-171
@@ -7,6 +7,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from decimal import Decimal, ROUND_FLOOR, ROUND_HALF_UP
|
||||
from typing import Optional
|
||||
@@ -19,11 +20,22 @@ from app.database.crud.transaction import create_transaction
|
||||
from app.database.crud.user import get_user_by_id
|
||||
from app.database.models import PaymentMethod, TransactionType
|
||||
from app.external.telegram_stars import TelegramStarsService
|
||||
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__)
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class _SimpleSubscriptionPayload:
|
||||
"""Данные для простой подписки, извлечённые из payload звёздного платежа."""
|
||||
|
||||
subscription_id: Optional[int]
|
||||
period_days: Optional[int]
|
||||
|
||||
|
||||
class TelegramStarsMixin:
|
||||
"""Mixin с операциями создания и обработки платежей через Telegram Stars."""
|
||||
|
||||
@@ -85,7 +97,6 @@ class TelegramStarsMixin:
|
||||
telegram_payment_charge_id: str,
|
||||
) -> bool:
|
||||
"""Финализирует платеж, пришедший из Telegram Stars, и обновляет баланс пользователя."""
|
||||
del payload # payload пока не используется, но оставляем аргумент для совместимости.
|
||||
try:
|
||||
rubles_amount = TelegramStarsService.calculate_rubles_from_stars(
|
||||
stars_amount
|
||||
@@ -96,12 +107,28 @@ class TelegramStarsMixin:
|
||||
)
|
||||
)
|
||||
|
||||
simple_payload = self._parse_simple_subscription_payload(
|
||||
payload,
|
||||
user_id,
|
||||
)
|
||||
|
||||
transaction_description = (
|
||||
f"Оплата подписки через Telegram Stars ({stars_amount} ⭐)"
|
||||
if simple_payload
|
||||
else f"Пополнение через Telegram Stars ({stars_amount} ⭐)"
|
||||
)
|
||||
transaction_type = (
|
||||
TransactionType.SUBSCRIPTION_PAYMENT
|
||||
if simple_payload
|
||||
else TransactionType.DEPOSIT
|
||||
)
|
||||
|
||||
transaction = await create_transaction(
|
||||
db=db,
|
||||
user_id=user_id,
|
||||
type=TransactionType.DEPOSIT,
|
||||
type=transaction_type,
|
||||
amount_kopeks=amount_kopeks,
|
||||
description=f"Пополнение через Telegram Stars ({stars_amount} ⭐)",
|
||||
description=transaction_description,
|
||||
payment_method=PaymentMethod.TELEGRAM_STARS,
|
||||
external_id=telegram_payment_charge_id,
|
||||
is_completed=True,
|
||||
@@ -115,178 +142,487 @@ class TelegramStarsMixin:
|
||||
)
|
||||
return False
|
||||
|
||||
# Запоминаем старые значения, чтобы корректно построить уведомления.
|
||||
old_balance = user.balance_kopeks
|
||||
was_first_topup = not user.has_made_first_topup
|
||||
|
||||
# Обновляем баланс в БД.
|
||||
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 "🔄 Пополнение"
|
||||
)
|
||||
|
||||
await db.commit()
|
||||
|
||||
description_for_referral = (
|
||||
f"Пополнение Stars: {settings.format_price(amount_kopeks)} ({stars_amount} ⭐)"
|
||||
)
|
||||
logger.info(
|
||||
"🔍 Проверка реферальной логики для описания: '%s'",
|
||||
description_for_referral,
|
||||
)
|
||||
|
||||
lower_description = description_for_referral.lower()
|
||||
contains_allowed_keywords = any(
|
||||
word in lower_description
|
||||
for word in ["пополнение", "stars", "yookassa", "topup"]
|
||||
)
|
||||
contains_forbidden_keywords = any(
|
||||
word in lower_description for word in ["комиссия", "бонус"]
|
||||
)
|
||||
allow_referral = contains_allowed_keywords and not contains_forbidden_keywords
|
||||
|
||||
if allow_referral:
|
||||
logger.info(
|
||||
"🔞 Вызов process_referral_topup для пользователя %s",
|
||||
user_id,
|
||||
)
|
||||
try:
|
||||
from app.services.referral_service import process_referral_topup
|
||||
|
||||
await process_referral_topup(
|
||||
db, user_id, amount_kopeks, getattr(self, "bot", None)
|
||||
)
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
"Ошибка обработки реферального пополнения: %s", error
|
||||
)
|
||||
else:
|
||||
logger.info(
|
||||
"❌ Описание '%s' не подходит для реферальной логики",
|
||||
description_for_referral,
|
||||
if simple_payload:
|
||||
return await self._finalize_simple_subscription_stars_payment(
|
||||
db=db,
|
||||
user=user,
|
||||
transaction=transaction,
|
||||
amount_kopeks=amount_kopeks,
|
||||
stars_amount=stars_amount,
|
||||
payload_data=simple_payload,
|
||||
telegram_payment_charge_id=telegram_payment_charge_id,
|
||||
)
|
||||
|
||||
if was_first_topup and not user.has_made_first_topup:
|
||||
user.has_made_first_topup = True
|
||||
await db.commit()
|
||||
|
||||
await db.refresh(user)
|
||||
|
||||
logger.info(
|
||||
"💰 Баланс пользователя %s изменен: %s → %s (Δ +%s)",
|
||||
user.telegram_id,
|
||||
old_balance,
|
||||
user.balance_kopeks,
|
||||
amount_kopeks,
|
||||
return await self._finalize_stars_balance_topup(
|
||||
db=db,
|
||||
user=user,
|
||||
transaction=transaction,
|
||||
amount_kopeks=amount_kopeks,
|
||||
stars_amount=stars_amount,
|
||||
telegram_payment_charge_id=telegram_payment_charge_id,
|
||||
)
|
||||
|
||||
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(
|
||||
"Ошибка отправки уведомления о пополнении Stars: %s",
|
||||
error,
|
||||
exc_info=True
|
||||
)
|
||||
|
||||
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"⭐ Звезд: {stars_amount}\n"
|
||||
f"💰 Сумма: {settings.format_price(amount_kopeks)}\n"
|
||||
"🦊 Способ: Telegram Stars\n"
|
||||
f"🆔 Транзакция: {telegram_payment_charge_id[:8]}...\n\n"
|
||||
"Баланс пополнен автоматически!"
|
||||
),
|
||||
parse_mode="HTML",
|
||||
reply_markup=keyboard,
|
||||
)
|
||||
logger.info(
|
||||
"✅ Отправлено уведомление пользователю %s о пополнении на %s",
|
||||
user.telegram_id,
|
||||
settings.format_price(amount_kopeks),
|
||||
)
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
"Ошибка отправки уведомления о пополнении Stars: %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)
|
||||
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="subscription_resume_checkout"
|
||||
)],
|
||||
[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(amount_kopeks)}!\n\n{cart_message}",
|
||||
reply_markup=keyboard
|
||||
)
|
||||
logger.info(f"Отправлено уведомление с кнопкой возврата к оформлению подписки пользователю {user.id}")
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при работе с сохраненной корзиной для пользователя {user.id}: {e}", exc_info=True)
|
||||
|
||||
logger.info(
|
||||
"✅ Обработан Stars платеж: пользователь %s, %s звезд → %s",
|
||||
user_id,
|
||||
stars_amount,
|
||||
settings.format_price(amount_kopeks),
|
||||
)
|
||||
return True
|
||||
|
||||
except Exception as error:
|
||||
logger.error("Ошибка обработки Stars платежа: %s", error, exc_info=True)
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def _parse_simple_subscription_payload(
|
||||
payload: str,
|
||||
expected_user_id: int,
|
||||
) -> Optional[_SimpleSubscriptionPayload]:
|
||||
"""Пытается извлечь параметры простой подписки из payload звёздного платежа."""
|
||||
|
||||
prefix = "simple_sub_"
|
||||
if not payload or not payload.startswith(prefix):
|
||||
return None
|
||||
|
||||
tail = payload[len(prefix) :]
|
||||
parts = tail.split("_", 2)
|
||||
if len(parts) < 3:
|
||||
logger.warning(
|
||||
"Payload Stars simple subscription имеет некорректный формат: %s",
|
||||
payload,
|
||||
)
|
||||
return None
|
||||
|
||||
user_part, subscription_part, period_part = parts
|
||||
|
||||
try:
|
||||
payload_user_id = int(user_part)
|
||||
except ValueError:
|
||||
logger.warning(
|
||||
"Не удалось разобрать user_id в payload Stars simple subscription: %s",
|
||||
payload,
|
||||
)
|
||||
return None
|
||||
|
||||
if payload_user_id != expected_user_id:
|
||||
logger.warning(
|
||||
"Получен payload Stars simple subscription с чужим user_id: %s (ожидался %s)",
|
||||
payload_user_id,
|
||||
expected_user_id,
|
||||
)
|
||||
return None
|
||||
|
||||
try:
|
||||
subscription_id = int(subscription_part)
|
||||
except ValueError:
|
||||
logger.warning(
|
||||
"Не удалось разобрать subscription_id в payload Stars simple subscription: %s",
|
||||
payload,
|
||||
)
|
||||
return None
|
||||
|
||||
period_days: Optional[int] = None
|
||||
try:
|
||||
period_days = int(period_part)
|
||||
except ValueError:
|
||||
logger.warning(
|
||||
"Не удалось разобрать период в payload Stars simple subscription: %s",
|
||||
payload,
|
||||
)
|
||||
|
||||
return _SimpleSubscriptionPayload(
|
||||
subscription_id=subscription_id,
|
||||
period_days=period_days,
|
||||
)
|
||||
|
||||
async def _finalize_simple_subscription_stars_payment(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
user,
|
||||
transaction,
|
||||
amount_kopeks: int,
|
||||
stars_amount: int,
|
||||
payload_data: _SimpleSubscriptionPayload,
|
||||
telegram_payment_charge_id: str,
|
||||
) -> bool:
|
||||
"""Активация простой подписки, оплаченной через Telegram Stars."""
|
||||
|
||||
period_days = payload_data.period_days or settings.SIMPLE_SUBSCRIPTION_PERIOD_DAYS
|
||||
pending_subscription = None
|
||||
|
||||
if payload_data.subscription_id is not None:
|
||||
try:
|
||||
from sqlalchemy import select
|
||||
from app.database.models import Subscription
|
||||
|
||||
result = await db.execute(
|
||||
select(Subscription).where(
|
||||
Subscription.id == payload_data.subscription_id,
|
||||
Subscription.user_id == user.id,
|
||||
)
|
||||
)
|
||||
pending_subscription = result.scalar_one_or_none()
|
||||
except Exception as lookup_error: # pragma: no cover - диагностический лог
|
||||
logger.error(
|
||||
"Ошибка поиска pending подписки %s для пользователя %s: %s",
|
||||
payload_data.subscription_id,
|
||||
user.id,
|
||||
lookup_error,
|
||||
exc_info=True,
|
||||
)
|
||||
pending_subscription = None
|
||||
|
||||
if not pending_subscription:
|
||||
logger.error(
|
||||
"Не найдена pending подписка %s для пользователя %s",
|
||||
payload_data.subscription_id,
|
||||
user.id,
|
||||
)
|
||||
return False
|
||||
|
||||
if payload_data.period_days is None:
|
||||
start_point = pending_subscription.start_date or datetime.utcnow()
|
||||
end_point = pending_subscription.end_date or start_point
|
||||
computed_days = max(1, (end_point - start_point).days or 0)
|
||||
period_days = max(period_days, computed_days)
|
||||
|
||||
try:
|
||||
from app.database.crud.subscription import activate_pending_subscription
|
||||
|
||||
subscription = await activate_pending_subscription(
|
||||
db=db,
|
||||
user_id=user.id,
|
||||
period_days=period_days,
|
||||
)
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
"Ошибка активации pending подписки для пользователя %s: %s",
|
||||
user.id,
|
||||
error,
|
||||
exc_info=True,
|
||||
)
|
||||
return False
|
||||
|
||||
if not subscription:
|
||||
logger.error(
|
||||
"Не удалось активировать pending подписку пользователя %s",
|
||||
user.id,
|
||||
)
|
||||
return False
|
||||
|
||||
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: # pragma: no cover - диагностический лог
|
||||
logger.error(
|
||||
"Ошибка синхронизации подписки с RemnaWave для пользователя %s: %s",
|
||||
user.id,
|
||||
sync_error,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
period_display = period_days
|
||||
if not period_display and getattr(subscription, "start_date", None) and getattr(
|
||||
subscription, "end_date", None
|
||||
):
|
||||
period_display = max(1, (subscription.end_date - subscription.start_date).days or 0)
|
||||
if not period_display:
|
||||
period_display = settings.SIMPLE_SUBSCRIPTION_PERIOD_DAYS
|
||||
|
||||
if getattr(self, "bot", None):
|
||||
try:
|
||||
from aiogram import types
|
||||
from app.localization.texts import get_texts
|
||||
|
||||
texts = get_texts(user.language)
|
||||
traffic_limit = getattr(subscription, "traffic_limit_gb", 0) or 0
|
||||
traffic_label = (
|
||||
"Безлимит" if traffic_limit == 0 else f"{int(traffic_limit)} ГБ"
|
||||
)
|
||||
|
||||
success_message = (
|
||||
"✅ <b>Подписка успешно активирована!</b>\n\n"
|
||||
f"📅 Период: {period_display} дней\n"
|
||||
f"📱 Устройства: {getattr(subscription, 'device_limit', 1)}\n"
|
||||
f"📊 Трафик: {traffic_label}\n"
|
||||
f"⭐ Оплата: {stars_amount} ⭐ ({settings.format_price(amount_kopeks)})\n\n"
|
||||
"🔗 Для подключения перейдите в раздел 'Моя подписка'"
|
||||
)
|
||||
|
||||
keyboard = types.InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text="📱 Моя подписка",
|
||||
callback_data="menu_subscription",
|
||||
)
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text="🏠 Главное меню",
|
||||
callback_data="back_to_menu",
|
||||
)
|
||||
],
|
||||
]
|
||||
)
|
||||
|
||||
await self.bot.send_message(
|
||||
chat_id=user.telegram_id,
|
||||
text=success_message,
|
||||
reply_markup=keyboard,
|
||||
parse_mode="HTML",
|
||||
)
|
||||
logger.info(
|
||||
"✅ Пользователь %s получил уведомление об оплате подписки через Stars",
|
||||
user.telegram_id,
|
||||
)
|
||||
except Exception as error: # pragma: no cover - диагностический лог
|
||||
logger.error(
|
||||
"Ошибка отправки уведомления о подписке через Stars: %s",
|
||||
error,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
if getattr(self, "bot", None):
|
||||
try:
|
||||
from app.services.admin_notification_service import AdminNotificationService
|
||||
|
||||
notification_service = AdminNotificationService(self.bot)
|
||||
await notification_service.send_subscription_purchase_notification(
|
||||
db,
|
||||
user,
|
||||
subscription,
|
||||
transaction,
|
||||
period_display,
|
||||
was_trial_conversion=False,
|
||||
)
|
||||
except Exception as admin_error: # pragma: no cover - диагностический лог
|
||||
logger.error(
|
||||
"Ошибка уведомления администраторов о подписке через Stars: %s",
|
||||
admin_error,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"✅ Обработан Stars платеж как покупка подписки: пользователь %s, %s звезд → %s",
|
||||
user.id,
|
||||
stars_amount,
|
||||
settings.format_price(amount_kopeks),
|
||||
)
|
||||
return True
|
||||
|
||||
async def _finalize_stars_balance_topup(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
user,
|
||||
transaction,
|
||||
amount_kopeks: int,
|
||||
stars_amount: int,
|
||||
telegram_payment_charge_id: str,
|
||||
) -> bool:
|
||||
"""Начисляет баланс пользователю после оплаты Stars и запускает автопокупку."""
|
||||
|
||||
# Запоминаем старые значения, чтобы корректно построить уведомления.
|
||||
old_balance = user.balance_kopeks
|
||||
was_first_topup = not user.has_made_first_topup
|
||||
|
||||
# Обновляем баланс в БД.
|
||||
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 "🔄 Пополнение"
|
||||
|
||||
await db.commit()
|
||||
|
||||
description_for_referral = (
|
||||
f"Пополнение Stars: {settings.format_price(amount_kopeks)} ({stars_amount} ⭐)"
|
||||
)
|
||||
logger.info(
|
||||
"🔍 Проверка реферальной логики для описания: '%s'",
|
||||
description_for_referral,
|
||||
)
|
||||
|
||||
lower_description = description_for_referral.lower()
|
||||
contains_allowed_keywords = any(
|
||||
word in lower_description for word in ["пополнение", "stars", "yookassa", "topup"]
|
||||
)
|
||||
contains_forbidden_keywords = any(
|
||||
word in lower_description for word in ["комиссия", "бонус"]
|
||||
)
|
||||
allow_referral = contains_allowed_keywords and not contains_forbidden_keywords
|
||||
|
||||
if allow_referral:
|
||||
logger.info(
|
||||
"🔞 Вызов process_referral_topup для пользователя %s",
|
||||
user.id,
|
||||
)
|
||||
try:
|
||||
from app.services.referral_service import process_referral_topup
|
||||
|
||||
await process_referral_topup(
|
||||
db,
|
||||
user.id,
|
||||
amount_kopeks,
|
||||
getattr(self, "bot", None),
|
||||
)
|
||||
except Exception as error: # pragma: no cover - диагностический лог
|
||||
logger.error(
|
||||
"Ошибка обработки реферального пополнения: %s",
|
||||
error,
|
||||
)
|
||||
else:
|
||||
logger.info(
|
||||
"❌ Описание '%s' не подходит для реферальной логики",
|
||||
description_for_referral,
|
||||
)
|
||||
|
||||
if was_first_topup and not user.has_made_first_topup:
|
||||
user.has_made_first_topup = True
|
||||
await db.commit()
|
||||
|
||||
await db.refresh(user)
|
||||
|
||||
logger.info(
|
||||
"💰 Баланс пользователя %s изменен: %s → %s (Δ +%s)",
|
||||
user.telegram_id,
|
||||
old_balance,
|
||||
user.balance_kopeks,
|
||||
amount_kopeks,
|
||||
)
|
||||
|
||||
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: # pragma: no cover - диагностический лог
|
||||
logger.error(
|
||||
"Ошибка отправки уведомления о пополнении Stars: %s",
|
||||
error,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
if getattr(self, "bot", None):
|
||||
try:
|
||||
keyboard = await self.build_topup_success_keyboard(user)
|
||||
|
||||
charge_id_short = (telegram_payment_charge_id or getattr(transaction, "external_id", ""))[:8]
|
||||
|
||||
await self.bot.send_message(
|
||||
user.telegram_id,
|
||||
(
|
||||
"✅ <b>Пополнение успешно!</b>\n\n"
|
||||
f"⭐ Звезд: {stars_amount}\n"
|
||||
f"💰 Сумма: {settings.format_price(amount_kopeks)}\n"
|
||||
"🦊 Способ: Telegram Stars\n"
|
||||
f"🆔 Транзакция: {charge_id_short}...\n\n"
|
||||
"Баланс пополнен автоматически!"
|
||||
),
|
||||
parse_mode="HTML",
|
||||
reply_markup=keyboard,
|
||||
)
|
||||
logger.info(
|
||||
"✅ Отправлено уведомление пользователю %s о пополнении на %s",
|
||||
user.telegram_id,
|
||||
settings.format_price(amount_kopeks),
|
||||
)
|
||||
except Exception as error: # pragma: no cover - диагностический лог
|
||||
logger.error(
|
||||
"Ошибка отправки уведомления о пополнении Stars: %s",
|
||||
error,
|
||||
)
|
||||
|
||||
# Проверяем наличие сохраненной корзины для возврата к оформлению подписки
|
||||
try:
|
||||
from aiogram import types
|
||||
from app.localization.texts import get_texts
|
||||
from app.services.user_cart_service import user_cart_service
|
||||
|
||||
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: # pragma: no cover - диагностический лог
|
||||
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):
|
||||
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="subscription_resume_checkout",
|
||||
)
|
||||
],
|
||||
[
|
||||
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(amount_kopeks)}!\n\n{cart_message}",
|
||||
reply_markup=keyboard,
|
||||
)
|
||||
logger.info(
|
||||
"Отправлено уведомление с кнопкой возврата к оформлению подписки пользователю %s",
|
||||
user.id,
|
||||
)
|
||||
except Exception as error: # pragma: no cover - диагностический лог
|
||||
logger.error(
|
||||
"Ошибка при работе с сохраненной корзиной для пользователя %s: %s",
|
||||
user.id,
|
||||
error,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"✅ Обработан Stars платеж: пользователь %s, %s звезд → %s",
|
||||
user.id,
|
||||
stars_amount,
|
||||
settings.format_price(amount_kopeks),
|
||||
)
|
||||
return True
|
||||
|
||||
+154
-15
@@ -12,12 +12,61 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.models import PaymentMethod, TransactionType
|
||||
from app.services.subscription_auto_purchase_service import (
|
||||
auto_purchase_saved_cart_after_topup,
|
||||
)
|
||||
from app.services.wata_service import WataAPIError, WataService
|
||||
from app.utils.user_utils import format_referrer_info
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _extract_transaction_id(payment: Any, remote_link: Optional[Dict[str, Any]] = None) -> Optional[str]:
|
||||
"""Try to find the remote WATA transaction identifier from stored payloads."""
|
||||
|
||||
def _from_mapping(mapping: Any) -> Optional[str]:
|
||||
if isinstance(mapping, str):
|
||||
try:
|
||||
import json
|
||||
|
||||
mapping = json.loads(mapping)
|
||||
except Exception: # pragma: no cover - defensive parsing
|
||||
return None
|
||||
if not isinstance(mapping, dict):
|
||||
return None
|
||||
for key in ("id", "transaction_id", "transactionId"):
|
||||
value = mapping.get(key)
|
||||
if not value:
|
||||
continue
|
||||
value_str = str(value)
|
||||
if "-" in value_str:
|
||||
return value_str
|
||||
return None
|
||||
|
||||
candidate = None
|
||||
|
||||
if hasattr(payment, "callback_payload"):
|
||||
candidate = _from_mapping(getattr(payment, "callback_payload"))
|
||||
if candidate:
|
||||
return candidate
|
||||
|
||||
metadata = getattr(payment, "metadata_json", None)
|
||||
if isinstance(metadata, dict):
|
||||
if "transaction" in metadata:
|
||||
candidate = _from_mapping(metadata.get("transaction"))
|
||||
if candidate:
|
||||
return candidate
|
||||
candidate = _from_mapping(metadata)
|
||||
if candidate:
|
||||
return candidate
|
||||
|
||||
candidate = _from_mapping(remote_link)
|
||||
if candidate:
|
||||
return candidate
|
||||
|
||||
return None
|
||||
|
||||
|
||||
class WataPaymentMixin:
|
||||
"""Encapsulates creation and status handling for WATA payment links."""
|
||||
|
||||
@@ -226,6 +275,7 @@ class WataPaymentMixin:
|
||||
|
||||
remote_link: Optional[Dict[str, Any]] = None
|
||||
transaction_payload: Optional[Dict[str, Any]] = None
|
||||
transaction_id: Optional[str] = None
|
||||
|
||||
if getattr(self, "wata_service", None) and payment.payment_link_id:
|
||||
try:
|
||||
@@ -253,29 +303,84 @@ class WataPaymentMixin:
|
||||
|
||||
remote_status_normalized = (remote_status or "").lower()
|
||||
if remote_status_normalized in {"closed", "paid"} and not payment.is_paid:
|
||||
transaction_id = _extract_transaction_id(payment, remote_link)
|
||||
if transaction_id:
|
||||
try:
|
||||
transaction_payload = await self.wata_service.get_transaction( # type: ignore[union-attr]
|
||||
transaction_id
|
||||
)
|
||||
except WataAPIError as error:
|
||||
logger.error(
|
||||
"Ошибка получения WATA транзакции %s: %s",
|
||||
transaction_id,
|
||||
error,
|
||||
)
|
||||
except Exception as error: # pragma: no cover - safety net
|
||||
logger.exception(
|
||||
"Непредвиденная ошибка при запросе WATA транзакции %s: %s",
|
||||
transaction_id,
|
||||
error,
|
||||
)
|
||||
if not transaction_payload:
|
||||
try:
|
||||
tx_response = await self.wata_service.search_transactions( # type: ignore[union-attr]
|
||||
order_id=payment.order_id,
|
||||
payment_link_id=payment.payment_link_id,
|
||||
status="Paid",
|
||||
limit=5,
|
||||
)
|
||||
items = tx_response.get("items") or []
|
||||
for item in items:
|
||||
if (item or {}).get("status") == "Paid":
|
||||
transaction_payload = item
|
||||
break
|
||||
except WataAPIError as error:
|
||||
logger.error(
|
||||
"Ошибка поиска WATA транзакций для %s: %s",
|
||||
payment.payment_link_id,
|
||||
error,
|
||||
)
|
||||
except Exception as error: # pragma: no cover - safety net
|
||||
logger.exception("Непредвиденная ошибка при поиске WATA транзакции: %s", error)
|
||||
|
||||
if (
|
||||
not transaction_payload
|
||||
and not payment.is_paid
|
||||
and getattr(self, "wata_service", None)
|
||||
):
|
||||
fallback_transaction_id = transaction_id or _extract_transaction_id(payment)
|
||||
if fallback_transaction_id:
|
||||
try:
|
||||
tx_response = await self.wata_service.search_transactions( # type: ignore[union-attr]
|
||||
order_id=payment.order_id,
|
||||
payment_link_id=payment.payment_link_id,
|
||||
status="Paid",
|
||||
limit=5,
|
||||
transaction_payload = await self.wata_service.get_transaction( # type: ignore[union-attr]
|
||||
fallback_transaction_id
|
||||
)
|
||||
items = tx_response.get("items") or []
|
||||
for item in items:
|
||||
if (item or {}).get("status") == "Paid":
|
||||
transaction_payload = item
|
||||
break
|
||||
except WataAPIError as error:
|
||||
logger.error(
|
||||
"Ошибка поиска WATA транзакций для %s: %s",
|
||||
payment.payment_link_id,
|
||||
"Ошибка повторного запроса WATA транзакции %s: %s",
|
||||
fallback_transaction_id,
|
||||
error,
|
||||
)
|
||||
except Exception as error: # pragma: no cover - safety net
|
||||
logger.exception("Непредвиденная ошибка при поиске WATA транзакции: %s", error)
|
||||
logger.exception(
|
||||
"Непредвиденная ошибка при повторном запросе WATA транзакции %s: %s",
|
||||
fallback_transaction_id,
|
||||
error,
|
||||
)
|
||||
|
||||
if transaction_payload and not payment.is_paid:
|
||||
payment = await self._finalize_wata_payment(db, payment, transaction_payload)
|
||||
normalized_status = None
|
||||
if isinstance(transaction_payload, dict):
|
||||
raw_status = transaction_payload.get("status") or transaction_payload.get("statusName")
|
||||
if raw_status:
|
||||
normalized_status = str(raw_status).lower()
|
||||
if normalized_status == "paid":
|
||||
payment = await self._finalize_wata_payment(db, payment, transaction_payload)
|
||||
else:
|
||||
logger.debug(
|
||||
"WATA транзакция %s в статусе %s, повторная обработка не требуется",
|
||||
transaction_id or getattr(payment, "payment_link_id", ""),
|
||||
normalized_status or "unknown",
|
||||
)
|
||||
|
||||
return {
|
||||
"payment": payment,
|
||||
@@ -293,7 +398,22 @@ class WataPaymentMixin:
|
||||
) -> Any:
|
||||
payment_module = import_module("app.services.payment_service")
|
||||
|
||||
paid_at = WataService._parse_datetime(transaction_payload.get("paymentTime"))
|
||||
if isinstance(transaction_payload, dict):
|
||||
paid_status = transaction_payload.get("status") or transaction_payload.get("statusName")
|
||||
else:
|
||||
paid_status = None
|
||||
if paid_status and str(paid_status).lower() not in {"paid", "declined", "pending"}:
|
||||
logger.debug(
|
||||
"Неизвестный статус WATA транзакции %s: %s",
|
||||
getattr(payment, "payment_link_id", ""),
|
||||
paid_status,
|
||||
)
|
||||
|
||||
paid_at = None
|
||||
if isinstance(transaction_payload, dict):
|
||||
paid_at = WataService._parse_datetime(transaction_payload.get("paymentTime"))
|
||||
if not paid_at and getattr(payment, "paid_at", None):
|
||||
paid_at = payment.paid_at
|
||||
existing_metadata = dict(getattr(payment, "metadata_json", {}) or {})
|
||||
existing_metadata["transaction"] = transaction_payload
|
||||
|
||||
@@ -407,6 +527,25 @@ class WataPaymentMixin:
|
||||
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
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from decimal import Decimal, InvalidOperation
|
||||
from importlib import import_module
|
||||
from typing import Any, Dict, Optional, TYPE_CHECKING
|
||||
|
||||
@@ -15,6 +16,9 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.models import PaymentMethod, TransactionType
|
||||
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__)
|
||||
@@ -206,6 +210,108 @@ class YooKassaPaymentMixin:
|
||||
logger.error("Ошибка создания платежа YooKassa СБП: %s", error)
|
||||
return None
|
||||
|
||||
async def get_yookassa_payment_status(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
local_payment_id: int,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""Запрашивает статус платежа в YooKassa и синхронизирует локальные данные."""
|
||||
|
||||
payment_module = import_module("app.services.payment_service")
|
||||
|
||||
payment = await payment_module.get_yookassa_payment_by_local_id(db, local_payment_id)
|
||||
if not payment:
|
||||
return None
|
||||
|
||||
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]
|
||||
payment.yookassa_payment_id
|
||||
)
|
||||
except Exception as error: # pragma: no cover - defensive logging
|
||||
logger.error(
|
||||
"Ошибка получения статуса YooKassa %s: %s",
|
||||
payment.yookassa_payment_id,
|
||||
error,
|
||||
)
|
||||
|
||||
if remote_data:
|
||||
status = remote_data.get("status") or payment.status
|
||||
paid = bool(remote_data.get("paid", getattr(payment, "is_paid", False)))
|
||||
captured_raw = remote_data.get("captured_at")
|
||||
captured_at = None
|
||||
if captured_raw:
|
||||
try:
|
||||
captured_at = datetime.fromisoformat(
|
||||
str(captured_raw).replace("Z", "+00:00")
|
||||
).replace(tzinfo=None)
|
||||
except Exception as parse_error: # pragma: no cover - diagnostic log
|
||||
logger.debug(
|
||||
"Не удалось распарсить captured_at %s: %s",
|
||||
captured_raw,
|
||||
parse_error,
|
||||
)
|
||||
captured_at = None
|
||||
|
||||
payment_method_type = remote_data.get("payment_method_type")
|
||||
|
||||
updated_payment = await payment_module.update_yookassa_payment_status(
|
||||
db,
|
||||
payment.yookassa_payment_id,
|
||||
status=status,
|
||||
is_paid=paid,
|
||||
is_captured=paid and status == "succeeded",
|
||||
captured_at=captured_at,
|
||||
payment_method_type=payment_method_type,
|
||||
)
|
||||
|
||||
if updated_payment:
|
||||
payment = updated_payment
|
||||
|
||||
transaction_id = getattr(payment, "transaction_id", None)
|
||||
|
||||
if (
|
||||
payment.status == "succeeded"
|
||||
and getattr(payment, "is_paid", False)
|
||||
):
|
||||
if not transaction_id:
|
||||
try:
|
||||
await db.refresh(payment)
|
||||
transaction_id = getattr(payment, "transaction_id", None)
|
||||
except Exception as refresh_error: # pragma: no cover - defensive logging
|
||||
logger.warning(
|
||||
"Не удалось обновить состояние платежа YooKassa %s перед повторной обработкой: %s",
|
||||
payment.yookassa_payment_id,
|
||||
refresh_error,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
if transaction_id:
|
||||
logger.info(
|
||||
"Пропускаем повторную обработку платежа YooKassa %s: уже связан с транзакцией %s",
|
||||
payment.yookassa_payment_id,
|
||||
transaction_id,
|
||||
)
|
||||
else:
|
||||
try:
|
||||
await self._process_successful_yookassa_payment(db, payment)
|
||||
except Exception as process_error: # pragma: no cover - defensive logging
|
||||
logger.error(
|
||||
"Ошибка обработки успешного платежа YooKassa %s: %s",
|
||||
payment.yookassa_payment_id,
|
||||
process_error,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
return {
|
||||
"payment": payment,
|
||||
"status": payment.status,
|
||||
"is_paid": getattr(payment, "is_paid", False),
|
||||
"remote_data": remote_data,
|
||||
}
|
||||
|
||||
async def _process_successful_yookassa_payment(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
@@ -213,6 +319,7 @@ class YooKassaPaymentMixin:
|
||||
) -> bool:
|
||||
"""Переносит успешный платёж YooKassa в транзакции и начисляет баланс пользователю."""
|
||||
try:
|
||||
from sqlalchemy import select
|
||||
payment_module = import_module("app.services.payment_service")
|
||||
|
||||
payment_description = getattr(payment, "description", "YooKassa платеж")
|
||||
@@ -255,12 +362,17 @@ class YooKassaPaymentMixin:
|
||||
is_completed=True,
|
||||
)
|
||||
|
||||
await payment_module.link_yookassa_payment_to_transaction(
|
||||
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
|
||||
|
||||
user = await payment_module.get_user_by_id(db, payment.user_id)
|
||||
if user:
|
||||
if is_simple_subscription:
|
||||
@@ -276,9 +388,27 @@ class YooKassaPaymentMixin:
|
||||
user.balance_kopeks += payment.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)
|
||||
# Обновляем пользователя с нужными связями, чтобы избежать проблем с ленивой загрузкой
|
||||
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.promo_group))
|
||||
.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.promo_group if full_user else getattr(user, "promo_group", None)
|
||||
|
||||
# Используем full_user для форматирования реферальной информации, чтобы избежать проблем с ленивой загрузкой
|
||||
user_for_referrer = full_user if full_user else user
|
||||
referrer_info = format_referrer_info(user_for_referrer)
|
||||
topup_status = (
|
||||
"🆕 Первое пополнение" if was_first_topup else "🔄 Пополнение"
|
||||
)
|
||||
@@ -314,8 +444,13 @@ 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(
|
||||
user,
|
||||
refreshed_user or user,
|
||||
transaction,
|
||||
old_balance,
|
||||
topup_status=topup_status,
|
||||
@@ -357,7 +492,31 @@ class YooKassaPaymentMixin:
|
||||
from app.services.user_cart_service import user_cart_service
|
||||
try:
|
||||
has_saved_cart = await user_cart_service.has_user_cart(user.id)
|
||||
logger.info(f"Результат проверки корзины для пользователя {user.id}: {has_saved_cart}")
|
||||
logger.info(
|
||||
"Результат проверки корзины для пользователя %s: %s",
|
||||
user.id,
|
||||
has_saved_cart,
|
||||
)
|
||||
|
||||
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):
|
||||
# Если у пользователя есть сохраненная корзина,
|
||||
# отправляем ему уведомление с кнопкой вернуться к оформлению
|
||||
@@ -402,7 +561,10 @@ class YooKassaPaymentMixin:
|
||||
f"Отправлено уведомление с кнопкой возврата к оформлению подписки пользователю {user.id}"
|
||||
)
|
||||
else:
|
||||
logger.info(f"У пользователя {user.id} нет сохраненной корзины или бот недоступен")
|
||||
logger.info(
|
||||
"У пользователя %s нет сохраненной корзины, бот недоступен или покупка уже выполнена",
|
||||
user.id,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Критическая ошибка при работе с сохраненной корзиной для пользователя {user.id}: {e}",
|
||||
@@ -481,10 +643,32 @@ 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.promo_group))
|
||||
.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,
|
||||
user,
|
||||
subscription,
|
||||
full_user or user,
|
||||
subscription_db or subscription,
|
||||
transaction,
|
||||
subscription_period,
|
||||
was_trial_conversion=False,
|
||||
@@ -545,18 +729,44 @@ class YooKassaPaymentMixin:
|
||||
logger.warning(
|
||||
"Локальный платеж для YooKassa id %s не найден", yookassa_payment_id
|
||||
)
|
||||
return False
|
||||
payment = await self._restore_missing_yookassa_payment(db, event_object)
|
||||
|
||||
if not payment:
|
||||
logger.error(
|
||||
"Не удалось восстановить локальную запись платежа YooKassa %s",
|
||||
yookassa_payment_id,
|
||||
)
|
||||
return False
|
||||
|
||||
payment.status = event_object.get("status", payment.status)
|
||||
payment.confirmation_url = event_object.get("confirmation_url")
|
||||
payment.confirmation_url = self._extract_confirmation_url(event_object)
|
||||
|
||||
current_paid = getattr(payment, "paid", False)
|
||||
payment.paid = event_object.get("paid", current_paid)
|
||||
payment.payment_method_type = (
|
||||
(event_object.get("payment_method") or {}).get("type")
|
||||
or payment.payment_method_type
|
||||
)
|
||||
payment.refundable = event_object.get("refundable", getattr(payment, "refundable", False))
|
||||
|
||||
current_paid = bool(getattr(payment, "is_paid", getattr(payment, "paid", False)))
|
||||
payment.is_paid = bool(event_object.get("paid", current_paid))
|
||||
|
||||
captured_at_raw = event_object.get("captured_at")
|
||||
if captured_at_raw:
|
||||
try:
|
||||
payment.captured_at = datetime.fromisoformat(
|
||||
captured_at_raw.replace("Z", "+00:00")
|
||||
).replace(tzinfo=None)
|
||||
except Exception as error:
|
||||
logger.debug(
|
||||
"Не удалось распарсить captured_at=%s: %s",
|
||||
captured_at_raw,
|
||||
error,
|
||||
)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(payment)
|
||||
|
||||
if payment.status == "succeeded" and payment.paid:
|
||||
if payment.status == "succeeded" and payment.is_paid:
|
||||
return await self._process_successful_yookassa_payment(db, payment)
|
||||
|
||||
logger.info(
|
||||
@@ -565,3 +775,152 @@ class YooKassaPaymentMixin:
|
||||
payment.status,
|
||||
)
|
||||
return True
|
||||
|
||||
async def _restore_missing_yookassa_payment(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
event_object: Dict[str, Any],
|
||||
) -> Optional["YooKassaPayment"]:
|
||||
"""Создает локальную запись платежа на основе данных webhook, если она отсутствует."""
|
||||
|
||||
yookassa_payment_id = event_object.get("id")
|
||||
if not yookassa_payment_id:
|
||||
return None
|
||||
|
||||
metadata = self._normalise_yookassa_metadata(event_object.get("metadata"))
|
||||
user_id_raw = metadata.get("user_id") or metadata.get("userId")
|
||||
|
||||
if user_id_raw is None:
|
||||
logger.error(
|
||||
"Webhook YooKassa %s не содержит user_id в metadata. Невозможно восстановить платеж.",
|
||||
yookassa_payment_id,
|
||||
)
|
||||
return None
|
||||
|
||||
try:
|
||||
user_id = int(user_id_raw)
|
||||
except (TypeError, ValueError):
|
||||
logger.error(
|
||||
"Webhook YooKassa %s содержит некорректный user_id=%s",
|
||||
yookassa_payment_id,
|
||||
user_id_raw,
|
||||
)
|
||||
return None
|
||||
|
||||
amount_info = event_object.get("amount") or {}
|
||||
amount_value = amount_info.get("value")
|
||||
currency = (amount_info.get("currency") or "RUB").upper()
|
||||
|
||||
if amount_value is None:
|
||||
logger.error(
|
||||
"Webhook YooKassa %s не содержит сумму платежа",
|
||||
yookassa_payment_id,
|
||||
)
|
||||
return None
|
||||
|
||||
try:
|
||||
amount_kopeks = int((Decimal(str(amount_value)) * 100).quantize(Decimal("1")))
|
||||
except (InvalidOperation, ValueError) as error:
|
||||
logger.error(
|
||||
"Некорректная сумма в webhook YooKassa %s: %s (%s)",
|
||||
yookassa_payment_id,
|
||||
amount_value,
|
||||
error,
|
||||
)
|
||||
return None
|
||||
|
||||
description = event_object.get("description") or metadata.get("description") or "YooKassa платеж"
|
||||
payment_method_type = (event_object.get("payment_method") or {}).get("type")
|
||||
|
||||
yookassa_created_at = None
|
||||
created_at_raw = event_object.get("created_at")
|
||||
if created_at_raw:
|
||||
try:
|
||||
yookassa_created_at = datetime.fromisoformat(
|
||||
created_at_raw.replace("Z", "+00:00")
|
||||
).replace(tzinfo=None)
|
||||
except Exception as error: # pragma: no cover - диагностический лог
|
||||
logger.debug(
|
||||
"Не удалось распарсить created_at=%s для YooKassa %s: %s",
|
||||
created_at_raw,
|
||||
yookassa_payment_id,
|
||||
error,
|
||||
)
|
||||
|
||||
payment_module = import_module("app.services.payment_service")
|
||||
|
||||
local_payment = await payment_module.create_yookassa_payment(
|
||||
db=db,
|
||||
user_id=user_id,
|
||||
yookassa_payment_id=yookassa_payment_id,
|
||||
amount_kopeks=amount_kopeks,
|
||||
currency=currency,
|
||||
description=description,
|
||||
status=event_object.get("status", "pending"),
|
||||
confirmation_url=self._extract_confirmation_url(event_object),
|
||||
metadata_json=metadata,
|
||||
payment_method_type=payment_method_type,
|
||||
yookassa_created_at=yookassa_created_at,
|
||||
test_mode=bool(event_object.get("test") or event_object.get("test_mode")),
|
||||
)
|
||||
|
||||
if not local_payment:
|
||||
return None
|
||||
|
||||
await payment_module.update_yookassa_payment_status(
|
||||
db=db,
|
||||
yookassa_payment_id=yookassa_payment_id,
|
||||
status=event_object.get("status", local_payment.status),
|
||||
is_paid=bool(event_object.get("paid")),
|
||||
is_captured=event_object.get("status") == "succeeded",
|
||||
captured_at=self._parse_datetime(event_object.get("captured_at")),
|
||||
payment_method_type=payment_method_type,
|
||||
)
|
||||
|
||||
return await payment_module.get_yookassa_payment_by_id(db, yookassa_payment_id)
|
||||
|
||||
@staticmethod
|
||||
def _normalise_yookassa_metadata(metadata: Any) -> Dict[str, Any]:
|
||||
if isinstance(metadata, dict):
|
||||
return metadata
|
||||
|
||||
if isinstance(metadata, list):
|
||||
normalised: Dict[str, Any] = {}
|
||||
for item in metadata:
|
||||
key = item.get("key") if isinstance(item, dict) else None
|
||||
if key:
|
||||
normalised[key] = item.get("value")
|
||||
return normalised
|
||||
|
||||
if isinstance(metadata, str):
|
||||
try:
|
||||
import json
|
||||
|
||||
parsed = json.loads(metadata)
|
||||
if isinstance(parsed, dict):
|
||||
return parsed
|
||||
except json.JSONDecodeError:
|
||||
logger.debug("Не удалось распарсить metadata webhook YooKassa: %s", metadata)
|
||||
|
||||
return {}
|
||||
|
||||
@staticmethod
|
||||
def _extract_confirmation_url(event_object: Dict[str, Any]) -> Optional[str]:
|
||||
if "confirmation_url" in event_object:
|
||||
return event_object.get("confirmation_url")
|
||||
|
||||
confirmation = event_object.get("confirmation")
|
||||
if isinstance(confirmation, dict):
|
||||
return confirmation.get("confirmation_url") or confirmation.get("return_url")
|
||||
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _parse_datetime(raw_value: Optional[str]) -> Optional[datetime]:
|
||||
if not raw_value:
|
||||
return None
|
||||
|
||||
try:
|
||||
return datetime.fromisoformat(raw_value.replace("Z", "+00:00")).replace(tzinfo=None)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
@@ -40,6 +40,11 @@ async def create_yookassa_payment(*args, **kwargs):
|
||||
return await yk_crud.create_yookassa_payment(*args, **kwargs)
|
||||
|
||||
|
||||
async def update_yookassa_payment_status(*args, **kwargs):
|
||||
yk_crud = import_module("app.database.crud.yookassa")
|
||||
return await yk_crud.update_yookassa_payment_status(*args, **kwargs)
|
||||
|
||||
|
||||
async def link_yookassa_payment_to_transaction(*args, **kwargs):
|
||||
yk_crud = import_module("app.database.crud.yookassa")
|
||||
return await yk_crud.link_yookassa_payment_to_transaction(*args, **kwargs)
|
||||
@@ -50,6 +55,11 @@ async def get_yookassa_payment_by_id(*args, **kwargs):
|
||||
return await yk_crud.get_yookassa_payment_by_id(*args, **kwargs)
|
||||
|
||||
|
||||
async def get_yookassa_payment_by_local_id(*args, **kwargs):
|
||||
yk_crud = import_module("app.database.crud.yookassa")
|
||||
return await yk_crud.get_yookassa_payment_by_local_id(*args, **kwargs)
|
||||
|
||||
|
||||
async def create_transaction(*args, **kwargs):
|
||||
transaction_crud = import_module("app.database.crud.transaction")
|
||||
return await transaction_crud.create_transaction(*args, **kwargs)
|
||||
|
||||
@@ -0,0 +1,767 @@
|
||||
"""Helpers for inspecting and manually checking pending top-up payments."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import re
|
||||
from collections import Counter
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta
|
||||
from typing import TYPE_CHECKING, Any, Dict, Iterable, List, Optional
|
||||
|
||||
from sqlalchemy import desc, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.config import settings
|
||||
from app.database.database import AsyncSessionLocal
|
||||
from app.database.models import (
|
||||
CryptoBotPayment,
|
||||
HeleketPayment,
|
||||
MulenPayPayment,
|
||||
Pal24Payment,
|
||||
PaymentMethod,
|
||||
Transaction,
|
||||
TransactionType,
|
||||
User,
|
||||
WataPayment,
|
||||
YooKassaPayment,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
PENDING_MAX_AGE = timedelta(hours=24)
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class PendingPayment:
|
||||
"""Normalized representation of a provider specific payment entry."""
|
||||
|
||||
method: PaymentMethod
|
||||
local_id: int
|
||||
identifier: str
|
||||
amount_kopeks: int
|
||||
status: str
|
||||
is_paid: bool
|
||||
created_at: datetime
|
||||
user: User
|
||||
payment: Any
|
||||
expires_at: Optional[datetime] = None
|
||||
|
||||
def is_recent(self, max_age: timedelta = PENDING_MAX_AGE) -> bool:
|
||||
return (datetime.utcnow() - self.created_at) <= max_age
|
||||
|
||||
|
||||
SUPPORTED_MANUAL_CHECK_METHODS: frozenset[PaymentMethod] = frozenset(
|
||||
{
|
||||
PaymentMethod.YOOKASSA,
|
||||
PaymentMethod.MULENPAY,
|
||||
PaymentMethod.PAL24,
|
||||
PaymentMethod.WATA,
|
||||
PaymentMethod.HELEKET,
|
||||
PaymentMethod.CRYPTOBOT,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
SUPPORTED_AUTO_CHECK_METHODS: frozenset[PaymentMethod] = frozenset(
|
||||
{
|
||||
PaymentMethod.YOOKASSA,
|
||||
PaymentMethod.MULENPAY,
|
||||
PaymentMethod.PAL24,
|
||||
PaymentMethod.WATA,
|
||||
PaymentMethod.CRYPTOBOT,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def method_display_name(method: PaymentMethod) -> str:
|
||||
if method == PaymentMethod.MULENPAY:
|
||||
return settings.get_mulenpay_display_name()
|
||||
if method == PaymentMethod.PAL24:
|
||||
return "PayPalych"
|
||||
if method == PaymentMethod.YOOKASSA:
|
||||
return "YooKassa"
|
||||
if method == PaymentMethod.WATA:
|
||||
return "WATA"
|
||||
if method == PaymentMethod.CRYPTOBOT:
|
||||
return "CryptoBot"
|
||||
if method == PaymentMethod.HELEKET:
|
||||
return "Heleket"
|
||||
if method == PaymentMethod.TELEGRAM_STARS:
|
||||
return "Telegram Stars"
|
||||
return method.value
|
||||
|
||||
|
||||
def _method_is_enabled(method: PaymentMethod) -> bool:
|
||||
if method == PaymentMethod.YOOKASSA:
|
||||
return settings.is_yookassa_enabled()
|
||||
if method == PaymentMethod.MULENPAY:
|
||||
return settings.is_mulenpay_enabled()
|
||||
if method == PaymentMethod.PAL24:
|
||||
return settings.is_pal24_enabled()
|
||||
if method == PaymentMethod.WATA:
|
||||
return settings.is_wata_enabled()
|
||||
if method == PaymentMethod.CRYPTOBOT:
|
||||
return settings.is_cryptobot_enabled()
|
||||
if method == PaymentMethod.HELEKET:
|
||||
return settings.is_heleket_enabled()
|
||||
return False
|
||||
|
||||
|
||||
def get_enabled_auto_methods() -> List[PaymentMethod]:
|
||||
return [
|
||||
method
|
||||
for method in SUPPORTED_AUTO_CHECK_METHODS
|
||||
if _method_is_enabled(method)
|
||||
]
|
||||
|
||||
|
||||
class AutoPaymentVerificationService:
|
||||
"""Background checker that periodically refreshes pending payments."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._task: Optional[asyncio.Task[None]] = None
|
||||
self._payment_service: Optional["PaymentService"] = None
|
||||
|
||||
def set_payment_service(self, payment_service: "PaymentService") -> None:
|
||||
self._payment_service = payment_service
|
||||
|
||||
def is_running(self) -> bool:
|
||||
return self._task is not None and not self._task.done()
|
||||
|
||||
async def start(self) -> None:
|
||||
await self.stop()
|
||||
|
||||
if not settings.is_payment_verification_auto_check_enabled():
|
||||
logger.info("Автопроверка пополнений отключена настройками")
|
||||
return
|
||||
|
||||
if not self._payment_service:
|
||||
logger.warning(
|
||||
"Автопроверка пополнений не запущена: PaymentService не инициализирован"
|
||||
)
|
||||
return
|
||||
|
||||
methods = get_enabled_auto_methods()
|
||||
if not methods:
|
||||
logger.info(
|
||||
"Автопроверка пополнений не запущена: нет активных провайдеров"
|
||||
)
|
||||
return
|
||||
|
||||
display_names = ", ".join(
|
||||
sorted(method_display_name(method) for method in methods)
|
||||
)
|
||||
interval_minutes = settings.get_payment_verification_auto_check_interval()
|
||||
|
||||
self._task = asyncio.create_task(self._auto_check_loop())
|
||||
logger.info(
|
||||
"🔄 Автопроверка пополнений запущена (каждые %s мин) для: %s",
|
||||
interval_minutes,
|
||||
display_names,
|
||||
)
|
||||
|
||||
async def stop(self) -> None:
|
||||
if self._task and not self._task.done():
|
||||
self._task.cancel()
|
||||
try:
|
||||
await self._task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
self._task = None
|
||||
|
||||
async def _auto_check_loop(self) -> None:
|
||||
try:
|
||||
while True:
|
||||
interval_minutes = settings.get_payment_verification_auto_check_interval()
|
||||
try:
|
||||
if (
|
||||
settings.is_payment_verification_auto_check_enabled()
|
||||
and self._payment_service
|
||||
):
|
||||
methods = get_enabled_auto_methods()
|
||||
if methods:
|
||||
await self._run_checks(methods)
|
||||
else:
|
||||
logger.debug(
|
||||
"Автопроверка пополнений: активных провайдеров нет"
|
||||
)
|
||||
else:
|
||||
logger.debug(
|
||||
"Автопроверка пополнений: отключена настройками или сервис не готов"
|
||||
)
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception as error: # noqa: BLE001 - логируем непредвиденные ошибки
|
||||
logger.error(
|
||||
"Ошибка автопроверки пополнений: %s",
|
||||
error,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
await asyncio.sleep(max(1, interval_minutes) * 60)
|
||||
except asyncio.CancelledError:
|
||||
logger.info("Автопроверка пополнений остановлена")
|
||||
raise
|
||||
|
||||
async def _run_checks(self, methods: List[PaymentMethod]) -> None:
|
||||
if not self._payment_service:
|
||||
return
|
||||
|
||||
async with AsyncSessionLocal() as session:
|
||||
try:
|
||||
pending = await list_recent_pending_payments(session)
|
||||
candidates = [
|
||||
record
|
||||
for record in pending
|
||||
if record.method in methods and not record.is_paid
|
||||
]
|
||||
|
||||
if not candidates:
|
||||
logger.debug(
|
||||
"Автопроверка пополнений: подходящих ожидающих платежей нет"
|
||||
)
|
||||
return
|
||||
|
||||
counts = Counter(record.method for record in candidates)
|
||||
summary = ", ".join(
|
||||
f"{method_display_name(method)}: {count}"
|
||||
for method, count in sorted(
|
||||
counts.items(), key=lambda item: method_display_name(item[0])
|
||||
)
|
||||
)
|
||||
logger.info(
|
||||
"🔄 Автопроверка пополнений: найдено %s инвойсов (%s)",
|
||||
len(candidates),
|
||||
summary,
|
||||
)
|
||||
|
||||
for record in candidates:
|
||||
refreshed = await run_manual_check(
|
||||
session,
|
||||
record.method,
|
||||
record.local_id,
|
||||
self._payment_service,
|
||||
)
|
||||
|
||||
if not refreshed:
|
||||
logger.debug(
|
||||
"Автопроверка пополнений: не удалось обновить %s %s",
|
||||
method_display_name(record.method),
|
||||
record.identifier,
|
||||
)
|
||||
continue
|
||||
|
||||
if refreshed.is_paid and not record.is_paid:
|
||||
logger.info(
|
||||
"✅ %s %s отмечен как оплаченный после автопроверки",
|
||||
method_display_name(refreshed.method),
|
||||
refreshed.identifier,
|
||||
)
|
||||
elif refreshed.status != record.status:
|
||||
logger.info(
|
||||
"ℹ️ %s %s обновлён: %s → %s",
|
||||
method_display_name(refreshed.method),
|
||||
refreshed.identifier,
|
||||
record.status or "—",
|
||||
refreshed.status or "—",
|
||||
)
|
||||
else:
|
||||
logger.debug(
|
||||
"Автопроверка пополнений: %s %s без изменений (%s)",
|
||||
method_display_name(refreshed.method),
|
||||
refreshed.identifier,
|
||||
refreshed.status or "—",
|
||||
)
|
||||
|
||||
if session.in_transaction():
|
||||
await session.commit()
|
||||
except Exception:
|
||||
if session.in_transaction():
|
||||
await session.rollback()
|
||||
raise
|
||||
|
||||
|
||||
auto_payment_verification_service = AutoPaymentVerificationService()
|
||||
|
||||
def _is_pal24_pending(payment: Pal24Payment) -> bool:
|
||||
if payment.is_paid:
|
||||
return False
|
||||
status = (payment.status or "").upper()
|
||||
return status in {"NEW", "PROCESS"}
|
||||
|
||||
|
||||
def _is_mulenpay_pending(payment: MulenPayPayment) -> bool:
|
||||
if payment.is_paid:
|
||||
return False
|
||||
status = (payment.status or "").lower()
|
||||
return status in {"created", "processing", "hold"}
|
||||
|
||||
|
||||
def _is_wata_pending(payment: WataPayment) -> bool:
|
||||
if payment.is_paid:
|
||||
return False
|
||||
status = (payment.status or "").lower()
|
||||
return status not in {
|
||||
"paid",
|
||||
"closed",
|
||||
"declined",
|
||||
"canceled",
|
||||
"cancelled",
|
||||
"expired",
|
||||
}
|
||||
|
||||
|
||||
def _is_heleket_pending(payment: HeleketPayment) -> bool:
|
||||
if payment.is_paid:
|
||||
return False
|
||||
status = (payment.status or "").lower()
|
||||
return status not in {"paid", "paid_over", "cancel", "canceled", "failed", "fail", "expired"}
|
||||
|
||||
|
||||
def _is_yookassa_pending(payment: YooKassaPayment) -> bool:
|
||||
if getattr(payment, "is_paid", False) and payment.status == "succeeded":
|
||||
return False
|
||||
status = (payment.status or "").lower()
|
||||
return status in {"pending", "waiting_for_capture"}
|
||||
|
||||
|
||||
def _is_cryptobot_pending(payment: CryptoBotPayment) -> bool:
|
||||
status = (payment.status or "").lower()
|
||||
return status == "active"
|
||||
|
||||
|
||||
def _parse_cryptobot_amount_kopeks(payment: CryptoBotPayment) -> int:
|
||||
payload = payment.payload or ""
|
||||
match = re.search(r"_(\d+)$", payload)
|
||||
if match:
|
||||
try:
|
||||
return int(match.group(1))
|
||||
except ValueError:
|
||||
return 0
|
||||
return 0
|
||||
|
||||
|
||||
def _metadata_is_balance(payment: YooKassaPayment) -> bool:
|
||||
metadata = getattr(payment, "metadata_json", {}) or {}
|
||||
payment_type = str(metadata.get("type") or metadata.get("payment_type") or "").lower()
|
||||
return payment_type.startswith("balance_topup")
|
||||
|
||||
|
||||
def _build_record(method: PaymentMethod, payment: Any, *, identifier: str, amount_kopeks: int,
|
||||
status: str, is_paid: bool, expires_at: Optional[datetime] = None) -> Optional[PendingPayment]:
|
||||
user = getattr(payment, "user", None)
|
||||
if user is None:
|
||||
logger.debug("Skipping %s payment %s without linked user", method.value, identifier)
|
||||
return None
|
||||
|
||||
created_at = getattr(payment, "created_at", None)
|
||||
if not isinstance(created_at, datetime):
|
||||
logger.debug("Skipping %s payment %s without valid created_at", method.value, identifier)
|
||||
return None
|
||||
|
||||
local_id = getattr(payment, "id", None)
|
||||
if local_id is None:
|
||||
logger.debug("Skipping %s payment without local id", method.value)
|
||||
return None
|
||||
|
||||
return PendingPayment(
|
||||
method=method,
|
||||
local_id=int(local_id),
|
||||
identifier=identifier,
|
||||
amount_kopeks=amount_kopeks,
|
||||
status=status,
|
||||
is_paid=is_paid,
|
||||
created_at=created_at,
|
||||
user=user,
|
||||
payment=payment,
|
||||
expires_at=expires_at,
|
||||
)
|
||||
|
||||
|
||||
async def _fetch_pal24_payments(db: AsyncSession, cutoff: datetime) -> List[PendingPayment]:
|
||||
stmt = (
|
||||
select(Pal24Payment)
|
||||
.options(selectinload(Pal24Payment.user))
|
||||
.where(Pal24Payment.created_at >= cutoff)
|
||||
.order_by(desc(Pal24Payment.created_at))
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
records: List[PendingPayment] = []
|
||||
for payment in result.scalars().all():
|
||||
if not _is_pal24_pending(payment):
|
||||
continue
|
||||
record = _build_record(
|
||||
PaymentMethod.PAL24,
|
||||
payment,
|
||||
identifier=payment.bill_id,
|
||||
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_mulenpay_payments(db: AsyncSession, cutoff: datetime) -> List[PendingPayment]:
|
||||
stmt = (
|
||||
select(MulenPayPayment)
|
||||
.options(selectinload(MulenPayPayment.user))
|
||||
.where(MulenPayPayment.created_at >= cutoff)
|
||||
.order_by(desc(MulenPayPayment.created_at))
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
records: List[PendingPayment] = []
|
||||
for payment in result.scalars().all():
|
||||
if not _is_mulenpay_pending(payment):
|
||||
continue
|
||||
record = _build_record(
|
||||
PaymentMethod.MULENPAY,
|
||||
payment,
|
||||
identifier=payment.uuid,
|
||||
amount_kopeks=payment.amount_kopeks,
|
||||
status=payment.status or "",
|
||||
is_paid=bool(payment.is_paid),
|
||||
)
|
||||
if record:
|
||||
records.append(record)
|
||||
return records
|
||||
|
||||
|
||||
async def _fetch_wata_payments(db: AsyncSession, cutoff: datetime) -> List[PendingPayment]:
|
||||
stmt = (
|
||||
select(WataPayment)
|
||||
.options(selectinload(WataPayment.user))
|
||||
.where(WataPayment.created_at >= cutoff)
|
||||
.order_by(desc(WataPayment.created_at))
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
records: List[PendingPayment] = []
|
||||
for payment in result.scalars().all():
|
||||
if not _is_wata_pending(payment):
|
||||
continue
|
||||
record = _build_record(
|
||||
PaymentMethod.WATA,
|
||||
payment,
|
||||
identifier=payment.payment_link_id,
|
||||
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)
|
||||
.options(selectinload(HeleketPayment.user))
|
||||
.where(HeleketPayment.created_at >= cutoff)
|
||||
.order_by(desc(HeleketPayment.created_at))
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
records: List[PendingPayment] = []
|
||||
for payment in result.scalars().all():
|
||||
if not _is_heleket_pending(payment):
|
||||
continue
|
||||
record = _build_record(
|
||||
PaymentMethod.HELEKET,
|
||||
payment,
|
||||
identifier=payment.uuid,
|
||||
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_yookassa_payments(db: AsyncSession, cutoff: datetime) -> List[PendingPayment]:
|
||||
stmt = (
|
||||
select(YooKassaPayment)
|
||||
.options(selectinload(YooKassaPayment.user))
|
||||
.where(YooKassaPayment.created_at >= cutoff)
|
||||
.order_by(desc(YooKassaPayment.created_at))
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
records: List[PendingPayment] = []
|
||||
for payment in result.scalars().all():
|
||||
if payment.transaction_id:
|
||||
continue
|
||||
if not _metadata_is_balance(payment):
|
||||
continue
|
||||
if not _is_yookassa_pending(payment):
|
||||
continue
|
||||
record = _build_record(
|
||||
PaymentMethod.YOOKASSA,
|
||||
payment,
|
||||
identifier=payment.yookassa_payment_id,
|
||||
amount_kopeks=payment.amount_kopeks,
|
||||
status=payment.status or "",
|
||||
is_paid=bool(getattr(payment, "is_paid", False)),
|
||||
)
|
||||
if record:
|
||||
records.append(record)
|
||||
return records
|
||||
|
||||
|
||||
async def _fetch_cryptobot_payments(db: AsyncSession, cutoff: datetime) -> List[PendingPayment]:
|
||||
stmt = (
|
||||
select(CryptoBotPayment)
|
||||
.options(selectinload(CryptoBotPayment.user))
|
||||
.where(CryptoBotPayment.created_at >= cutoff)
|
||||
.order_by(desc(CryptoBotPayment.created_at))
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
records: List[PendingPayment] = []
|
||||
for payment in result.scalars().all():
|
||||
status = (payment.status or "").lower()
|
||||
if not _is_cryptobot_pending(payment) and status != "paid":
|
||||
continue
|
||||
amount_kopeks = _parse_cryptobot_amount_kopeks(payment)
|
||||
record = _build_record(
|
||||
PaymentMethod.CRYPTOBOT,
|
||||
payment,
|
||||
identifier=payment.invoice_id,
|
||||
amount_kopeks=amount_kopeks,
|
||||
status=payment.status or "",
|
||||
is_paid=bool(payment.is_paid),
|
||||
)
|
||||
if record:
|
||||
records.append(record)
|
||||
return records
|
||||
|
||||
|
||||
async def _fetch_stars_transactions(db: AsyncSession, cutoff: datetime) -> List[PendingPayment]:
|
||||
stmt = (
|
||||
select(Transaction)
|
||||
.options(selectinload(Transaction.user))
|
||||
.where(
|
||||
Transaction.created_at >= cutoff,
|
||||
Transaction.type == TransactionType.DEPOSIT.value,
|
||||
Transaction.payment_method == PaymentMethod.TELEGRAM_STARS.value,
|
||||
)
|
||||
.order_by(desc(Transaction.created_at))
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
records: List[PendingPayment] = []
|
||||
for transaction in result.scalars().all():
|
||||
record = _build_record(
|
||||
PaymentMethod.TELEGRAM_STARS,
|
||||
transaction,
|
||||
identifier=transaction.external_id or str(transaction.id),
|
||||
amount_kopeks=transaction.amount_kopeks,
|
||||
status="paid" if transaction.is_completed else "pending",
|
||||
is_paid=bool(transaction.is_completed),
|
||||
)
|
||||
if record:
|
||||
records.append(record)
|
||||
return records
|
||||
|
||||
|
||||
async def list_recent_pending_payments(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
max_age: timedelta = PENDING_MAX_AGE,
|
||||
) -> List[PendingPayment]:
|
||||
"""Return pending payments (top-ups) from supported providers within the age window."""
|
||||
|
||||
cutoff = datetime.utcnow() - max_age
|
||||
|
||||
tasks: Iterable[List[PendingPayment]] = (
|
||||
await _fetch_yookassa_payments(db, cutoff),
|
||||
await _fetch_pal24_payments(db, cutoff),
|
||||
await _fetch_mulenpay_payments(db, cutoff),
|
||||
await _fetch_wata_payments(db, cutoff),
|
||||
await _fetch_heleket_payments(db, cutoff),
|
||||
await _fetch_cryptobot_payments(db, cutoff),
|
||||
await _fetch_stars_transactions(db, cutoff),
|
||||
)
|
||||
|
||||
records: List[PendingPayment] = []
|
||||
for batch in tasks:
|
||||
records.extend(batch)
|
||||
|
||||
records.sort(key=lambda item: item.created_at, reverse=True)
|
||||
return records
|
||||
|
||||
|
||||
async def get_payment_record(
|
||||
db: AsyncSession,
|
||||
method: PaymentMethod,
|
||||
local_payment_id: int,
|
||||
) -> Optional[PendingPayment]:
|
||||
"""Load single payment record and normalize it to :class:`PendingPayment`."""
|
||||
|
||||
cutoff = datetime.utcnow() - PENDING_MAX_AGE
|
||||
|
||||
if method == PaymentMethod.PAL24:
|
||||
payment = await db.get(Pal24Payment, local_payment_id)
|
||||
if not payment:
|
||||
return None
|
||||
await db.refresh(payment, attribute_names=["user"])
|
||||
return _build_record(
|
||||
method,
|
||||
payment,
|
||||
identifier=payment.bill_id,
|
||||
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.MULENPAY:
|
||||
payment = await db.get(MulenPayPayment, local_payment_id)
|
||||
if not payment:
|
||||
return None
|
||||
await db.refresh(payment, attribute_names=["user"])
|
||||
return _build_record(
|
||||
method,
|
||||
payment,
|
||||
identifier=payment.uuid,
|
||||
amount_kopeks=payment.amount_kopeks,
|
||||
status=payment.status or "",
|
||||
is_paid=bool(payment.is_paid),
|
||||
)
|
||||
|
||||
if method == PaymentMethod.WATA:
|
||||
payment = await db.get(WataPayment, local_payment_id)
|
||||
if not payment:
|
||||
return None
|
||||
await db.refresh(payment, attribute_names=["user"])
|
||||
return _build_record(
|
||||
method,
|
||||
payment,
|
||||
identifier=payment.payment_link_id,
|
||||
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:
|
||||
return None
|
||||
await db.refresh(payment, attribute_names=["user"])
|
||||
return _build_record(
|
||||
method,
|
||||
payment,
|
||||
identifier=payment.uuid,
|
||||
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.YOOKASSA:
|
||||
payment = await db.get(YooKassaPayment, local_payment_id)
|
||||
if not payment:
|
||||
return None
|
||||
await db.refresh(payment, attribute_names=["user"])
|
||||
if payment.created_at < cutoff:
|
||||
logger.debug("YooKassa payment %s is older than cutoff", payment.id)
|
||||
return _build_record(
|
||||
method,
|
||||
payment,
|
||||
identifier=payment.yookassa_payment_id,
|
||||
amount_kopeks=payment.amount_kopeks,
|
||||
status=payment.status or "",
|
||||
is_paid=bool(getattr(payment, "is_paid", False)),
|
||||
)
|
||||
|
||||
if method == PaymentMethod.CRYPTOBOT:
|
||||
payment = await db.get(CryptoBotPayment, local_payment_id)
|
||||
if not payment:
|
||||
return None
|
||||
await db.refresh(payment, attribute_names=["user"])
|
||||
amount_kopeks = _parse_cryptobot_amount_kopeks(payment)
|
||||
return _build_record(
|
||||
method,
|
||||
payment,
|
||||
identifier=payment.invoice_id,
|
||||
amount_kopeks=amount_kopeks,
|
||||
status=payment.status or "",
|
||||
is_paid=bool(payment.is_paid),
|
||||
)
|
||||
|
||||
if method == PaymentMethod.TELEGRAM_STARS:
|
||||
transaction = await db.get(Transaction, local_payment_id)
|
||||
if not transaction:
|
||||
return None
|
||||
await db.refresh(transaction, attribute_names=["user"])
|
||||
if transaction.payment_method != PaymentMethod.TELEGRAM_STARS.value:
|
||||
return None
|
||||
return _build_record(
|
||||
method,
|
||||
transaction,
|
||||
identifier=transaction.external_id or str(transaction.id),
|
||||
amount_kopeks=transaction.amount_kopeks,
|
||||
status="paid" if transaction.is_completed else "pending",
|
||||
is_paid=bool(transaction.is_completed),
|
||||
)
|
||||
|
||||
logger.debug("Unsupported payment method requested: %s", method)
|
||||
return None
|
||||
|
||||
|
||||
async def run_manual_check(
|
||||
db: AsyncSession,
|
||||
method: PaymentMethod,
|
||||
local_payment_id: int,
|
||||
payment_service: "PaymentService",
|
||||
) -> Optional[PendingPayment]:
|
||||
"""Trigger provider specific status refresh and return the updated record."""
|
||||
|
||||
try:
|
||||
if method == PaymentMethod.PAL24:
|
||||
result = await payment_service.get_pal24_payment_status(db, local_payment_id)
|
||||
payment = result.get("payment") if result else None
|
||||
elif method == PaymentMethod.MULENPAY:
|
||||
result = await payment_service.get_mulenpay_payment_status(db, local_payment_id)
|
||||
payment = result.get("payment") if result else None
|
||||
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.HELEKET:
|
||||
payment = await payment_service.sync_heleket_payment_status(
|
||||
db, local_payment_id=local_payment_id
|
||||
)
|
||||
elif method == PaymentMethod.YOOKASSA:
|
||||
result = await payment_service.get_yookassa_payment_status(db, local_payment_id)
|
||||
payment = result.get("payment") if result else None
|
||||
elif method == PaymentMethod.CRYPTOBOT:
|
||||
result = await payment_service.get_cryptobot_payment_status(db, local_payment_id)
|
||||
payment = result.get("payment") if result else None
|
||||
else:
|
||||
logger.warning("Manual check requested for unsupported method %s", method)
|
||||
return None
|
||||
|
||||
if not payment:
|
||||
return None
|
||||
|
||||
return await get_payment_record(db, method, local_payment_id)
|
||||
|
||||
except Exception as error: # pragma: no cover - defensive logging
|
||||
logger.error(
|
||||
"Manual status check failed for %s payment %s: %s",
|
||||
method.value,
|
||||
local_payment_id,
|
||||
error,
|
||||
exc_info=True,
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
if TYPE_CHECKING: # pragma: no cover
|
||||
from app.services.payment_service import PaymentService
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
import asyncio
|
||||
import logging
|
||||
from types import SimpleNamespace
|
||||
from typing import Iterable
|
||||
|
||||
from aiogram import Bot
|
||||
from aiogram.exceptions import TelegramBadRequest
|
||||
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
|
||||
from sqlalchemy import and_, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.crud.user import add_user_balance
|
||||
from app.database.models import (
|
||||
Poll,
|
||||
PollOption,
|
||||
PollQuestion,
|
||||
PollResponse,
|
||||
TransactionType,
|
||||
User,
|
||||
)
|
||||
from app.localization.texts import get_texts
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _build_poll_invitation_text(poll: Poll, language: str) -> str:
|
||||
texts = get_texts(language)
|
||||
|
||||
lines: list[str] = [f"🗳️ <b>{poll.title}</b>"]
|
||||
if poll.description:
|
||||
lines.append(poll.description)
|
||||
|
||||
if poll.reward_enabled and poll.reward_amount_kopeks > 0:
|
||||
reward_line = texts.t(
|
||||
"POLL_INVITATION_REWARD",
|
||||
"🎁 За участие вы получите {amount}.",
|
||||
).format(amount=settings.format_price(poll.reward_amount_kopeks))
|
||||
lines.append(reward_line)
|
||||
|
||||
lines.append(
|
||||
texts.t(
|
||||
"POLL_INVITATION_START",
|
||||
"Нажмите кнопку ниже, чтобы пройти опрос.",
|
||||
)
|
||||
)
|
||||
|
||||
return "\n\n".join(lines)
|
||||
|
||||
|
||||
def build_start_keyboard(response_id: int, language: str) -> InlineKeyboardMarkup:
|
||||
texts = get_texts(language)
|
||||
return InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t("POLL_START_BUTTON", "📝 Пройти опрос"),
|
||||
callback_data=f"poll_start:{response_id}",
|
||||
)
|
||||
]
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
async def send_poll_to_users(
|
||||
bot: Bot,
|
||||
db: AsyncSession,
|
||||
poll: Poll,
|
||||
users: Iterable[User],
|
||||
) -> dict:
|
||||
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(
|
||||
id=user.id,
|
||||
telegram_id=user.telegram_id,
|
||||
language=user.language,
|
||||
)
|
||||
for user in users
|
||||
]
|
||||
|
||||
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,
|
||||
)
|
||||
)
|
||||
)
|
||||
if existing_response.scalar_one_or_none():
|
||||
skipped += 1
|
||||
continue
|
||||
|
||||
response = PollResponse(
|
||||
poll_id=poll_id,
|
||||
user_id=user.id,
|
||||
)
|
||||
db.add(response)
|
||||
|
||||
try:
|
||||
await db.flush()
|
||||
|
||||
text = _build_poll_invitation_text(poll_snapshot, user.language)
|
||||
keyboard = build_start_keyboard(response.id, user.language)
|
||||
|
||||
await bot.send_message(
|
||||
chat_id=user.telegram_id,
|
||||
text=text,
|
||||
reply_markup=keyboard,
|
||||
parse_mode="HTML",
|
||||
disable_web_page_preview=True,
|
||||
)
|
||||
|
||||
await db.commit()
|
||||
sent += 1
|
||||
|
||||
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
|
||||
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,
|
||||
"failed": failed,
|
||||
"skipped": skipped,
|
||||
"total": sent + failed + skipped,
|
||||
}
|
||||
|
||||
|
||||
async def reward_user_for_poll(
|
||||
db: AsyncSession,
|
||||
response: PollResponse,
|
||||
) -> int:
|
||||
await db.refresh(response, with_for_update=True)
|
||||
|
||||
poll = response.poll
|
||||
if not poll.reward_enabled or poll.reward_amount_kopeks <= 0:
|
||||
return 0
|
||||
|
||||
if response.reward_given:
|
||||
return response.reward_amount_kopeks
|
||||
|
||||
user = response.user
|
||||
description = f"Награда за участие в опросе \"{poll.title}\""
|
||||
|
||||
response.reward_given = True
|
||||
response.reward_amount_kopeks = poll.reward_amount_kopeks
|
||||
|
||||
success = await add_user_balance(
|
||||
db,
|
||||
user,
|
||||
poll.reward_amount_kopeks,
|
||||
description,
|
||||
transaction_type=TransactionType.POLL_REWARD,
|
||||
)
|
||||
|
||||
if not success:
|
||||
return 0
|
||||
|
||||
await db.refresh(
|
||||
response,
|
||||
attribute_names=["reward_given", "reward_amount_kopeks"],
|
||||
)
|
||||
|
||||
return poll.reward_amount_kopeks
|
||||
|
||||
|
||||
async def get_next_question(response: PollResponse) -> tuple[int | None, PollQuestion | None]:
|
||||
if not response.poll or not response.poll.questions:
|
||||
return None, None
|
||||
|
||||
answered_question_ids = {answer.question_id for answer in response.answers}
|
||||
ordered_questions = sorted(response.poll.questions, key=lambda q: q.order)
|
||||
|
||||
for index, question in enumerate(ordered_questions, start=1):
|
||||
if question.id not in answered_question_ids:
|
||||
return index, question
|
||||
|
||||
return None, None
|
||||
|
||||
|
||||
async def get_question_option(question: PollQuestion, option_id: int) -> PollOption | None:
|
||||
for option in question.options:
|
||||
if option.id == option_id:
|
||||
return option
|
||||
return None
|
||||
@@ -131,12 +131,20 @@ class PromoCodeService:
|
||||
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=1,
|
||||
device_limit=device_limit,
|
||||
connected_squads=trial_squads,
|
||||
update_server_counters=True,
|
||||
)
|
||||
@@ -155,10 +163,15 @@ 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
|
||||
duration_days=trial_days,
|
||||
device_limit=forced_devices,
|
||||
)
|
||||
|
||||
await self.subscription_service.create_remnawave_user(db, trial_subscription)
|
||||
|
||||
@@ -89,16 +89,64 @@ 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()
|
||||
|
||||
@@ -161,36 +209,33 @@ async def process_referral_topup(
|
||||
await send_referral_notification(bot, referrer.telegram_id, inviter_bonus_notification)
|
||||
|
||||
else:
|
||||
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
|
||||
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"💎 Средства зачислены на ваш баланс."
|
||||
)
|
||||
|
||||
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)
|
||||
await send_referral_notification(bot, referrer.telegram_id, commission_notification)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
+300
-112
@@ -1,9 +1,9 @@
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
from contextlib import AsyncExitStack, asynccontextmanager
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Any, Dict, List, Optional
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
@@ -13,9 +13,15 @@ from app.external.remnawave_api import (
|
||||
RemnaWaveNode, UserStatus, TrafficLimitStrategy, RemnaWaveAPIError
|
||||
)
|
||||
from sqlalchemy import and_, cast, delete, func, select, update, String
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.orm import selectinload
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from app.database.crud.user import get_users_list, get_user_by_telegram_id, update_user
|
||||
from app.database.crud.user import (
|
||||
create_user,
|
||||
get_users_list,
|
||||
get_user_by_telegram_id,
|
||||
update_user,
|
||||
)
|
||||
from app.database.crud.subscription import (
|
||||
get_subscription_by_user_id,
|
||||
update_subscription_usage,
|
||||
@@ -32,6 +38,10 @@ from app.database.models import (
|
||||
SubscriptionStatus,
|
||||
ServerSquad,
|
||||
)
|
||||
from app.utils.subscription_utils import (
|
||||
resolve_hwid_device_limit_for_payload,
|
||||
)
|
||||
from app.utils.timezone import get_local_timezone
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -49,15 +59,7 @@ class RemnaWaveService:
|
||||
|
||||
self._config_error: Optional[str] = None
|
||||
|
||||
tz_name = os.getenv("TZ", "UTC")
|
||||
try:
|
||||
self._panel_timezone = ZoneInfo(tz_name)
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"⚠️ Не удалось загрузить временную зону '%s'. Используется UTC.",
|
||||
tz_name,
|
||||
)
|
||||
self._panel_timezone = ZoneInfo("UTC")
|
||||
self._panel_timezone = get_local_timezone()
|
||||
|
||||
if not base_url:
|
||||
self._config_error = "REMNAWAVE_API_URL не настроен"
|
||||
@@ -132,6 +134,116 @@ class RemnaWaveService:
|
||||
except Exception as e:
|
||||
logger.warning(f"⚠️ Не удалось распарсить дату '{date_str}': {e}. Используем дефолтную дату.")
|
||||
return self._now_in_panel_timezone() + timedelta(days=30)
|
||||
|
||||
def _safe_panel_expire_date(self, panel_user: Dict[str, Any]) -> datetime:
|
||||
"""Парсит дату окончания подписки пользователя панели для сравнения."""
|
||||
|
||||
expire_at_value = panel_user.get('expireAt')
|
||||
|
||||
if expire_at_value is None:
|
||||
return datetime.min.replace(tzinfo=None)
|
||||
|
||||
expire_at_str = str(expire_at_value).strip()
|
||||
if not expire_at_str:
|
||||
return datetime.min.replace(tzinfo=None)
|
||||
|
||||
return self._parse_remnawave_date(expire_at_str)
|
||||
|
||||
def _is_preferred_panel_user(
|
||||
self,
|
||||
*,
|
||||
candidate: Dict[str, Any],
|
||||
current: Dict[str, Any],
|
||||
) -> bool:
|
||||
"""Определяет, является ли новая запись предпочтительной для Telegram ID."""
|
||||
|
||||
candidate_expire = self._safe_panel_expire_date(candidate)
|
||||
current_expire = self._safe_panel_expire_date(current)
|
||||
|
||||
if candidate_expire > current_expire:
|
||||
return True
|
||||
if candidate_expire < current_expire:
|
||||
return False
|
||||
|
||||
candidate_status = (candidate.get('status') or '').upper()
|
||||
current_status = (current.get('status') or '').upper()
|
||||
|
||||
active_statuses = {'ACTIVE', 'TRIAL'}
|
||||
if candidate_status in active_statuses and current_status not in active_statuses:
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def _deduplicate_panel_users_by_telegram_id(
|
||||
self,
|
||||
panel_users: List[Dict[str, Any]],
|
||||
) -> Dict[Any, Dict[str, Any]]:
|
||||
"""Возвращает уникальных пользователей панели по Telegram ID."""
|
||||
|
||||
unique_users: Dict[Any, Dict[str, Any]] = {}
|
||||
|
||||
for panel_user in panel_users:
|
||||
telegram_id = panel_user.get('telegramId')
|
||||
if telegram_id is None:
|
||||
continue
|
||||
|
||||
existing_user = unique_users.get(telegram_id)
|
||||
if existing_user is None or self._is_preferred_panel_user(
|
||||
candidate=panel_user,
|
||||
current=existing_user,
|
||||
):
|
||||
unique_users[telegram_id] = panel_user
|
||||
|
||||
return unique_users
|
||||
|
||||
async def _get_or_create_bot_user_from_panel(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
panel_user: Dict[str, Any],
|
||||
) -> Tuple[Optional[User], bool]:
|
||||
"""Возвращает пользователя бота, создавая его при необходимости.
|
||||
|
||||
При конфликте уникальности telegram_id повторно загружает пользователя
|
||||
из базы данных и сообщает, что запись не была создана заново.
|
||||
"""
|
||||
|
||||
telegram_id = panel_user.get("telegramId")
|
||||
if telegram_id is None:
|
||||
return None, False
|
||||
|
||||
username = panel_user.get("username") or f"user_{telegram_id}"
|
||||
|
||||
try:
|
||||
db_user = await create_user(
|
||||
db=db,
|
||||
telegram_id=telegram_id,
|
||||
username=username,
|
||||
first_name=f"Panel User {telegram_id}",
|
||||
language="ru",
|
||||
)
|
||||
return db_user, True
|
||||
except IntegrityError as create_error:
|
||||
logger.info(
|
||||
"♻️ Пользователь с telegram_id %s уже существует. Используем существующую запись.",
|
||||
telegram_id,
|
||||
)
|
||||
|
||||
try:
|
||||
await db.rollback()
|
||||
except Exception:
|
||||
# create_user уже выполняет rollback при необходимости
|
||||
pass
|
||||
|
||||
existing_user = await get_user_by_telegram_id(db, telegram_id)
|
||||
if existing_user is None:
|
||||
raise create_error
|
||||
|
||||
logger.debug(
|
||||
"Используется существующий пользователь %s после конфликта уникальности: %s",
|
||||
telegram_id,
|
||||
create_error,
|
||||
)
|
||||
return existing_user, False
|
||||
|
||||
async def get_system_statistics(self) -> Dict[str, Any]:
|
||||
try:
|
||||
@@ -773,47 +885,67 @@ class RemnaWaveService:
|
||||
logger.info(f"📊 Пользователей в боте: {len(bot_users)}")
|
||||
|
||||
panel_users_with_tg = [
|
||||
user for user in panel_users
|
||||
user for user in panel_users
|
||||
if user.get('telegramId') is not None
|
||||
]
|
||||
|
||||
|
||||
logger.info(f"📊 Пользователей в панели с Telegram ID: {len(panel_users_with_tg)}")
|
||||
|
||||
panel_telegram_ids = set()
|
||||
|
||||
for i, panel_user in enumerate(panel_users_with_tg):
|
||||
|
||||
unique_panel_users_map = self._deduplicate_panel_users_by_telegram_id(panel_users_with_tg)
|
||||
unique_panel_users = list(unique_panel_users_map.values())
|
||||
duplicates_count = len(panel_users_with_tg) - len(unique_panel_users)
|
||||
|
||||
if duplicates_count:
|
||||
logger.info(
|
||||
"♻️ Обнаружено %s дубликатов пользователей по Telegram ID. Используем самые свежие записи.",
|
||||
duplicates_count,
|
||||
)
|
||||
|
||||
panel_telegram_ids = set(unique_panel_users_map.keys())
|
||||
|
||||
for i, panel_user in enumerate(unique_panel_users):
|
||||
try:
|
||||
telegram_id = panel_user.get('telegramId')
|
||||
if not telegram_id:
|
||||
continue
|
||||
|
||||
panel_telegram_ids.add(telegram_id)
|
||||
|
||||
if (i + 1) % 10 == 0:
|
||||
logger.info(f"🔄 Обрабатываем пользователя {i+1}/{len(panel_users_with_tg)}: {telegram_id}")
|
||||
|
||||
if (i + 1) % 10 == 0:
|
||||
logger.info(f"🔄 Обрабатываем пользователя {i+1}/{len(unique_panel_users)}: {telegram_id}")
|
||||
|
||||
db_user = bot_users_by_telegram_id.get(telegram_id)
|
||||
|
||||
if not db_user:
|
||||
if sync_type in ["new_only", "all"]:
|
||||
logger.info(f"🆕 Создание пользователя для telegram_id {telegram_id}")
|
||||
|
||||
from app.database.crud.user import create_user
|
||||
|
||||
db_user = await create_user(
|
||||
db=db,
|
||||
telegram_id=telegram_id,
|
||||
username=panel_user.get('username') or f"user_{telegram_id}",
|
||||
first_name=f"Panel User {telegram_id}",
|
||||
language="ru"
|
||||
)
|
||||
|
||||
await update_user(db, db_user, remnawave_uuid=panel_user.get('uuid'))
|
||||
|
||||
await self._create_subscription_from_panel_data(db, db_user, panel_user)
|
||||
|
||||
stats["created"] += 1
|
||||
logger.info(f"✅ Создан пользователь {telegram_id} с подпиской")
|
||||
|
||||
db_user, is_created = await self._get_or_create_bot_user_from_panel(db, panel_user)
|
||||
|
||||
if not db_user:
|
||||
logger.error(
|
||||
"❌ Не удалось создать или получить пользователя для telegram_id %s",
|
||||
telegram_id,
|
||||
)
|
||||
stats["errors"] += 1
|
||||
continue
|
||||
|
||||
bot_users_by_telegram_id[telegram_id] = db_user
|
||||
|
||||
if not db_user.remnawave_uuid:
|
||||
await update_user(db, db_user, remnawave_uuid=panel_user.get('uuid'))
|
||||
|
||||
if is_created or not getattr(db_user, "subscription", None):
|
||||
await self._create_subscription_from_panel_data(db, db_user, panel_user)
|
||||
else:
|
||||
await self._update_subscription_from_panel_data(db, db_user, panel_user)
|
||||
|
||||
if is_created:
|
||||
stats["created"] += 1
|
||||
logger.info(f"✅ Создан пользователь {telegram_id} с подпиской")
|
||||
else:
|
||||
stats["updated"] += 1
|
||||
logger.info(
|
||||
f"♻️ Обновлена подписка существующего пользователя {telegram_id}"
|
||||
)
|
||||
|
||||
else:
|
||||
if sync_type in ["update_only", "all"]:
|
||||
@@ -1080,44 +1212,57 @@ class RemnaWaveService:
|
||||
for user in users:
|
||||
if not user.subscription:
|
||||
continue
|
||||
|
||||
|
||||
try:
|
||||
subscription = user.subscription
|
||||
|
||||
hwid_limit = resolve_hwid_device_limit_for_payload(subscription)
|
||||
|
||||
if user.remnawave_uuid:
|
||||
await api.update_user(
|
||||
update_kwargs = dict(
|
||||
uuid=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=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
|
||||
|
||||
await api.update_user(**update_kwargs)
|
||||
stats["updated"] += 1
|
||||
else:
|
||||
username = f"user_{user.telegram_id}"
|
||||
|
||||
new_user = await api.create_user(
|
||||
username = settings.format_remnawave_username(
|
||||
full_name=user.full_name,
|
||||
username=user.username,
|
||||
telegram_id=user.telegram_id,
|
||||
)
|
||||
|
||||
create_kwargs = dict(
|
||||
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=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
|
||||
|
||||
new_user = await api.create_user(**create_kwargs)
|
||||
|
||||
await update_user(db, user, remnawave_uuid=new_user.uuid)
|
||||
subscription.remnawave_short_uuid = new_user.short_uuid
|
||||
@@ -1846,67 +1991,110 @@ class RemnaWaveService:
|
||||
}
|
||||
|
||||
async def check_panel_health(self) -> Dict[str, Any]:
|
||||
try:
|
||||
start_time = datetime.utcnow()
|
||||
|
||||
async with self.get_api_client() as api:
|
||||
try:
|
||||
system_stats = await api.get_system_stats()
|
||||
api_available = True
|
||||
api_error = None
|
||||
except Exception as e:
|
||||
api_available = False
|
||||
api_error = str(e)
|
||||
system_stats = {}
|
||||
|
||||
try:
|
||||
nodes = await api.get_all_nodes()
|
||||
nodes_online = sum(1 for node in nodes if node.is_connected and node.is_node_online)
|
||||
total_nodes = len(nodes)
|
||||
nodes_health = "healthy" if nodes_online > 0 else "unhealthy"
|
||||
except Exception:
|
||||
nodes_online = 0
|
||||
total_nodes = 0
|
||||
nodes_health = "unknown"
|
||||
|
||||
end_time = datetime.utcnow()
|
||||
response_time = (end_time - start_time).total_seconds()
|
||||
|
||||
if not api_available:
|
||||
status = "offline"
|
||||
elif response_time > 10:
|
||||
status = "degraded"
|
||||
elif nodes_health == "unhealthy":
|
||||
status = "degraded"
|
||||
else:
|
||||
status = "online"
|
||||
|
||||
return {
|
||||
"status": status,
|
||||
"api_available": api_available,
|
||||
"api_error": api_error,
|
||||
"response_time": round(response_time, 2),
|
||||
"nodes_online": nodes_online,
|
||||
"total_nodes": total_nodes,
|
||||
"nodes_health": nodes_health,
|
||||
"users_online": system_stats.get('onlineStats', {}).get('onlineNow', 0),
|
||||
"total_users": system_stats.get('users', {}).get('totalUsers', 0),
|
||||
"last_check": end_time,
|
||||
"api_url": settings.REMNAWAVE_API_URL
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка проверки здоровья панели: {e}")
|
||||
return {
|
||||
"status": "offline",
|
||||
"api_available": False,
|
||||
"api_error": str(e),
|
||||
"response_time": 0,
|
||||
"nodes_online": 0,
|
||||
"total_nodes": 0,
|
||||
"nodes_health": "unknown",
|
||||
"last_check": datetime.utcnow(),
|
||||
"api_url": settings.REMNAWAVE_API_URL
|
||||
}
|
||||
attempts = settings.get_maintenance_retry_attempts()
|
||||
attempts = max(1, attempts)
|
||||
|
||||
last_result: Optional[Dict[str, Any]] = None
|
||||
last_error: Optional[Exception] = None
|
||||
|
||||
for attempt in range(1, attempts + 1):
|
||||
try:
|
||||
start_time = datetime.utcnow()
|
||||
|
||||
async with self.get_api_client() as api:
|
||||
try:
|
||||
system_stats = await api.get_system_stats()
|
||||
api_available = True
|
||||
api_error = None
|
||||
except Exception as e:
|
||||
api_available = False
|
||||
api_error = str(e)
|
||||
system_stats = {}
|
||||
|
||||
try:
|
||||
nodes = await api.get_all_nodes()
|
||||
nodes_online = sum(
|
||||
1 for node in nodes if node.is_connected and node.is_node_online
|
||||
)
|
||||
total_nodes = len(nodes)
|
||||
nodes_health = "healthy" if nodes_online > 0 else "unhealthy"
|
||||
except Exception:
|
||||
nodes_online = 0
|
||||
total_nodes = 0
|
||||
nodes_health = "unknown"
|
||||
|
||||
end_time = datetime.utcnow()
|
||||
response_time = (end_time - start_time).total_seconds()
|
||||
|
||||
if not api_available:
|
||||
status = "offline"
|
||||
elif response_time > 10:
|
||||
status = "degraded"
|
||||
elif nodes_health == "unhealthy":
|
||||
status = "degraded"
|
||||
else:
|
||||
status = "online"
|
||||
|
||||
result = {
|
||||
"status": status,
|
||||
"api_available": api_available,
|
||||
"api_error": api_error,
|
||||
"response_time": round(response_time, 2),
|
||||
"nodes_online": nodes_online,
|
||||
"total_nodes": total_nodes,
|
||||
"nodes_health": nodes_health,
|
||||
"users_online": system_stats.get('onlineStats', {}).get('onlineNow', 0),
|
||||
"total_users": system_stats.get('users', {}).get('totalUsers', 0),
|
||||
"last_check": end_time,
|
||||
"api_url": settings.REMNAWAVE_API_URL,
|
||||
"attempts_used": attempt,
|
||||
}
|
||||
|
||||
if result["api_available"]:
|
||||
if attempt > 1:
|
||||
logger.info("Панель Remnawave ответила с %s попытки", attempt)
|
||||
return result
|
||||
|
||||
last_result = result
|
||||
|
||||
if attempt < attempts:
|
||||
logger.warning(
|
||||
"Панель Remnawave недоступна (попытка %s/%s): %s",
|
||||
attempt,
|
||||
attempts,
|
||||
result.get("api_error") or "неизвестная ошибка",
|
||||
)
|
||||
await asyncio.sleep(1)
|
||||
|
||||
except Exception as error:
|
||||
last_error = error
|
||||
if attempt < attempts:
|
||||
logger.warning(
|
||||
"Ошибка проверки здоровья панели (попытка %s/%s): %s",
|
||||
attempt,
|
||||
attempts,
|
||||
error,
|
||||
)
|
||||
await asyncio.sleep(1)
|
||||
continue
|
||||
|
||||
logger.error(f"Ошибка проверки здоровья панели: {error}")
|
||||
|
||||
if last_result is not None:
|
||||
return last_result
|
||||
|
||||
error_message = str(last_error) if last_error else "Неизвестная ошибка"
|
||||
return {
|
||||
"status": "offline",
|
||||
"api_available": False,
|
||||
"api_error": error_message,
|
||||
"response_time": 0,
|
||||
"nodes_online": 0,
|
||||
"total_nodes": 0,
|
||||
"nodes_health": "unknown",
|
||||
"last_check": datetime.utcnow(),
|
||||
"api_url": settings.REMNAWAVE_API_URL,
|
||||
"attempts_used": attempts,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,602 @@
|
||||
"""Automatic subscription purchase from a saved cart after balance top-up."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional
|
||||
|
||||
from aiogram import Bot
|
||||
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.crud.subscription import extend_subscription
|
||||
from app.database.crud.transaction import create_transaction
|
||||
from app.database.crud.user import subtract_user_balance
|
||||
from app.database.models import Subscription, TransactionType, User
|
||||
from app.localization.texts import get_texts
|
||||
from app.services.admin_notification_service import AdminNotificationService
|
||||
from app.services.subscription_checkout_service import clear_subscription_checkout_draft
|
||||
from app.services.subscription_purchase_service import (
|
||||
PurchaseOptionsContext,
|
||||
PurchasePricingResult,
|
||||
PurchaseSelection,
|
||||
PurchaseValidationError,
|
||||
PurchaseBalanceError,
|
||||
MiniAppSubscriptionPurchaseService,
|
||||
)
|
||||
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__)
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class AutoPurchaseContext:
|
||||
"""Aggregated data prepared for automatic checkout processing."""
|
||||
|
||||
context: PurchaseOptionsContext
|
||||
pricing: PurchasePricingResult
|
||||
selection: PurchaseSelection
|
||||
service: MiniAppSubscriptionPurchaseService
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class AutoExtendContext:
|
||||
"""Data required to automatically extend an existing subscription."""
|
||||
|
||||
subscription: Subscription
|
||||
period_days: int
|
||||
price_kopeks: int
|
||||
description: str
|
||||
device_limit: Optional[int] = None
|
||||
traffic_limit_gb: Optional[int] = None
|
||||
squad_uuid: Optional[str] = None
|
||||
consume_promo_offer: bool = False
|
||||
|
||||
|
||||
async def _prepare_auto_purchase(
|
||||
db: AsyncSession,
|
||||
user: User,
|
||||
cart_data: dict,
|
||||
) -> Optional[AutoPurchaseContext]:
|
||||
"""Builds purchase context and pricing for a saved cart."""
|
||||
|
||||
period_days = int(cart_data.get("period_days") or 0)
|
||||
if period_days <= 0:
|
||||
logger.info(
|
||||
"🔁 Автопокупка: у пользователя %s нет корректного периода в сохранённой корзине",
|
||||
user.telegram_id,
|
||||
)
|
||||
return None
|
||||
|
||||
miniapp_service = MiniAppSubscriptionPurchaseService()
|
||||
context = await miniapp_service.build_options(db, user)
|
||||
|
||||
period_config = context.period_map.get(f"days:{period_days}")
|
||||
if not period_config:
|
||||
logger.warning(
|
||||
"🔁 Автопокупка: период %s дней недоступен для пользователя %s",
|
||||
period_days,
|
||||
user.telegram_id,
|
||||
)
|
||||
return None
|
||||
|
||||
traffic_value = cart_data.get("traffic_gb")
|
||||
if traffic_value is None:
|
||||
traffic_value = (
|
||||
period_config.traffic.current_value
|
||||
if period_config.traffic.current_value is not None
|
||||
else period_config.traffic.default_value or 0
|
||||
)
|
||||
else:
|
||||
traffic_value = int(traffic_value)
|
||||
|
||||
devices = int(cart_data.get("devices") or period_config.devices.current or 1)
|
||||
servers = list(cart_data.get("countries") or [])
|
||||
if not servers:
|
||||
servers = list(period_config.servers.default_selection)
|
||||
|
||||
selection = PurchaseSelection(
|
||||
period=period_config,
|
||||
traffic_value=traffic_value,
|
||||
servers=servers,
|
||||
devices=devices,
|
||||
)
|
||||
|
||||
pricing = await miniapp_service.calculate_pricing(db, context, selection)
|
||||
return AutoPurchaseContext(
|
||||
context=context,
|
||||
pricing=pricing,
|
||||
selection=selection,
|
||||
service=miniapp_service,
|
||||
)
|
||||
|
||||
|
||||
def _safe_int(value: Optional[object], default: int = 0) -> int:
|
||||
try:
|
||||
return int(value) # type: ignore[arg-type]
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
|
||||
|
||||
async def _prepare_auto_extend_context(
|
||||
user: User,
|
||||
cart_data: dict,
|
||||
) -> Optional[AutoExtendContext]:
|
||||
subscription = getattr(user, "subscription", None)
|
||||
if subscription is None:
|
||||
logger.info(
|
||||
"🔁 Автопокупка: у пользователя %s нет активной подписки для продления",
|
||||
user.telegram_id,
|
||||
)
|
||||
return None
|
||||
|
||||
saved_subscription_id = cart_data.get("subscription_id")
|
||||
if saved_subscription_id is not None:
|
||||
saved_subscription_id = _safe_int(saved_subscription_id, subscription.id)
|
||||
if saved_subscription_id != subscription.id:
|
||||
logger.warning(
|
||||
"🔁 Автопокупка: сохранённая подписка %s не совпадает с текущей %s у пользователя %s",
|
||||
saved_subscription_id,
|
||||
subscription.id,
|
||||
user.telegram_id,
|
||||
)
|
||||
return None
|
||||
|
||||
period_days = _safe_int(cart_data.get("period_days"))
|
||||
price_kopeks = _safe_int(
|
||||
cart_data.get("total_price")
|
||||
or cart_data.get("price")
|
||||
or cart_data.get("final_price"),
|
||||
)
|
||||
|
||||
if period_days <= 0:
|
||||
logger.warning(
|
||||
"🔁 Автопокупка: некорректное количество дней продления (%s) у пользователя %s",
|
||||
period_days,
|
||||
user.telegram_id,
|
||||
)
|
||||
return None
|
||||
|
||||
if price_kopeks <= 0:
|
||||
logger.warning(
|
||||
"🔁 Автопокупка: некорректная цена продления (%s) у пользователя %s",
|
||||
price_kopeks,
|
||||
user.telegram_id,
|
||||
)
|
||||
return None
|
||||
|
||||
description = cart_data.get("description") or f"Продление подписки на {period_days} дней"
|
||||
|
||||
device_limit = cart_data.get("device_limit")
|
||||
if device_limit is not None:
|
||||
device_limit = _safe_int(device_limit, subscription.device_limit)
|
||||
|
||||
traffic_limit_gb = cart_data.get("traffic_limit_gb")
|
||||
if traffic_limit_gb is not None:
|
||||
traffic_limit_gb = _safe_int(traffic_limit_gb, subscription.traffic_limit_gb or 0)
|
||||
|
||||
squad_uuid = cart_data.get("squad_uuid")
|
||||
consume_promo_offer = bool(cart_data.get("consume_promo_offer"))
|
||||
|
||||
return AutoExtendContext(
|
||||
subscription=subscription,
|
||||
period_days=period_days,
|
||||
price_kopeks=price_kopeks,
|
||||
description=description,
|
||||
device_limit=device_limit,
|
||||
traffic_limit_gb=traffic_limit_gb,
|
||||
squad_uuid=squad_uuid,
|
||||
consume_promo_offer=consume_promo_offer,
|
||||
)
|
||||
|
||||
|
||||
def _apply_extension_updates(context: AutoExtendContext) -> None:
|
||||
subscription = context.subscription
|
||||
|
||||
if subscription.is_trial:
|
||||
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:
|
||||
subscription.device_limit = max(subscription.device_limit, context.device_limit)
|
||||
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 (
|
||||
context.device_limit is not None
|
||||
and context.device_limit > subscription.device_limit
|
||||
):
|
||||
subscription.device_limit = context.device_limit
|
||||
if context.squad_uuid and context.squad_uuid not in (subscription.connected_squads or []):
|
||||
subscription.connected_squads = (subscription.connected_squads or []) + [context.squad_uuid]
|
||||
|
||||
|
||||
async def _auto_extend_subscription(
|
||||
db: AsyncSession,
|
||||
user: User,
|
||||
cart_data: dict,
|
||||
*,
|
||||
bot: Optional[Bot] = None,
|
||||
) -> bool:
|
||||
try:
|
||||
prepared = await _prepare_auto_extend_context(user, cart_data)
|
||||
except Exception as error: # pragma: no cover - defensive logging
|
||||
logger.error(
|
||||
"❌ Автопокупка: ошибка подготовки данных продления для пользователя %s: %s",
|
||||
user.telegram_id,
|
||||
error,
|
||||
exc_info=True,
|
||||
)
|
||||
return False
|
||||
|
||||
if prepared is None:
|
||||
return False
|
||||
|
||||
if user.balance_kopeks < prepared.price_kopeks:
|
||||
logger.info(
|
||||
"🔁 Автопокупка: у пользователя %s недостаточно средств для продления (%s < %s)",
|
||||
user.telegram_id,
|
||||
user.balance_kopeks,
|
||||
prepared.price_kopeks,
|
||||
)
|
||||
return False
|
||||
|
||||
try:
|
||||
deducted = await subtract_user_balance(
|
||||
db,
|
||||
user,
|
||||
prepared.price_kopeks,
|
||||
prepared.description,
|
||||
consume_promo_offer=prepared.consume_promo_offer,
|
||||
)
|
||||
except Exception as error: # pragma: no cover - defensive logging
|
||||
logger.error(
|
||||
"❌ Автопокупка: ошибка списания средств при продлении пользователя %s: %s",
|
||||
user.telegram_id,
|
||||
error,
|
||||
exc_info=True,
|
||||
)
|
||||
return False
|
||||
|
||||
if not deducted:
|
||||
logger.warning(
|
||||
"❌ Автопокупка: списание средств для продления подписки пользователя %s не выполнено",
|
||||
user.telegram_id,
|
||||
)
|
||||
return False
|
||||
|
||||
subscription = prepared.subscription
|
||||
old_end_date = subscription.end_date
|
||||
|
||||
_apply_extension_updates(prepared)
|
||||
|
||||
try:
|
||||
updated_subscription = await extend_subscription(
|
||||
db,
|
||||
subscription,
|
||||
prepared.period_days,
|
||||
)
|
||||
except Exception as error: # pragma: no cover - defensive logging
|
||||
logger.error(
|
||||
"❌ Автопокупка: не удалось продлить подписку пользователя %s: %s",
|
||||
user.telegram_id,
|
||||
error,
|
||||
exc_info=True,
|
||||
)
|
||||
return False
|
||||
|
||||
transaction = None
|
||||
try:
|
||||
transaction = await create_transaction(
|
||||
db=db,
|
||||
user_id=user.id,
|
||||
type=TransactionType.SUBSCRIPTION_PAYMENT,
|
||||
amount_kopeks=prepared.price_kopeks,
|
||||
description=prepared.description,
|
||||
)
|
||||
except Exception as error: # pragma: no cover - defensive logging
|
||||
logger.error(
|
||||
"⚠️ Автопокупка: не удалось зафиксировать транзакцию продления для пользователя %s: %s",
|
||||
user.telegram_id,
|
||||
error,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
subscription_service = SubscriptionService()
|
||||
try:
|
||||
await subscription_service.update_remnawave_user(
|
||||
db,
|
||||
updated_subscription,
|
||||
reset_traffic=settings.RESET_TRAFFIC_ON_PAYMENT,
|
||||
reset_reason="продление подписки",
|
||||
)
|
||||
except Exception as error: # pragma: no cover - defensive logging
|
||||
logger.error(
|
||||
"⚠️ Автопокупка: не удалось обновить RemnaWave пользователя %s после продления: %s",
|
||||
user.telegram_id,
|
||||
error,
|
||||
)
|
||||
|
||||
await user_cart_service.delete_user_cart(user.id)
|
||||
await clear_subscription_checkout_draft(user.id)
|
||||
|
||||
texts = get_texts(getattr(user, "language", "ru"))
|
||||
period_label = format_period_description(
|
||||
prepared.period_days,
|
||||
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")
|
||||
|
||||
if bot:
|
||||
try:
|
||||
notification_service = AdminNotificationService(bot)
|
||||
await notification_service.send_subscription_extension_notification(
|
||||
db,
|
||||
user,
|
||||
updated_subscription,
|
||||
transaction,
|
||||
prepared.period_days,
|
||||
old_end_date,
|
||||
new_end_date=new_end_date,
|
||||
balance_after=user.balance_kopeks,
|
||||
)
|
||||
except Exception as error: # pragma: no cover - defensive logging
|
||||
logger.error(
|
||||
"⚠️ Автопокупка: не удалось уведомить администраторов о продлении пользователя %s: %s",
|
||||
user.telegram_id,
|
||||
error,
|
||||
)
|
||||
|
||||
try:
|
||||
auto_message = texts.t(
|
||||
"AUTO_PURCHASE_SUBSCRIPTION_EXTENDED",
|
||||
"✅ Subscription automatically extended for {period}.",
|
||||
).format(period=period_label)
|
||||
details_message = texts.t(
|
||||
"AUTO_PURCHASE_SUBSCRIPTION_EXTENDED_DETAILS",
|
||||
"New expiration date: {date}.",
|
||||
).format(date=end_date_label)
|
||||
hint_message = texts.t(
|
||||
"AUTO_PURCHASE_SUBSCRIPTION_HINT",
|
||||
"Open the ‘My subscription’ section to access your link.",
|
||||
)
|
||||
|
||||
full_message = "\n\n".join(
|
||||
part.strip()
|
||||
for part in [auto_message, details_message, hint_message]
|
||||
if part and part.strip()
|
||||
)
|
||||
|
||||
keyboard = InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t("MY_SUBSCRIPTION_BUTTON", "📱 My subscription"),
|
||||
callback_data="menu_subscription",
|
||||
)
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t("BACK_TO_MAIN_MENU_BUTTON", "🏠 Main menu"),
|
||||
callback_data="back_to_menu",
|
||||
)
|
||||
],
|
||||
]
|
||||
)
|
||||
|
||||
await bot.send_message(
|
||||
chat_id=user.telegram_id,
|
||||
text=full_message,
|
||||
reply_markup=keyboard,
|
||||
parse_mode="HTML",
|
||||
)
|
||||
except Exception as error: # pragma: no cover - defensive logging
|
||||
logger.error(
|
||||
"⚠️ Автопокупка: не удалось уведомить пользователя %s о продлении: %s",
|
||||
user.telegram_id,
|
||||
error,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"✅ Автопокупка: подписка продлена на %s дней для пользователя %s",
|
||||
prepared.period_days,
|
||||
user.telegram_id,
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def auto_purchase_saved_cart_after_topup(
|
||||
db: AsyncSession,
|
||||
user: User,
|
||||
*,
|
||||
bot: Optional[Bot] = None,
|
||||
) -> bool:
|
||||
"""Attempts to automatically purchase a subscription from a saved cart."""
|
||||
|
||||
if not settings.is_auto_purchase_after_topup_enabled():
|
||||
return False
|
||||
|
||||
if not user or not getattr(user, "id", None):
|
||||
return False
|
||||
|
||||
cart_data = await user_cart_service.get_user_cart(user.id)
|
||||
if not cart_data:
|
||||
return False
|
||||
|
||||
logger.info(
|
||||
"🔁 Автопокупка: обнаружена сохранённая корзина у пользователя %s", user.telegram_id
|
||||
)
|
||||
|
||||
cart_mode = cart_data.get("cart_mode") or cart_data.get("mode")
|
||||
if cart_mode == "extend":
|
||||
return await _auto_extend_subscription(db, user, cart_data, bot=bot)
|
||||
|
||||
try:
|
||||
prepared = await _prepare_auto_purchase(db, user, cart_data)
|
||||
except PurchaseValidationError as error:
|
||||
logger.error(
|
||||
"❌ Автопокупка: ошибка валидации корзины пользователя %s: %s",
|
||||
user.telegram_id,
|
||||
error,
|
||||
)
|
||||
return False
|
||||
except Exception as error: # pragma: no cover - defensive logging
|
||||
logger.error(
|
||||
"❌ Автопокупка: непредвиденная ошибка при подготовке корзины %s: %s",
|
||||
user.telegram_id,
|
||||
error,
|
||||
exc_info=True,
|
||||
)
|
||||
return False
|
||||
|
||||
if prepared is None:
|
||||
return False
|
||||
|
||||
pricing = prepared.pricing
|
||||
selection = prepared.selection
|
||||
|
||||
if pricing.final_total <= 0:
|
||||
logger.warning(
|
||||
"❌ Автопокупка: итоговая сумма для пользователя %s некорректна (%s)",
|
||||
user.telegram_id,
|
||||
pricing.final_total,
|
||||
)
|
||||
return False
|
||||
|
||||
if user.balance_kopeks < pricing.final_total:
|
||||
logger.info(
|
||||
"🔁 Автопокупка: у пользователя %s недостаточно средств (%s < %s)",
|
||||
user.telegram_id,
|
||||
user.balance_kopeks,
|
||||
pricing.final_total,
|
||||
)
|
||||
return False
|
||||
|
||||
purchase_service = prepared.service
|
||||
|
||||
try:
|
||||
purchase_result = await purchase_service.submit_purchase(
|
||||
db,
|
||||
prepared.context,
|
||||
pricing,
|
||||
)
|
||||
except PurchaseBalanceError:
|
||||
logger.info(
|
||||
"🔁 Автопокупка: баланс пользователя %s изменился и стал недостаточным",
|
||||
user.telegram_id,
|
||||
)
|
||||
return False
|
||||
except PurchaseValidationError as error:
|
||||
logger.error(
|
||||
"❌ Автопокупка: не удалось подтвердить корзину пользователя %s: %s",
|
||||
user.telegram_id,
|
||||
error,
|
||||
)
|
||||
return False
|
||||
except Exception as error: # pragma: no cover - defensive logging
|
||||
logger.error(
|
||||
"❌ Автопокупка: ошибка оформления подписки для пользователя %s: %s",
|
||||
user.telegram_id,
|
||||
error,
|
||||
exc_info=True,
|
||||
)
|
||||
return False
|
||||
|
||||
await user_cart_service.delete_user_cart(user.id)
|
||||
await clear_subscription_checkout_draft(user.id)
|
||||
|
||||
subscription = purchase_result.get("subscription")
|
||||
transaction = purchase_result.get("transaction")
|
||||
was_trial_conversion = purchase_result.get("was_trial_conversion", False)
|
||||
texts = get_texts(getattr(user, "language", "ru"))
|
||||
|
||||
if bot:
|
||||
try:
|
||||
notification_service = AdminNotificationService(bot)
|
||||
await notification_service.send_subscription_purchase_notification(
|
||||
db,
|
||||
user,
|
||||
subscription,
|
||||
transaction,
|
||||
selection.period.days,
|
||||
was_trial_conversion,
|
||||
)
|
||||
except Exception as error: # pragma: no cover - defensive logging
|
||||
logger.error(
|
||||
"⚠️ Автопокупка: не удалось отправить уведомление админам (%s): %s",
|
||||
user.telegram_id,
|
||||
error,
|
||||
)
|
||||
|
||||
try:
|
||||
period_label = format_period_description(
|
||||
selection.period.days,
|
||||
getattr(user, "language", "ru"),
|
||||
)
|
||||
auto_message = texts.t(
|
||||
"AUTO_PURCHASE_SUBSCRIPTION_SUCCESS",
|
||||
"✅ Subscription purchased automatically after balance top-up ({period}).",
|
||||
).format(period=period_label)
|
||||
|
||||
hint_message = texts.t(
|
||||
"AUTO_PURCHASE_SUBSCRIPTION_HINT",
|
||||
"Open the ‘My subscription’ section to access your link.",
|
||||
)
|
||||
|
||||
purchase_message = purchase_result.get("message", "")
|
||||
full_message = "\n\n".join(
|
||||
part.strip()
|
||||
for part in [auto_message, purchase_message, hint_message]
|
||||
if part and part.strip()
|
||||
)
|
||||
|
||||
keyboard = InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t("MY_SUBSCRIPTION_BUTTON", "📱 My subscription"),
|
||||
callback_data="menu_subscription",
|
||||
)
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t("BACK_TO_MAIN_MENU_BUTTON", "🏠 Main menu"),
|
||||
callback_data="back_to_menu",
|
||||
)
|
||||
],
|
||||
]
|
||||
)
|
||||
|
||||
await bot.send_message(
|
||||
chat_id=user.telegram_id,
|
||||
text=full_message,
|
||||
reply_markup=keyboard,
|
||||
parse_mode="HTML",
|
||||
)
|
||||
except Exception as error: # pragma: no cover - defensive logging
|
||||
logger.error(
|
||||
"⚠️ Автопокупка: не удалось уведомить пользователя %s: %s",
|
||||
user.telegram_id,
|
||||
error,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"✅ Автопокупка: подписка на %s дней оформлена для пользователя %s",
|
||||
selection.period.days,
|
||||
user.telegram_id,
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
__all__ = ["auto_purchase_saved_cart_after_topup"]
|
||||
@@ -3,6 +3,7 @@ from dataclasses import dataclass, field
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Any, Dict, List, Optional, Sequence, Tuple
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import PERIOD_PRICES, settings
|
||||
@@ -1060,7 +1061,24 @@ class MiniAppSubscriptionPurchaseService:
|
||||
|
||||
await db.refresh(user)
|
||||
|
||||
subscription = getattr(user, "subscription", None)
|
||||
subscription = context.subscription
|
||||
if subscription is not None and getattr(subscription, "id", None):
|
||||
try:
|
||||
await db.refresh(subscription)
|
||||
except Exception as refresh_error: # pragma: no cover - defensive logging
|
||||
logger.warning(
|
||||
"Failed to refresh existing subscription %s: %s",
|
||||
getattr(subscription, "id", None),
|
||||
refresh_error,
|
||||
)
|
||||
else:
|
||||
result = await db.execute(
|
||||
select(Subscription).where(Subscription.user_id == user.id)
|
||||
)
|
||||
subscription = result.scalar_one_or_none()
|
||||
if subscription is not None:
|
||||
context.subscription = subscription
|
||||
|
||||
was_trial_conversion = False
|
||||
now = datetime.utcnow()
|
||||
|
||||
|
||||
@@ -17,6 +17,9 @@ 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__)
|
||||
|
||||
@@ -172,6 +175,7 @@ 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}")
|
||||
@@ -183,20 +187,24 @@ class SubscriptionService:
|
||||
except Exception as hwid_error:
|
||||
logger.warning(f"⚠️ Не удалось сбросить HWID: {hwid_error}")
|
||||
|
||||
updated_user = await api.update_user(
|
||||
update_kwargs = dict(
|
||||
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(
|
||||
@@ -208,23 +216,31 @@ class SubscriptionService:
|
||||
|
||||
else:
|
||||
logger.info(f"🆕 Создаем нового пользователя в панели для {user.telegram_id}")
|
||||
username = f"user_{user.telegram_id}"
|
||||
updated_user = await api.create_user(
|
||||
username = settings.format_remnawave_username(
|
||||
full_name=user.full_name,
|
||||
username=user.username,
|
||||
telegram_id=user.telegram_id,
|
||||
)
|
||||
create_kwargs = dict(
|
||||
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,
|
||||
@@ -282,20 +298,26 @@ class SubscriptionService:
|
||||
logger.info(f"🔔 Статус подписки {subscription.id} автоматически изменен на 'expired'")
|
||||
|
||||
async with self.get_api_client() as api:
|
||||
updated_user = await api.update_user(
|
||||
hwid_limit = resolve_hwid_device_limit_for_payload(subscription)
|
||||
|
||||
update_kwargs = dict(
|
||||
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(
|
||||
@@ -565,7 +587,18 @@ class SubscriptionService:
|
||||
servers_discount = servers_price * servers_discount_percent // 100
|
||||
discounted_servers_price = servers_price - servers_discount
|
||||
|
||||
devices_price = max(0, subscription.device_limit - settings.DEFAULT_DEVICE_LIMIT) * settings.PRICE_PER_DEVICE
|
||||
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_discount_percent = _resolve_discount_percent(
|
||||
user,
|
||||
promo_group,
|
||||
@@ -617,7 +650,7 @@ class SubscriptionService:
|
||||
)
|
||||
logger.info(message)
|
||||
if devices_price > 0:
|
||||
message = f" 📱 Устройства ({subscription.device_limit}): {discounted_devices_price/100}₽"
|
||||
message = f" 📱 Устройства ({device_limit}): {discounted_devices_price/100}₽"
|
||||
if devices_discount > 0:
|
||||
message += (
|
||||
f" (скидка {devices_discount_percent}%: -{devices_discount/100}₽ от {devices_price/100}₽)"
|
||||
@@ -894,7 +927,18 @@ class SubscriptionService:
|
||||
discounted_servers_per_month = servers_price_per_month - servers_discount_per_month
|
||||
total_servers_price = discounted_servers_per_month * months_in_period
|
||||
|
||||
additional_devices = max(0, subscription.device_limit - settings.DEFAULT_DEVICE_LIMIT)
|
||||
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)
|
||||
devices_price_per_month = additional_devices * settings.PRICE_PER_DEVICE
|
||||
devices_discount_percent = _resolve_discount_percent(
|
||||
user,
|
||||
|
||||
@@ -9,7 +9,13 @@ 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
|
||||
from app.config import (
|
||||
Settings,
|
||||
settings,
|
||||
refresh_period_prices,
|
||||
refresh_traffic_prices,
|
||||
ENV_OVERRIDE_KEYS,
|
||||
)
|
||||
from app.database.crud.system_setting import (
|
||||
delete_system_setting,
|
||||
upsert_system_setting,
|
||||
@@ -71,7 +77,9 @@ class BotConfigurationService:
|
||||
"SUPPORT": "💬 Поддержка и тикеты",
|
||||
"LOCALIZATION": "🌍 Языки интерфейса",
|
||||
"CHANNEL": "📣 Обязательная подписка",
|
||||
"TIMEZONE": "🗂 Timezone",
|
||||
"PAYMENT": "💳 Общие платежные настройки",
|
||||
"PAYMENT_VERIFICATION": "🕵️ Проверка платежей",
|
||||
"TELEGRAM": "⭐ Telegram Stars",
|
||||
"CRYPTOBOT": "🪙 CryptoBot",
|
||||
"HELEKET": "🪙 Heleket",
|
||||
@@ -123,7 +131,9 @@ class BotConfigurationService:
|
||||
"SUPPORT": "Контакты поддержки, SLA и режимы обработки обращений.",
|
||||
"LOCALIZATION": "Доступные языки, локализация интерфейса и выбор языка.",
|
||||
"CHANNEL": "Настройки обязательной подписки на канал или группу.",
|
||||
"TIMEZONE": "Часовой пояс панели и отображение времени.",
|
||||
"PAYMENT": "Общие тексты платежей, описания чеков и шаблоны.",
|
||||
"PAYMENT_VERIFICATION": "Автоматическая проверка пополнений и интервал выполнения.",
|
||||
"YOOKASSA": "Интеграция с YooKassa: идентификаторы магазина и вебхуки.",
|
||||
"CRYPTOBOT": "CryptoBot и криптоплатежи через Telegram.",
|
||||
"HELEKET": "Heleket: криптоплатежи, ключи мерчанта и вебхуки.",
|
||||
@@ -193,6 +203,8 @@ 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",
|
||||
@@ -209,7 +221,6 @@ 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",
|
||||
@@ -234,6 +245,7 @@ class BotConfigurationService:
|
||||
"PAYMENT_SUBSCRIPTION_DESCRIPTION": "PAYMENT",
|
||||
"PAYMENT_BALANCE_TEMPLATE": "PAYMENT",
|
||||
"PAYMENT_SUBSCRIPTION_TEMPLATE": "PAYMENT",
|
||||
"AUTO_PURCHASE_AFTER_TOPUP_ENABLED": "PAYMENT",
|
||||
"SIMPLE_SUBSCRIPTION_ENABLED": "SIMPLE_SUBSCRIPTION",
|
||||
"SIMPLE_SUBSCRIPTION_PERIOD_DAYS": "SIMPLE_SUBSCRIPTION",
|
||||
"SIMPLE_SUBSCRIPTION_DEVICE_LIMIT": "SIMPLE_SUBSCRIPTION",
|
||||
@@ -258,6 +270,7 @@ 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",
|
||||
@@ -265,6 +278,7 @@ 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",
|
||||
}
|
||||
@@ -292,6 +306,7 @@ class BotConfigurationService:
|
||||
"MULENPAY_": "MULENPAY",
|
||||
"PAL24_": "PAL24",
|
||||
"PAYMENT_": "PAYMENT",
|
||||
"PAYMENT_VERIFICATION_": "PAYMENT_VERIFICATION",
|
||||
"WATA_": "WATA",
|
||||
"EXTERNAL_ADMIN_": "EXTERNAL_ADMIN",
|
||||
"SIMPLE_SUBSCRIPTION_": "SIMPLE_SUBSCRIPTION",
|
||||
@@ -446,6 +461,21 @@ 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": "Булево значение.",
|
||||
@@ -453,6 +483,34 @@ class BotConfigurationService:
|
||||
"warning": "Пустой токен или неверный вебхук приведут к отказам платежей.",
|
||||
"dependencies": "CRYPTOBOT_API_TOKEN, CRYPTOBOT_WEBHOOK_SECRET",
|
||||
},
|
||||
"PAYMENT_VERIFICATION_AUTO_CHECK_ENABLED": {
|
||||
"description": (
|
||||
"Запускает фоновую проверку ожидающих пополнений и повторно обращается "
|
||||
"к платёжным провайдерам без участия администратора."
|
||||
),
|
||||
"format": "Булево значение.",
|
||||
"example": "Включено, чтобы автоматически перепроверять зависшие платежи.",
|
||||
"warning": "Требует активных интеграций YooKassa, {mulenpay_name}, PayPalych, WATA или CryptoBot.",
|
||||
},
|
||||
"PAYMENT_VERIFICATION_AUTO_CHECK_INTERVAL_MINUTES": {
|
||||
"description": (
|
||||
"Интервал между автоматическими проверками ожидающих пополнений в минутах."
|
||||
),
|
||||
"format": "Целое число не меньше 1.",
|
||||
"example": "10",
|
||||
"warning": "Слишком малый интервал может привести к частым обращениям к платёжным API.",
|
||||
"dependencies": "PAYMENT_VERIFICATION_AUTO_CHECK_ENABLED",
|
||||
},
|
||||
"AUTO_PURCHASE_AFTER_TOPUP_ENABLED": {
|
||||
"description": (
|
||||
"При достаточном балансе автоматически оформляет сохранённую подписку сразу после пополнения."
|
||||
),
|
||||
"format": "Булево значение.",
|
||||
"example": "true",
|
||||
"warning": (
|
||||
"Используйте с осторожностью: средства будут списаны мгновенно, если корзина найдена."
|
||||
),
|
||||
},
|
||||
"SUPPORT_TICKET_SLA_MINUTES": {
|
||||
"description": "Лимит времени для ответа модераторов на тикет в минутах.",
|
||||
"format": "Целое число от 1 до 1440.",
|
||||
@@ -467,6 +525,28 @@ 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": (
|
||||
"Список слов и фрагментов, при наличии которых в отображаемом имени "
|
||||
@@ -504,6 +584,31 @@ 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 бота и его токена и доступно только для чтения.",
|
||||
@@ -534,6 +639,10 @@ 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):
|
||||
@@ -647,6 +756,7 @@ 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]] = {}
|
||||
@@ -770,6 +880,8 @@ 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
|
||||
@@ -1065,6 +1177,12 @@ 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:
|
||||
@@ -1184,8 +1302,15 @@ class BotConfigurationService:
|
||||
|
||||
raw_value = cls.serialize_value(key, value)
|
||||
await upsert_system_setting(db, key, raw_value)
|
||||
cls._overrides_raw[key] = raw_value
|
||||
cls._apply_to_settings(key, 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)
|
||||
|
||||
if key in {"WEB_API_DEFAULT_TOKEN", "WEB_API_DEFAULT_TOKEN_NAME"}:
|
||||
await cls._sync_default_web_api_token()
|
||||
@@ -1203,14 +1328,26 @@ class BotConfigurationService:
|
||||
|
||||
await delete_system_setting(db, key)
|
||||
cls._overrides_raw.pop(key, None)
|
||||
original = cls.get_original_value(key)
|
||||
cls._apply_to_settings(key, original)
|
||||
if cls._is_env_override(key):
|
||||
logger.info(
|
||||
"Настройка %s сброшена в БД, используется значение из окружения",
|
||||
key,
|
||||
)
|
||||
else:
|
||||
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 {
|
||||
|
||||
@@ -14,6 +14,9 @@ from app.database.crud.transaction import (
|
||||
from app.database.crud.user import get_user_by_telegram_id
|
||||
from app.external.tribute import TributeService as TributeAPI
|
||||
from app.services.payment_service import PaymentService
|
||||
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__)
|
||||
@@ -267,17 +270,36 @@ class TributeService:
|
||||
# Проверяем наличие сохраненной корзины для возврата к оформлению подписки
|
||||
from app.services.user_cart_service import user_cart_service
|
||||
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(
|
||||
session,
|
||||
user,
|
||||
bot=self.bot,
|
||||
)
|
||||
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 self.bot:
|
||||
# Если у пользователя есть сохраненная корзина,
|
||||
# Если у пользователя есть сохраненная корзина,
|
||||
# отправляем ему уведомление с кнопкой вернуться к оформлению
|
||||
from app.localization.texts import get_texts
|
||||
from aiogram import types
|
||||
|
||||
|
||||
texts = get_texts(user.language)
|
||||
cart_message = texts.BALANCE_TOPUP_CART_REMINDER_DETAILED.format(
|
||||
total_amount=settings.format_price(amount_kopeks)
|
||||
)
|
||||
|
||||
|
||||
# Создаем клавиатуру с кнопками
|
||||
keyboard = types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(
|
||||
@@ -293,13 +315,16 @@ class TributeService:
|
||||
callback_data="back_to_menu"
|
||||
)]
|
||||
])
|
||||
|
||||
|
||||
await self.bot.send_message(
|
||||
chat_id=user_id,
|
||||
text=f"✅ Баланс пополнен на {settings.format_price(amount_kopeks)}!\n\n{cart_message}",
|
||||
reply_markup=keyboard
|
||||
)
|
||||
logger.info(f"Отправлено уведомление с кнопкой возврата к оформлению подписки пользователю {user_id}")
|
||||
logger.info(
|
||||
"Отправлено уведомление с кнопкой возврата к оформлению подписки пользователю %s",
|
||||
user_id,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка отправки уведомления об успешном платеже: {e}")
|
||||
|
||||
@@ -9,7 +9,7 @@ from app.database.crud.user import (
|
||||
get_user_by_id, get_user_by_telegram_id, get_users_list,
|
||||
get_users_count, get_users_statistics, get_inactive_users,
|
||||
add_user_balance, subtract_user_balance, update_user, delete_user,
|
||||
get_users_spending_stats
|
||||
get_users_spending_stats, get_referrals
|
||||
)
|
||||
from app.database.crud.promo_group import get_promo_group_by_id
|
||||
from app.database.crud.transaction import get_user_transactions_count
|
||||
@@ -21,7 +21,7 @@ from app.database.models import (
|
||||
User, UserStatus, Subscription, Transaction, PromoCode, PromoCodeUse,
|
||||
ReferralEarning, SubscriptionServer, YooKassaPayment, BroadcastHistory,
|
||||
CryptoBotPayment, SubscriptionConversion, UserMessage, WelcomeText,
|
||||
SentNotification, PromoGroup, MulenPayPayment, Pal24Payment,
|
||||
SentNotification, PromoGroup, MulenPayPayment, Pal24Payment, HeleketPayment,
|
||||
AdvertisingCampaign, AdvertisingCampaignRegistration, PaymentMethod,
|
||||
TransactionType
|
||||
)
|
||||
@@ -411,6 +411,72 @@ class UserService:
|
||||
logger.error(f"Ошибка обновления промогруппы пользователя {user_id}: {e}")
|
||||
return False, None, None, None
|
||||
|
||||
async def update_user_referrals(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
referral_user_ids: List[int],
|
||||
admin_id: int,
|
||||
) -> Tuple[bool, Dict[str, int]]:
|
||||
try:
|
||||
user = await get_user_by_id(db, user_id)
|
||||
if not user:
|
||||
return False, {"error": "user_not_found"}
|
||||
|
||||
unique_ids: List[int] = []
|
||||
for referral_id in referral_user_ids:
|
||||
if referral_id == user_id:
|
||||
continue
|
||||
if referral_id not in unique_ids:
|
||||
unique_ids.append(referral_id)
|
||||
|
||||
current_referrals = await get_referrals(db, user_id)
|
||||
current_ids = {ref.id for ref in current_referrals}
|
||||
|
||||
to_assign = unique_ids
|
||||
to_remove = [rid for rid in current_ids if rid not in unique_ids]
|
||||
to_add = [rid for rid in unique_ids if rid not in current_ids]
|
||||
|
||||
if to_assign:
|
||||
await db.execute(
|
||||
update(User)
|
||||
.where(User.id.in_(to_assign))
|
||||
.values(referred_by_id=user_id)
|
||||
)
|
||||
|
||||
if to_remove:
|
||||
await db.execute(
|
||||
update(User)
|
||||
.where(User.id.in_(to_remove))
|
||||
.values(referred_by_id=None)
|
||||
)
|
||||
|
||||
await db.commit()
|
||||
|
||||
logger.info(
|
||||
"Админ %s обновил рефералов пользователя %s: добавлено %s, удалено %s, всего %s",
|
||||
admin_id,
|
||||
user_id,
|
||||
len(to_add),
|
||||
len(to_remove),
|
||||
len(unique_ids),
|
||||
)
|
||||
|
||||
return True, {
|
||||
"added": len(to_add),
|
||||
"removed": len(to_remove),
|
||||
"total": len(unique_ids),
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
await db.rollback()
|
||||
logger.error(
|
||||
"Ошибка обновления рефералов пользователя %s: %s",
|
||||
user_id,
|
||||
e,
|
||||
)
|
||||
return False, {"error": "update_failed"}
|
||||
|
||||
async def block_user(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
@@ -713,6 +779,29 @@ class UserService:
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Ошибка удаления Pal24 платежей: {e}")
|
||||
|
||||
try:
|
||||
heleket_result = await db.execute(
|
||||
select(HeleketPayment).where(HeleketPayment.user_id == user_id)
|
||||
)
|
||||
heleket_payments = heleket_result.scalars().all()
|
||||
|
||||
if heleket_payments:
|
||||
logger.info(
|
||||
f"🔄 Удаляем {len(heleket_payments)} Heleket платежей"
|
||||
)
|
||||
await db.execute(
|
||||
update(HeleketPayment)
|
||||
.where(HeleketPayment.user_id == user_id)
|
||||
.values(transaction_id=None)
|
||||
)
|
||||
await db.flush()
|
||||
await db.execute(
|
||||
delete(HeleketPayment).where(HeleketPayment.user_id == user_id)
|
||||
)
|
||||
await db.flush()
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Ошибка удаления Heleket платежей: {e}")
|
||||
|
||||
try:
|
||||
transactions_result = await db.execute(
|
||||
select(Transaction).where(Transaction.user_id == user_id)
|
||||
|
||||
@@ -90,6 +90,7 @@ class AdminStates(StatesGroup):
|
||||
editing_device_price = State()
|
||||
editing_user_devices = State()
|
||||
editing_user_traffic = State()
|
||||
editing_user_referrals = State()
|
||||
|
||||
editing_rules_page = State()
|
||||
editing_privacy_policy = State()
|
||||
|
||||
+235
-1
@@ -1,7 +1,14 @@
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Tuple
|
||||
from typing import Any, Dict, List, Optional, Sequence, Tuple, TYPE_CHECKING
|
||||
import logging
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
|
||||
if TYPE_CHECKING: # pragma: no cover
|
||||
from app.database.models import User, PromoGroup
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -68,6 +75,218 @@ def apply_percentage_discount(amount: int, percent: int) -> Tuple[int, int]:
|
||||
return discounted_amount, discount_value
|
||||
|
||||
|
||||
def resolve_discount_percent(
|
||||
user: Optional["User"],
|
||||
promo_group: Optional["PromoGroup"],
|
||||
category: str,
|
||||
*,
|
||||
period_days: Optional[int] = None,
|
||||
) -> int:
|
||||
"""Определяет размер скидки для указанной категории."""
|
||||
|
||||
if user is not None:
|
||||
try:
|
||||
return user.get_promo_discount(category, period_days)
|
||||
except AttributeError: # pragma: no cover - defensive guard
|
||||
pass
|
||||
|
||||
if promo_group is not None:
|
||||
return promo_group.get_discount_percent(category, period_days)
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
async def compute_simple_subscription_price(
|
||||
db: AsyncSession,
|
||||
params: Dict[str, Any],
|
||||
*,
|
||||
user: Optional["User"] = None,
|
||||
resolved_squad_uuids: Optional[Sequence[str]] = None,
|
||||
) -> Tuple[int, Dict[str, Any]]:
|
||||
"""Вычисляет стоимость простой подписки с учетом всех доплат и скидок."""
|
||||
|
||||
period_days = int(params.get("period_days", 30) or 30)
|
||||
attr_name = f"PRICE_{period_days}_DAYS"
|
||||
base_price_original = getattr(settings, attr_name, settings.BASE_SUBSCRIPTION_PRICE)
|
||||
|
||||
traffic_limit_raw = params.get("traffic_limit_gb")
|
||||
try:
|
||||
traffic_limit = int(traffic_limit_raw) if traffic_limit_raw is not None else None
|
||||
except (TypeError, ValueError): # pragma: no cover - defensive conversion
|
||||
traffic_limit = None
|
||||
|
||||
if traffic_limit is None or traffic_limit <= 0:
|
||||
# Default simple subscriptions already include unlimited traffic.
|
||||
traffic_price_original = 0
|
||||
else:
|
||||
traffic_price_original = settings.get_traffic_price(traffic_limit)
|
||||
|
||||
device_limit_raw = params.get("device_limit", settings.DEFAULT_DEVICE_LIMIT)
|
||||
try:
|
||||
device_limit = int(device_limit_raw)
|
||||
except (TypeError, ValueError): # pragma: no cover - defensive conversion
|
||||
device_limit = settings.DEFAULT_DEVICE_LIMIT
|
||||
additional_devices = max(0, device_limit - settings.DEFAULT_DEVICE_LIMIT)
|
||||
devices_price_original = additional_devices * settings.PRICE_PER_DEVICE
|
||||
|
||||
promo_group: Optional["PromoGroup"] = params.get("promo_group")
|
||||
|
||||
if promo_group is None:
|
||||
promo_group_id = params.get("promo_group_id")
|
||||
if promo_group_id:
|
||||
from app.database.crud.promo_group import get_promo_group_by_id
|
||||
|
||||
promo_group = await get_promo_group_by_id(db, int(promo_group_id))
|
||||
|
||||
if promo_group is None and user is not None:
|
||||
promo_group = getattr(user, "promo_group", None)
|
||||
|
||||
period_discount_percent = resolve_discount_percent(
|
||||
user,
|
||||
promo_group,
|
||||
"period",
|
||||
period_days=period_days,
|
||||
)
|
||||
base_discount = base_price_original * period_discount_percent // 100
|
||||
discounted_base = base_price_original - base_discount
|
||||
|
||||
traffic_discount_percent = resolve_discount_percent(
|
||||
user,
|
||||
promo_group,
|
||||
"traffic",
|
||||
period_days=period_days,
|
||||
)
|
||||
traffic_discount = traffic_price_original * traffic_discount_percent // 100
|
||||
discounted_traffic = traffic_price_original - traffic_discount
|
||||
|
||||
devices_discount_percent = resolve_discount_percent(
|
||||
user,
|
||||
promo_group,
|
||||
"devices",
|
||||
period_days=period_days,
|
||||
)
|
||||
devices_discount = devices_price_original * devices_discount_percent // 100
|
||||
discounted_devices = devices_price_original - devices_discount
|
||||
|
||||
servers_discount_percent = resolve_discount_percent(
|
||||
user,
|
||||
promo_group,
|
||||
"servers",
|
||||
period_days=period_days,
|
||||
)
|
||||
|
||||
resolved_uuids: List[str] = []
|
||||
if resolved_squad_uuids:
|
||||
resolved_uuids.extend([uuid for uuid in resolved_squad_uuids if uuid])
|
||||
else:
|
||||
raw_squad = params.get("squad_uuid")
|
||||
if isinstance(raw_squad, (list, tuple, set)):
|
||||
resolved_uuids.extend([str(uuid) for uuid in raw_squad if uuid])
|
||||
elif raw_squad:
|
||||
resolved_uuids.append(str(raw_squad))
|
||||
|
||||
from app.database.crud.server_squad import get_server_squad_by_uuid
|
||||
|
||||
server_breakdown: List[Dict[str, Any]] = []
|
||||
servers_price_original = 0
|
||||
servers_discount_total = 0
|
||||
|
||||
for squad_uuid in resolved_uuids:
|
||||
server = await get_server_squad_by_uuid(db, squad_uuid)
|
||||
if not server:
|
||||
logger.warning(
|
||||
"SIMPLE_SUBSCRIPTION_PRICE_SERVER_NOT_FOUND | squad=%s",
|
||||
squad_uuid,
|
||||
)
|
||||
server_breakdown.append(
|
||||
{
|
||||
"uuid": squad_uuid,
|
||||
"name": None,
|
||||
"available": False,
|
||||
"original_price": 0,
|
||||
"discount": 0,
|
||||
"final_price": 0,
|
||||
}
|
||||
)
|
||||
continue
|
||||
|
||||
if not server.is_available or server.is_full:
|
||||
logger.warning(
|
||||
"SIMPLE_SUBSCRIPTION_PRICE_SERVER_UNAVAILABLE | squad=%s | available=%s | full=%s",
|
||||
squad_uuid,
|
||||
server.is_available,
|
||||
server.is_full,
|
||||
)
|
||||
server_breakdown.append(
|
||||
{
|
||||
"uuid": squad_uuid,
|
||||
"name": server.display_name,
|
||||
"available": False,
|
||||
"original_price": 0,
|
||||
"discount": 0,
|
||||
"final_price": 0,
|
||||
}
|
||||
)
|
||||
continue
|
||||
|
||||
original_price = server.price_kopeks
|
||||
discount_value = original_price * servers_discount_percent // 100
|
||||
final_price = original_price - discount_value
|
||||
|
||||
servers_price_original += original_price
|
||||
servers_discount_total += discount_value
|
||||
|
||||
server_breakdown.append(
|
||||
{
|
||||
"uuid": squad_uuid,
|
||||
"name": server.display_name,
|
||||
"available": True,
|
||||
"original_price": original_price,
|
||||
"discount": discount_value,
|
||||
"final_price": final_price,
|
||||
}
|
||||
)
|
||||
|
||||
total_before_discount = (
|
||||
base_price_original
|
||||
+ traffic_price_original
|
||||
+ devices_price_original
|
||||
+ servers_price_original
|
||||
)
|
||||
|
||||
total_discount = (
|
||||
base_discount
|
||||
+ traffic_discount
|
||||
+ devices_discount
|
||||
+ servers_discount_total
|
||||
)
|
||||
|
||||
total_price = max(0, total_before_discount - total_discount)
|
||||
|
||||
breakdown = {
|
||||
"base_price": base_price_original,
|
||||
"base_discount": base_discount,
|
||||
"traffic_price": traffic_price_original,
|
||||
"traffic_discount": traffic_discount,
|
||||
"devices_price": devices_price_original,
|
||||
"devices_discount": devices_discount,
|
||||
"servers_price": servers_price_original,
|
||||
"servers_discount": servers_discount_total,
|
||||
"servers_final": sum(item["final_price"] for item in server_breakdown),
|
||||
"server_details": server_breakdown,
|
||||
"total_before_discount": total_before_discount,
|
||||
"total_discount": total_discount,
|
||||
"resolved_squad_uuids": resolved_uuids,
|
||||
"applied_promo_group_id": getattr(promo_group, "id", None) if promo_group else None,
|
||||
"period_discount_percent": period_discount_percent,
|
||||
"traffic_discount_percent": traffic_discount_percent,
|
||||
"devices_discount_percent": devices_discount_percent,
|
||||
"servers_discount_percent": servers_discount_percent,
|
||||
}
|
||||
|
||||
return total_price, breakdown
|
||||
|
||||
|
||||
def format_period_description(days: int, language: str = "ru") -> str:
|
||||
months = calculate_months_from_days(days)
|
||||
|
||||
@@ -94,6 +313,21 @@ def format_period_description(days: int, language: str = "ru") -> str:
|
||||
return f"{days} days ({months} {month_word})"
|
||||
|
||||
|
||||
def format_period_option_label(label: str, price: int) -> str:
|
||||
"""Return a period option label with price when it's greater than zero.
|
||||
|
||||
When the price is zero or negative, the price suffix is omitted so that the
|
||||
option does not misleadingly show "0" as the cost of the period. This keeps
|
||||
the UI consistent when pricing is calculated dynamically based on other
|
||||
parameters such as servers or devices.
|
||||
"""
|
||||
|
||||
if price and price > 0:
|
||||
return f"{label} - {settings.format_price(price)}"
|
||||
|
||||
return label
|
||||
|
||||
|
||||
def validate_pricing_calculation(
|
||||
base_price: int,
|
||||
monthly_additions: int,
|
||||
|
||||
@@ -174,3 +174,58 @@ def convert_subscription_link_to_happ_scheme(subscription_link: Optional[str]) -
|
||||
return subscription_link
|
||||
|
||||
return urlunparse(parsed_link._replace(scheme="happ"))
|
||||
|
||||
|
||||
def resolve_hwid_device_limit(subscription: Optional[Subscription]) -> Optional[int]:
|
||||
"""Return a device limit value for RemnaWave payloads when selection is enabled."""
|
||||
|
||||
if subscription is None:
|
||||
return None
|
||||
|
||||
if not settings.is_devices_selection_enabled():
|
||||
forced_limit = settings.get_disabled_mode_device_limit()
|
||||
return forced_limit
|
||||
|
||||
limit = getattr(subscription, "device_limit", None)
|
||||
if limit is None or limit <= 0:
|
||||
return None
|
||||
|
||||
return limit
|
||||
|
||||
|
||||
def resolve_hwid_device_limit_for_payload(
|
||||
subscription: Optional[Subscription],
|
||||
) -> Optional[int]:
|
||||
"""Return the device limit that should be sent to RemnaWave APIs.
|
||||
|
||||
When device selection is disabled and no explicit override is configured,
|
||||
RemnaWave should continue receiving the subscription's stored limit so the
|
||||
external panel stays aligned with the bot configuration.
|
||||
"""
|
||||
|
||||
resolved_limit = resolve_hwid_device_limit(subscription)
|
||||
|
||||
if resolved_limit is not None:
|
||||
return resolved_limit
|
||||
|
||||
if subscription is None:
|
||||
return None
|
||||
|
||||
fallback_limit = getattr(subscription, "device_limit", None)
|
||||
if fallback_limit is None or fallback_limit <= 0:
|
||||
return None
|
||||
|
||||
return fallback_limit
|
||||
|
||||
|
||||
def resolve_simple_subscription_device_limit() -> int:
|
||||
"""Return the effective device limit for simple subscription flows."""
|
||||
|
||||
if settings.is_devices_selection_enabled():
|
||||
return int(getattr(settings, "SIMPLE_SUBSCRIPTION_DEVICE_LIMIT", 0) or 0)
|
||||
|
||||
forced_limit = settings.get_disabled_mode_device_limit()
|
||||
if forced_limit is not None:
|
||||
return forced_limit
|
||||
|
||||
return int(getattr(settings, "SIMPLE_SUBSCRIPTION_DEVICE_LIMIT", 0) or 0)
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
"""Timezone utilities for consistent local time handling."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timezone as dt_timezone
|
||||
from functools import lru_cache
|
||||
from typing import Optional
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from app.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def get_local_timezone() -> ZoneInfo:
|
||||
"""Return the configured local timezone.
|
||||
|
||||
Falls back to UTC if the configured timezone cannot be loaded. The
|
||||
fallback is logged once and cached for subsequent calls.
|
||||
"""
|
||||
|
||||
tz_name = settings.TIMEZONE
|
||||
|
||||
try:
|
||||
return ZoneInfo(tz_name)
|
||||
except Exception as exc: # pragma: no cover - defensive branch
|
||||
logger.warning(
|
||||
"⚠️ Не удалось загрузить временную зону '%s': %s. Используем UTC.",
|
||||
tz_name,
|
||||
exc,
|
||||
)
|
||||
return ZoneInfo("UTC")
|
||||
|
||||
|
||||
def to_local_datetime(dt: Optional[datetime]) -> Optional[datetime]:
|
||||
"""Convert a datetime value to the configured local timezone."""
|
||||
|
||||
if dt is None:
|
||||
return None
|
||||
|
||||
aware_dt = dt if dt.tzinfo is not None else dt.replace(tzinfo=dt_timezone.utc)
|
||||
return aware_dt.astimezone(get_local_timezone())
|
||||
|
||||
|
||||
def format_local_datetime(
|
||||
dt: Optional[datetime],
|
||||
fmt: str = "%Y-%m-%d %H:%M:%S %Z",
|
||||
na_placeholder: str = "N/A",
|
||||
) -> str:
|
||||
"""Format a datetime value in the configured local timezone."""
|
||||
|
||||
localized = to_local_datetime(dt)
|
||||
if localized is None:
|
||||
return na_placeholder
|
||||
return localized.strftime(fmt)
|
||||
|
||||
|
||||
class TimezoneAwareFormatter(logging.Formatter):
|
||||
"""Logging formatter that renders timestamps in the configured timezone."""
|
||||
|
||||
def __init__(self, *args, timezone_name: Optional[str] = None, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
if timezone_name:
|
||||
try:
|
||||
self._timezone = ZoneInfo(timezone_name)
|
||||
except Exception as exc: # pragma: no cover - defensive branch
|
||||
logger.warning(
|
||||
"⚠️ Не удалось загрузить временную зону '%s': %s. Используем UTC.",
|
||||
timezone_name,
|
||||
exc,
|
||||
)
|
||||
self._timezone = ZoneInfo("UTC")
|
||||
else:
|
||||
self._timezone = get_local_timezone()
|
||||
|
||||
def formatTime(self, record, datefmt=None): # noqa: N802 - inherited method name
|
||||
dt = datetime.fromtimestamp(record.created, tz=self._timezone)
|
||||
if datefmt:
|
||||
return dt.strftime(datefmt)
|
||||
return dt.strftime("%Y-%m-%d %H:%M:%S,%f")[:-3]
|
||||
+20
-9
@@ -20,15 +20,26 @@ def format_referrer_info(user: User) -> str:
|
||||
if not referred_by_id:
|
||||
return "Нет"
|
||||
|
||||
referrer = getattr(user, "referrer", None)
|
||||
|
||||
if not referrer:
|
||||
return f"ID {referred_by_id} (не найден)"
|
||||
|
||||
if referrer.username:
|
||||
return f"@{referrer.username} (ID: {referred_by_id})"
|
||||
|
||||
return f"ID {referrer.telegram_id}"
|
||||
try:
|
||||
# Проверяем, является ли referrer обычным объектом или InstrumentedList
|
||||
referrer = getattr(user, "referrer", None)
|
||||
|
||||
# Если referrer это InstrumentedList или None, то возвращаем информацию по ID
|
||||
if referrer is None:
|
||||
return f"ID {referred_by_id} (не найден)"
|
||||
|
||||
# Пытаемся получить атрибуты referrer, если они доступны
|
||||
referrer_username = getattr(referrer, "username", None)
|
||||
referrer_telegram_id = getattr(referrer, "telegram_id", None)
|
||||
|
||||
if referrer_username:
|
||||
return f"@{referrer_username} (ID: {referred_by_id})"
|
||||
|
||||
return f"ID {referrer_telegram_id or referred_by_id}"
|
||||
|
||||
except (AttributeError, TypeError):
|
||||
# Если возникла ошибка при обращении к атрибутам, просто возвращаем ID
|
||||
return f"ID {referred_by_id} (ошибка загрузки)"
|
||||
|
||||
|
||||
async def generate_unique_referral_code(db: AsyncSession, telegram_id: int) -> str:
|
||||
|
||||
+15
-1
@@ -13,8 +13,9 @@ from .routes import (
|
||||
config,
|
||||
health,
|
||||
main_menu_buttons,
|
||||
promocodes,
|
||||
miniapp,
|
||||
polls,
|
||||
promocodes,
|
||||
promo_groups,
|
||||
promo_offers,
|
||||
pages,
|
||||
@@ -25,6 +26,7 @@ from .routes import (
|
||||
tokens,
|
||||
transactions,
|
||||
users,
|
||||
logs,
|
||||
)
|
||||
|
||||
|
||||
@@ -69,6 +71,12 @@ OPENAPI_TAGS = [
|
||||
"name": "promo-offers",
|
||||
"description": "Управление промо-предложениями, шаблонами и журналом событий.",
|
||||
},
|
||||
{
|
||||
"name": "logs",
|
||||
"description": (
|
||||
"Журналы мониторинга бота, действий модераторов поддержки и системный лог-файл."
|
||||
),
|
||||
},
|
||||
{
|
||||
"name": "auth",
|
||||
"description": "Управление токенами доступа к административному API.",
|
||||
@@ -84,6 +92,10 @@ OPENAPI_TAGS = [
|
||||
"name": "miniapp",
|
||||
"description": "Endpoint для Telegram Mini App с информацией о подписке пользователя.",
|
||||
},
|
||||
{
|
||||
"name": "polls",
|
||||
"description": "Создание опросов, удаление, статистика и ответы пользователей.",
|
||||
},
|
||||
{
|
||||
"name": "pages",
|
||||
"description": "Управление контентом публичных страниц: оферта, политика, FAQ и правила.",
|
||||
@@ -138,5 +150,7 @@ def create_web_api_app() -> FastAPI:
|
||||
app.include_router(tokens.router, prefix="/tokens", tags=["auth"])
|
||||
app.include_router(remnawave.router, prefix="/remnawave", tags=["remnawave"])
|
||||
app.include_router(miniapp.router, prefix="/miniapp", tags=["miniapp"])
|
||||
app.include_router(polls.router, prefix="/polls", tags=["polls"])
|
||||
app.include_router(logs.router, prefix="/logs", tags=["logs"])
|
||||
|
||||
return app
|
||||
|
||||
@@ -3,6 +3,7 @@ from . import (
|
||||
health,
|
||||
main_menu_buttons,
|
||||
miniapp,
|
||||
polls,
|
||||
promo_offers,
|
||||
pages,
|
||||
promo_groups,
|
||||
@@ -13,6 +14,7 @@ from . import (
|
||||
tokens,
|
||||
transactions,
|
||||
users,
|
||||
logs,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
@@ -20,6 +22,7 @@ __all__ = [
|
||||
"health",
|
||||
"main_menu_buttons",
|
||||
"miniapp",
|
||||
"polls",
|
||||
"promo_offers",
|
||||
"pages",
|
||||
"promo_groups",
|
||||
@@ -30,4 +33,5 @@ __all__ = [
|
||||
"tokens",
|
||||
"transactions",
|
||||
"users",
|
||||
"logs",
|
||||
]
|
||||
|
||||
@@ -0,0 +1,239 @@
|
||||
"""Маршруты административного API для просмотра логов."""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Security
|
||||
from fastapi.concurrency import run_in_threadpool
|
||||
from fastapi.responses import FileResponse
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.crud.ticket import TicketCRUD
|
||||
from app.services.monitoring_service import monitoring_service
|
||||
|
||||
from ..dependencies import get_db_session, require_api_token
|
||||
from ..schemas.logs import (
|
||||
MonitoringLogEntry,
|
||||
MonitoringLogListResponse,
|
||||
MonitoringLogTypeListResponse,
|
||||
SupportAuditActionsResponse,
|
||||
SupportAuditLogEntry,
|
||||
SupportAuditLogListResponse,
|
||||
SystemLogPreviewResponse,
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
SYSTEM_LOG_PREVIEW_LIMIT_DEFAULT = 4000
|
||||
SYSTEM_LOG_PREVIEW_LIMIT_MAX = 20000
|
||||
|
||||
|
||||
def _resolve_system_log_path() -> Path:
|
||||
path = Path(settings.LOG_FILE)
|
||||
if not path.is_absolute():
|
||||
path = Path.cwd() / path
|
||||
return path
|
||||
|
||||
|
||||
async def _read_system_log(path: Path) -> tuple[str, int, Optional[float]]:
|
||||
def _read() -> tuple[str, int, float]:
|
||||
content = path.read_text(encoding="utf-8", errors="ignore")
|
||||
stats = path.stat()
|
||||
return content, stats.st_size, stats.st_mtime
|
||||
|
||||
return await run_in_threadpool(_read)
|
||||
|
||||
|
||||
def _format_timestamp(timestamp: Optional[float]) -> Optional[datetime]:
|
||||
if timestamp is None:
|
||||
return None
|
||||
return datetime.fromtimestamp(timestamp, tz=timezone.utc)
|
||||
|
||||
|
||||
@router.get("/system", response_model=SystemLogPreviewResponse)
|
||||
async def get_system_log_preview(
|
||||
_: Any = Security(require_api_token),
|
||||
preview_limit: int = Query(
|
||||
SYSTEM_LOG_PREVIEW_LIMIT_DEFAULT,
|
||||
ge=500,
|
||||
le=SYSTEM_LOG_PREVIEW_LIMIT_MAX,
|
||||
description="Количество символов предпросмотра от конца файла",
|
||||
),
|
||||
) -> SystemLogPreviewResponse:
|
||||
"""Получить предпросмотр системного лог-файла бота."""
|
||||
|
||||
log_path = _resolve_system_log_path()
|
||||
|
||||
if not log_path.exists() or not log_path.is_file():
|
||||
return SystemLogPreviewResponse(
|
||||
path=str(log_path),
|
||||
exists=False,
|
||||
updated_at=None,
|
||||
size_bytes=0,
|
||||
size_chars=0,
|
||||
preview="",
|
||||
preview_chars=0,
|
||||
preview_truncated=False,
|
||||
download_url="/logs/system/download",
|
||||
)
|
||||
|
||||
try:
|
||||
content, size_bytes, mtime = await _read_system_log(log_path)
|
||||
except FileNotFoundError:
|
||||
logger.warning("Лог-файл %s исчез во время чтения", log_path)
|
||||
return SystemLogPreviewResponse(
|
||||
path=str(log_path),
|
||||
exists=False,
|
||||
updated_at=None,
|
||||
size_bytes=0,
|
||||
size_chars=0,
|
||||
preview="",
|
||||
preview_chars=0,
|
||||
preview_truncated=False,
|
||||
download_url="/logs/system/download",
|
||||
)
|
||||
except Exception as error: # pragma: no cover - защита от неожиданных ошибок чтения
|
||||
logger.error("Ошибка чтения лог-файла %s: %s", log_path, error)
|
||||
raise HTTPException(status_code=500, detail="Не удалось прочитать лог-файл") from error
|
||||
|
||||
preview_text = content[-preview_limit:] if preview_limit > 0 else ""
|
||||
truncated = len(content) > len(preview_text)
|
||||
|
||||
return SystemLogPreviewResponse(
|
||||
path=str(log_path),
|
||||
exists=True,
|
||||
updated_at=_format_timestamp(mtime),
|
||||
size_bytes=size_bytes,
|
||||
size_chars=len(content),
|
||||
preview=preview_text,
|
||||
preview_chars=len(preview_text),
|
||||
preview_truncated=truncated,
|
||||
download_url="/logs/system/download",
|
||||
)
|
||||
|
||||
|
||||
@router.get("/system/download")
|
||||
async def download_system_log(
|
||||
_: Any = Security(require_api_token),
|
||||
) -> FileResponse:
|
||||
"""Скачать полный лог-файл бота."""
|
||||
|
||||
log_path = _resolve_system_log_path()
|
||||
|
||||
if not log_path.exists() or not log_path.is_file():
|
||||
raise HTTPException(status_code=404, detail="Лог-файл не найден")
|
||||
|
||||
try:
|
||||
return FileResponse(
|
||||
log_path,
|
||||
media_type="text/plain",
|
||||
filename=log_path.name,
|
||||
)
|
||||
except Exception as error: # pragma: no cover - защита от неожиданных ошибок отдачи файла
|
||||
logger.error("Ошибка отправки лог-файла %s: %s", log_path, error)
|
||||
raise HTTPException(status_code=500, detail="Не удалось отправить лог-файл") from error
|
||||
|
||||
|
||||
@router.get("/monitoring", response_model=MonitoringLogListResponse)
|
||||
async def list_monitoring_logs(
|
||||
_: Any = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
limit: int = Query(50, ge=1, le=200, description="Количество записей на странице"),
|
||||
offset: int = Query(0, ge=0, description="Смещение от начала списка"),
|
||||
event_type: Optional[str] = Query(
|
||||
default=None,
|
||||
max_length=100,
|
||||
description="Фильтр по типу события",
|
||||
),
|
||||
) -> MonitoringLogListResponse:
|
||||
"""Получить список логов мониторинга с пагинацией."""
|
||||
|
||||
per_page = limit
|
||||
page = (offset // per_page) + 1
|
||||
|
||||
raw_logs = await monitoring_service.get_monitoring_logs(
|
||||
db,
|
||||
event_type=event_type,
|
||||
page=page,
|
||||
per_page=per_page,
|
||||
)
|
||||
total = await monitoring_service.get_monitoring_logs_count(db, event_type=event_type)
|
||||
|
||||
return MonitoringLogListResponse(
|
||||
total=total,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
items=[MonitoringLogEntry(**entry) for entry in raw_logs],
|
||||
)
|
||||
|
||||
|
||||
@router.get("/monitoring/event-types", response_model=MonitoringLogTypeListResponse)
|
||||
async def list_monitoring_event_types(
|
||||
_: Any = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> MonitoringLogTypeListResponse:
|
||||
"""Получить список доступных типов событий мониторинга."""
|
||||
|
||||
event_types = await monitoring_service.get_monitoring_event_types(db)
|
||||
return MonitoringLogTypeListResponse(items=event_types)
|
||||
|
||||
|
||||
@router.get("/support", response_model=SupportAuditLogListResponse)
|
||||
async def list_support_audit_logs(
|
||||
_: Any = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
limit: int = Query(50, ge=1, le=200, description="Количество записей на странице"),
|
||||
offset: int = Query(0, ge=0, description="Смещение от начала списка"),
|
||||
action: Optional[str] = Query(
|
||||
default=None,
|
||||
max_length=50,
|
||||
description="Фильтр по типу действия модератора",
|
||||
),
|
||||
) -> SupportAuditLogListResponse:
|
||||
"""Получить список аудита действий модераторов поддержки."""
|
||||
|
||||
logs = await TicketCRUD.list_support_audit(
|
||||
db,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
action=action,
|
||||
)
|
||||
total = await TicketCRUD.count_support_audit(db, action=action)
|
||||
|
||||
return SupportAuditLogListResponse(
|
||||
total=total,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
items=[
|
||||
SupportAuditLogEntry(
|
||||
id=log.id,
|
||||
actor_user_id=log.actor_user_id,
|
||||
actor_telegram_id=log.actor_telegram_id,
|
||||
is_moderator=log.is_moderator,
|
||||
action=log.action,
|
||||
ticket_id=log.ticket_id,
|
||||
target_user_id=log.target_user_id,
|
||||
details=log.details,
|
||||
created_at=log.created_at,
|
||||
)
|
||||
for log in logs
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
@router.get("/support/actions", response_model=SupportAuditActionsResponse)
|
||||
async def list_support_audit_actions(
|
||||
_: Any = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> SupportAuditActionsResponse:
|
||||
"""Получить список действий, доступных в аудите поддержки."""
|
||||
|
||||
actions = await TicketCRUD.list_support_audit_actions(db)
|
||||
return SupportAuditActionsResponse(items=actions)
|
||||
@@ -58,6 +58,7 @@ from app.services.admin_notification_service import AdminNotificationService
|
||||
from app.services.faq_service import FaqService
|
||||
from app.services.privacy_policy_service import PrivacyPolicyService
|
||||
from app.services.public_offer_service import PublicOfferService
|
||||
from app.utils.timezone import format_local_datetime
|
||||
from app.services.remnawave_service import (
|
||||
RemnaWaveConfigurationError,
|
||||
RemnaWaveService,
|
||||
@@ -2408,23 +2409,6 @@ async def _build_referral_info(
|
||||
inviter_bonus_kopeks = int(referral_settings.get("inviter_bonus_kopeks") or 0)
|
||||
commission_percent = float(referral_settings.get("commission_percent") or 0)
|
||||
|
||||
referred_user_reward_kopeks = settings.get_referred_user_reward_kopeks()
|
||||
for key in ("referred_user_reward_kopeks", "referred_user_reward"):
|
||||
candidate = referral_settings.get(key)
|
||||
if candidate is None:
|
||||
continue
|
||||
try:
|
||||
value = int(candidate)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if value <= 0:
|
||||
referred_user_reward_kopeks = 0
|
||||
break
|
||||
if key == "referred_user_reward" and value < 1000:
|
||||
value *= 100
|
||||
referred_user_reward_kopeks = value
|
||||
break
|
||||
|
||||
terms = MiniAppReferralTerms(
|
||||
minimum_topup_kopeks=minimum_topup_kopeks,
|
||||
minimum_topup_label=settings.format_price(minimum_topup_kopeks),
|
||||
@@ -2433,8 +2417,6 @@ async def _build_referral_info(
|
||||
inviter_bonus_kopeks=inviter_bonus_kopeks,
|
||||
inviter_bonus_label=settings.format_price(inviter_bonus_kopeks),
|
||||
commission_percent=commission_percent,
|
||||
referred_user_reward_kopeks=referred_user_reward_kopeks,
|
||||
referred_user_reward_label=settings.format_price(referred_user_reward_kopeks),
|
||||
)
|
||||
|
||||
summary = await get_user_referral_summary(db, user.id)
|
||||
@@ -3121,8 +3103,16 @@ async def activate_subscription_trial_endpoint(
|
||||
},
|
||||
)
|
||||
|
||||
forced_devices = None
|
||||
if not settings.is_devices_selection_enabled():
|
||||
forced_devices = settings.get_disabled_mode_device_limit()
|
||||
|
||||
try:
|
||||
subscription = await create_trial_subscription(db, user.id)
|
||||
subscription = await create_trial_subscription(
|
||||
db,
|
||||
user.id,
|
||||
device_limit=forced_devices,
|
||||
)
|
||||
except Exception as error: # pragma: no cover - defensive logging
|
||||
logger.error(
|
||||
"Failed to activate trial subscription for user %s: %s",
|
||||
@@ -3638,7 +3628,9 @@ async def _calculate_subscription_renewal_pricing(
|
||||
if traffic_limit is None:
|
||||
traffic_limit = settings.DEFAULT_TRAFFIC_LIMIT_GB
|
||||
|
||||
devices_limit = subscription.device_limit or settings.DEFAULT_DEVICE_LIMIT
|
||||
devices_limit = subscription.device_limit
|
||||
if devices_limit is None:
|
||||
devices_limit = settings.DEFAULT_DEVICE_LIMIT
|
||||
|
||||
total_cost, details = await calculate_subscription_total_cost(
|
||||
db,
|
||||
@@ -4437,7 +4429,7 @@ async def submit_subscription_renewal_endpoint(
|
||||
language_code = _normalize_language_code(user)
|
||||
amount_label = settings.format_price(final_total)
|
||||
date_label = (
|
||||
subscription.end_date.strftime("%d.%m.%Y %H:%M")
|
||||
format_local_datetime(subscription.end_date, "%d.%m.%Y %H:%M")
|
||||
if subscription.end_date
|
||||
else ""
|
||||
)
|
||||
@@ -5044,7 +5036,12 @@ async def update_subscription_devices_endpoint(
|
||||
},
|
||||
)
|
||||
|
||||
current_devices = int(subscription.device_limit or settings.DEFAULT_DEVICE_LIMIT or 1)
|
||||
current_devices_value = subscription.device_limit
|
||||
if current_devices_value is None:
|
||||
fallback_value = settings.DEFAULT_DEVICE_LIMIT or 1
|
||||
current_devices_value = fallback_value
|
||||
|
||||
current_devices = int(current_devices_value)
|
||||
old_devices = current_devices
|
||||
|
||||
if new_devices == current_devices:
|
||||
|
||||
@@ -0,0 +1,308 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from fastapi import (
|
||||
APIRouter,
|
||||
Depends,
|
||||
HTTPException,
|
||||
Query,
|
||||
Response,
|
||||
Security,
|
||||
status,
|
||||
)
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.database.crud.poll import (
|
||||
create_poll,
|
||||
delete_poll as delete_poll_record,
|
||||
get_poll_by_id,
|
||||
get_poll_responses_with_answers,
|
||||
get_poll_statistics,
|
||||
)
|
||||
from app.database.models import Poll, PollAnswer, PollOption, PollQuestion, PollResponse
|
||||
|
||||
from ..dependencies import get_db_session, require_api_token
|
||||
from ..schemas.polls import (
|
||||
PollAnswerResponse,
|
||||
PollCreateRequest,
|
||||
PollDetailResponse,
|
||||
PollListResponse,
|
||||
PollOptionStats,
|
||||
PollQuestionOptionResponse,
|
||||
PollQuestionResponse,
|
||||
PollQuestionStats,
|
||||
PollResponsesListResponse,
|
||||
PollStatisticsResponse,
|
||||
PollSummaryResponse,
|
||||
PollUserResponse,
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _format_price(kopeks: int) -> float:
|
||||
return round(kopeks / 100, 2)
|
||||
|
||||
|
||||
def _serialize_option(option: PollOption) -> PollQuestionOptionResponse:
|
||||
return PollQuestionOptionResponse(
|
||||
id=option.id,
|
||||
text=option.text,
|
||||
order=option.order,
|
||||
)
|
||||
|
||||
|
||||
def _serialize_question(question: PollQuestion) -> PollQuestionResponse:
|
||||
options = [
|
||||
_serialize_option(option)
|
||||
for option in sorted(question.options, key=lambda item: item.order)
|
||||
]
|
||||
return PollQuestionResponse(
|
||||
id=question.id,
|
||||
text=question.text,
|
||||
order=question.order,
|
||||
options=options,
|
||||
)
|
||||
|
||||
|
||||
def _serialize_poll_summary(
|
||||
poll: Poll,
|
||||
responses_count: int | None = None,
|
||||
) -> PollSummaryResponse:
|
||||
questions = getattr(poll, "questions", [])
|
||||
if responses_count is None:
|
||||
responses = getattr(poll, "responses", [])
|
||||
responses_count = len(responses)
|
||||
return PollSummaryResponse(
|
||||
id=poll.id,
|
||||
title=poll.title,
|
||||
description=poll.description,
|
||||
reward_enabled=poll.reward_enabled,
|
||||
reward_amount_kopeks=poll.reward_amount_kopeks,
|
||||
reward_amount_rubles=_format_price(poll.reward_amount_kopeks),
|
||||
questions_count=len(questions),
|
||||
responses_count=responses_count,
|
||||
created_at=poll.created_at,
|
||||
updated_at=poll.updated_at,
|
||||
)
|
||||
|
||||
|
||||
def _serialize_poll_detail(poll: Poll) -> PollDetailResponse:
|
||||
questions = [
|
||||
_serialize_question(question)
|
||||
for question in sorted(poll.questions, key=lambda item: item.order)
|
||||
]
|
||||
return PollDetailResponse(
|
||||
id=poll.id,
|
||||
title=poll.title,
|
||||
description=poll.description,
|
||||
reward_enabled=poll.reward_enabled,
|
||||
reward_amount_kopeks=poll.reward_amount_kopeks,
|
||||
reward_amount_rubles=_format_price(poll.reward_amount_kopeks),
|
||||
questions=questions,
|
||||
created_at=poll.created_at,
|
||||
updated_at=poll.updated_at,
|
||||
)
|
||||
|
||||
|
||||
def _serialize_answer(answer: PollAnswer) -> PollAnswerResponse:
|
||||
question = getattr(answer, "question", None)
|
||||
option = getattr(answer, "option", None)
|
||||
return PollAnswerResponse(
|
||||
question_id=question.id if question else answer.question_id,
|
||||
question_text=question.text if question else None,
|
||||
option_id=option.id if option else answer.option_id,
|
||||
option_text=option.text if option else None,
|
||||
created_at=answer.created_at,
|
||||
)
|
||||
|
||||
|
||||
def _serialize_user_response(response: PollResponse) -> PollUserResponse:
|
||||
user = getattr(response, "user", None)
|
||||
answers = [
|
||||
_serialize_answer(answer)
|
||||
for answer in sorted(response.answers, key=lambda item: item.created_at)
|
||||
]
|
||||
return PollUserResponse(
|
||||
id=response.id,
|
||||
user_id=getattr(user, "id", None),
|
||||
user_telegram_id=getattr(user, "telegram_id", None),
|
||||
user_username=getattr(user, "username", None),
|
||||
sent_at=response.sent_at,
|
||||
started_at=response.started_at,
|
||||
completed_at=response.completed_at,
|
||||
reward_given=response.reward_given,
|
||||
reward_amount_kopeks=response.reward_amount_kopeks,
|
||||
reward_amount_rubles=_format_price(response.reward_amount_kopeks),
|
||||
answers=answers,
|
||||
)
|
||||
|
||||
|
||||
@router.get("", response_model=PollListResponse)
|
||||
async def list_polls(
|
||||
_: Any = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
limit: int = Query(50, ge=1, le=200),
|
||||
offset: int = Query(0, ge=0),
|
||||
) -> PollListResponse:
|
||||
total_result = await db.execute(select(func.count()).select_from(Poll))
|
||||
total = int(total_result.scalar_one() or 0)
|
||||
|
||||
if total == 0:
|
||||
return PollListResponse(items=[], total=0, limit=limit, offset=offset)
|
||||
|
||||
result = await db.execute(
|
||||
select(Poll)
|
||||
.options(selectinload(Poll.questions))
|
||||
.order_by(Poll.created_at.desc())
|
||||
.offset(offset)
|
||||
.limit(limit)
|
||||
)
|
||||
polls = result.scalars().unique().all()
|
||||
|
||||
if not polls:
|
||||
return PollListResponse(items=[], total=total, limit=limit, offset=offset)
|
||||
|
||||
poll_ids = [poll.id for poll in polls]
|
||||
counts_result = await db.execute(
|
||||
select(PollResponse.poll_id, func.count(PollResponse.id))
|
||||
.where(PollResponse.poll_id.in_(poll_ids))
|
||||
.group_by(PollResponse.poll_id)
|
||||
)
|
||||
responses_counts = {poll_id: count for poll_id, count in counts_result.all()}
|
||||
|
||||
return PollListResponse(
|
||||
items=[
|
||||
_serialize_poll_summary(
|
||||
poll, responses_counts.get(poll.id, 0)
|
||||
)
|
||||
for poll in polls
|
||||
],
|
||||
total=total,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{poll_id}", response_model=PollDetailResponse)
|
||||
async def get_poll(
|
||||
poll_id: int,
|
||||
_: Any = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> PollDetailResponse:
|
||||
poll = await get_poll_by_id(db, poll_id)
|
||||
if not poll:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "Poll not found")
|
||||
|
||||
return _serialize_poll_detail(poll)
|
||||
|
||||
|
||||
@router.post("", response_model=PollDetailResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def create_poll_endpoint(
|
||||
payload: PollCreateRequest,
|
||||
_: Any = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> PollDetailResponse:
|
||||
poll = await create_poll(
|
||||
db,
|
||||
title=payload.title,
|
||||
description=payload.description,
|
||||
reward_enabled=payload.reward_enabled,
|
||||
reward_amount_kopeks=payload.reward_amount_kopeks,
|
||||
created_by=None,
|
||||
questions=[
|
||||
{
|
||||
"text": question.text,
|
||||
"options": [option.text for option in question.options],
|
||||
}
|
||||
for question in payload.questions
|
||||
],
|
||||
)
|
||||
|
||||
poll = await get_poll_by_id(db, poll.id)
|
||||
return _serialize_poll_detail(poll)
|
||||
|
||||
|
||||
@router.delete("/{poll_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_poll(
|
||||
poll_id: int,
|
||||
_: Any = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> Response:
|
||||
success = await delete_poll_record(db, poll_id)
|
||||
if not success:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "Poll not found")
|
||||
|
||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
|
||||
@router.get("/{poll_id}/stats", response_model=PollStatisticsResponse)
|
||||
async def get_poll_stats(
|
||||
poll_id: int,
|
||||
_: Any = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> PollStatisticsResponse:
|
||||
poll = await db.get(Poll, poll_id)
|
||||
if not poll:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "Poll not found")
|
||||
|
||||
stats = await get_poll_statistics(db, poll_id)
|
||||
|
||||
formatted_questions = [
|
||||
PollQuestionStats(
|
||||
id=question_data["id"],
|
||||
text=question_data["text"],
|
||||
order=question_data["order"],
|
||||
options=[
|
||||
PollOptionStats(
|
||||
id=option_data["id"],
|
||||
text=option_data["text"],
|
||||
count=option_data["count"],
|
||||
)
|
||||
for option_data in question_data.get("options", [])
|
||||
],
|
||||
)
|
||||
for question_data in stats.get("questions", [])
|
||||
]
|
||||
|
||||
return PollStatisticsResponse(
|
||||
poll_id=poll.id,
|
||||
poll_title=poll.title,
|
||||
total_responses=stats.get("total_responses", 0),
|
||||
completed_responses=stats.get("completed_responses", 0),
|
||||
reward_sum_kopeks=stats.get("reward_sum_kopeks", 0),
|
||||
reward_sum_rubles=_format_price(stats.get("reward_sum_kopeks", 0)),
|
||||
questions=formatted_questions,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{poll_id}/responses", response_model=PollResponsesListResponse)
|
||||
async def get_poll_responses(
|
||||
poll_id: int,
|
||||
_: Any = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
limit: int = Query(50, ge=1, le=200),
|
||||
offset: int = Query(0, ge=0),
|
||||
) -> PollResponsesListResponse:
|
||||
poll_exists = await db.get(Poll, poll_id)
|
||||
if not poll_exists:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "Poll not found")
|
||||
|
||||
responses, total = await get_poll_responses_with_answers(
|
||||
db,
|
||||
poll_id,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
)
|
||||
|
||||
items = [_serialize_user_response(response) for response in responses]
|
||||
|
||||
return PollResponsesListResponse(
|
||||
items=items,
|
||||
total=total,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
)
|
||||
@@ -52,12 +52,12 @@ def _serialize(group: PromoGroup, members_count: int = 0) -> PromoGroupResponse:
|
||||
apply_discounts_to_addons=group.apply_discounts_to_addons,
|
||||
is_default=group.is_default,
|
||||
members_count=members_count,
|
||||
created_at=group.created_at,
|
||||
updated_at=group.updated_at,
|
||||
created_at=getattr(group, "created_at", None),
|
||||
updated_at=getattr(group, "updated_at", None),
|
||||
)
|
||||
|
||||
|
||||
@router.get("", response_model=PromoGroupListResponse)
|
||||
@router.get("", response_model=PromoGroupListResponse, response_model_exclude_none=True)
|
||||
async def list_promo_groups(
|
||||
_: Any = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
@@ -79,7 +79,7 @@ async def list_promo_groups(
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{group_id}", response_model=PromoGroupResponse)
|
||||
@router.get("/{group_id}", response_model=PromoGroupResponse, response_model_exclude_none=True)
|
||||
async def get_promo_group(
|
||||
group_id: int,
|
||||
_: Any = Security(require_api_token),
|
||||
@@ -93,7 +93,12 @@ async def get_promo_group(
|
||||
return _serialize(group, members_count=members_count)
|
||||
|
||||
|
||||
@router.post("", response_model=PromoGroupResponse, status_code=status.HTTP_201_CREATED)
|
||||
@router.post(
|
||||
"",
|
||||
response_model=PromoGroupResponse,
|
||||
response_model_exclude_none=True,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
async def create_promo_group_endpoint(
|
||||
payload: PromoGroupCreateRequest,
|
||||
_: Any = Security(require_api_token),
|
||||
@@ -120,7 +125,11 @@ async def create_promo_group_endpoint(
|
||||
return _serialize(group, members_count=0)
|
||||
|
||||
|
||||
@router.patch("/{group_id}", response_model=PromoGroupResponse)
|
||||
@router.patch(
|
||||
"/{group_id}",
|
||||
response_model=PromoGroupResponse,
|
||||
response_model_exclude_none=True,
|
||||
)
|
||||
async def update_promo_group_endpoint(
|
||||
group_id: int,
|
||||
payload: PromoGroupUpdateRequest,
|
||||
|
||||
@@ -112,24 +112,38 @@ async def create_subscription(
|
||||
if existing:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, "User already has a subscription")
|
||||
|
||||
forced_devices = None
|
||||
if not settings.is_devices_selection_enabled():
|
||||
forced_devices = settings.get_disabled_mode_device_limit()
|
||||
|
||||
if payload.is_trial:
|
||||
trial_device_limit = payload.device_limit
|
||||
if trial_device_limit is None:
|
||||
trial_device_limit = forced_devices
|
||||
|
||||
subscription = await create_trial_subscription(
|
||||
db,
|
||||
user_id=payload.user_id,
|
||||
duration_days=payload.duration_days,
|
||||
traffic_limit_gb=payload.traffic_limit_gb,
|
||||
device_limit=payload.device_limit,
|
||||
device_limit=trial_device_limit,
|
||||
squad_uuid=payload.squad_uuid,
|
||||
)
|
||||
else:
|
||||
if payload.duration_days is None:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, "duration_days is required for paid subscriptions")
|
||||
device_limit = payload.device_limit
|
||||
if device_limit is None:
|
||||
if forced_devices is not None:
|
||||
device_limit = forced_devices
|
||||
else:
|
||||
device_limit = settings.DEFAULT_DEVICE_LIMIT
|
||||
subscription = await create_paid_subscription(
|
||||
db,
|
||||
user_id=payload.user_id,
|
||||
duration_days=payload.duration_days,
|
||||
traffic_limit_gb=payload.traffic_limit_gb or settings.DEFAULT_TRAFFIC_LIMIT_GB,
|
||||
device_limit=payload.device_limit or settings.DEFAULT_DEVICE_LIMIT,
|
||||
device_limit=device_limit,
|
||||
connected_squads=payload.connected_squads or [],
|
||||
update_server_counters=True,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
"""Pydantic-схемы для работы с логами административного API."""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class MonitoringLogEntry(BaseModel):
|
||||
"""Запись лога мониторинга."""
|
||||
|
||||
id: int
|
||||
event_type: str = Field(..., description="Тип события мониторинга")
|
||||
message: str = Field(..., description="Краткое описание события")
|
||||
data: Optional[Dict[str, Any]] = Field(
|
||||
default=None,
|
||||
description="Дополнительные данные события",
|
||||
)
|
||||
is_success: bool = Field(..., description="Флаг успешности выполнения операции")
|
||||
created_at: datetime = Field(..., description="Дата и время создания записи")
|
||||
|
||||
|
||||
class MonitoringLogListResponse(BaseModel):
|
||||
"""Ответ со списком логов мониторинга."""
|
||||
|
||||
total: int = Field(..., ge=0)
|
||||
limit: int = Field(..., ge=1)
|
||||
offset: int = Field(..., ge=0)
|
||||
items: List[MonitoringLogEntry]
|
||||
|
||||
|
||||
class MonitoringLogTypeListResponse(BaseModel):
|
||||
"""Ответ со списком доступных типов событий мониторинга."""
|
||||
|
||||
items: List[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class SupportAuditLogEntry(BaseModel):
|
||||
"""Запись аудита модераторов поддержки."""
|
||||
|
||||
id: int
|
||||
actor_user_id: Optional[int]
|
||||
actor_telegram_id: int
|
||||
is_moderator: bool
|
||||
action: str
|
||||
ticket_id: Optional[int]
|
||||
target_user_id: Optional[int]
|
||||
details: Optional[Dict[str, Any]] = None
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class SupportAuditLogListResponse(BaseModel):
|
||||
"""Ответ со списком аудита поддержки."""
|
||||
|
||||
total: int = Field(..., ge=0)
|
||||
limit: int = Field(..., ge=1)
|
||||
offset: int = Field(..., ge=0)
|
||||
items: List[SupportAuditLogEntry]
|
||||
|
||||
|
||||
class SupportAuditActionsResponse(BaseModel):
|
||||
"""Ответ со списком доступных действий аудита поддержки."""
|
||||
|
||||
items: List[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class SystemLogPreviewResponse(BaseModel):
|
||||
"""Ответ с превью системного лог-файла бота."""
|
||||
|
||||
path: str = Field(..., description="Абсолютный путь до лог-файла")
|
||||
exists: bool = Field(..., description="Флаг наличия лог-файла")
|
||||
updated_at: Optional[datetime] = Field(
|
||||
default=None,
|
||||
description="Дата и время последнего изменения лог-файла",
|
||||
)
|
||||
size_bytes: int = Field(..., ge=0, description="Размер лог-файла в байтах")
|
||||
size_chars: int = Field(..., ge=0, description="Количество символов в лог-файле")
|
||||
preview: str = Field(
|
||||
default="",
|
||||
description="Фрагмент содержимого лог-файла, возвращаемый для предпросмотра",
|
||||
)
|
||||
preview_chars: int = Field(..., ge=0, description="Размер предпросмотра в символах")
|
||||
preview_truncated: bool = Field(
|
||||
..., description="Флаг усечения предпросмотра относительно полного файла"
|
||||
)
|
||||
download_url: Optional[str] = Field(
|
||||
default=None,
|
||||
description="Относительный путь до endpoint для скачивания лог-файла",
|
||||
)
|
||||
@@ -296,8 +296,6 @@ class MiniAppReferralTerms(BaseModel):
|
||||
inviter_bonus_kopeks: int = 0
|
||||
inviter_bonus_label: Optional[str] = None
|
||||
commission_percent: float = 0.0
|
||||
referred_user_reward_kopeks: int = 0
|
||||
referred_user_reward_label: Optional[str] = None
|
||||
|
||||
|
||||
class MiniAppReferralStats(BaseModel):
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import BaseModel, Field, field_validator, model_validator
|
||||
|
||||
|
||||
class PollOptionCreate(BaseModel):
|
||||
text: str = Field(..., min_length=1, max_length=500)
|
||||
|
||||
@field_validator("text")
|
||||
@classmethod
|
||||
def strip_text(cls, value: str) -> str:
|
||||
text = value.strip()
|
||||
if not text:
|
||||
raise ValueError("Option text cannot be empty")
|
||||
return text
|
||||
|
||||
|
||||
class PollQuestionCreate(BaseModel):
|
||||
text: str = Field(..., min_length=1, max_length=1000)
|
||||
options: list[PollOptionCreate] = Field(..., min_length=2)
|
||||
|
||||
@field_validator("text")
|
||||
@classmethod
|
||||
def strip_question_text(cls, value: str) -> str:
|
||||
text = value.strip()
|
||||
if not text:
|
||||
raise ValueError("Question text cannot be empty")
|
||||
return text
|
||||
|
||||
@field_validator("options")
|
||||
@classmethod
|
||||
def validate_options(cls, value: list[PollOptionCreate]) -> list[PollOptionCreate]:
|
||||
seen: set[str] = set()
|
||||
for option in value:
|
||||
normalized = option.text.lower()
|
||||
if normalized in seen:
|
||||
raise ValueError("Option texts must be unique within a question")
|
||||
seen.add(normalized)
|
||||
return value
|
||||
|
||||
|
||||
class PollCreateRequest(BaseModel):
|
||||
title: str = Field(..., min_length=1, max_length=255)
|
||||
description: Optional[str] = Field(default=None, max_length=4000)
|
||||
reward_enabled: bool = False
|
||||
reward_amount_kopeks: int = Field(default=0, ge=0, le=1_000_000_000)
|
||||
questions: list[PollQuestionCreate] = Field(..., min_length=1)
|
||||
|
||||
@field_validator("title")
|
||||
@classmethod
|
||||
def strip_title(cls, value: str) -> str:
|
||||
title = value.strip()
|
||||
if not title:
|
||||
raise ValueError("Title cannot be empty")
|
||||
return title
|
||||
|
||||
@field_validator("description")
|
||||
@classmethod
|
||||
def normalize_description(cls, value: Optional[str]) -> Optional[str]:
|
||||
if value is None:
|
||||
return None
|
||||
description = value.strip()
|
||||
return description or None
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_reward(self) -> "PollCreateRequest":
|
||||
if self.reward_enabled and self.reward_amount_kopeks <= 0:
|
||||
raise ValueError("Reward amount must be positive when rewards are enabled")
|
||||
if not self.reward_enabled:
|
||||
self.reward_amount_kopeks = 0
|
||||
return self
|
||||
|
||||
|
||||
class PollQuestionOptionResponse(BaseModel):
|
||||
id: int
|
||||
text: str
|
||||
order: int
|
||||
|
||||
|
||||
class PollQuestionResponse(BaseModel):
|
||||
id: int
|
||||
text: str
|
||||
order: int
|
||||
options: list[PollQuestionOptionResponse]
|
||||
|
||||
|
||||
class PollSummaryResponse(BaseModel):
|
||||
id: int
|
||||
title: str
|
||||
description: Optional[str]
|
||||
reward_enabled: bool
|
||||
reward_amount_kopeks: int
|
||||
reward_amount_rubles: float
|
||||
questions_count: int
|
||||
responses_count: int
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class PollDetailResponse(BaseModel):
|
||||
id: int
|
||||
title: str
|
||||
description: Optional[str]
|
||||
reward_enabled: bool
|
||||
reward_amount_kopeks: int
|
||||
reward_amount_rubles: float
|
||||
questions: list[PollQuestionResponse]
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class PollListResponse(BaseModel):
|
||||
items: list[PollSummaryResponse]
|
||||
total: int
|
||||
limit: int
|
||||
offset: int
|
||||
|
||||
|
||||
class PollOptionStats(BaseModel):
|
||||
id: int
|
||||
text: str
|
||||
count: int
|
||||
|
||||
|
||||
class PollQuestionStats(BaseModel):
|
||||
id: int
|
||||
text: str
|
||||
order: int
|
||||
options: list[PollOptionStats]
|
||||
|
||||
|
||||
class PollStatisticsResponse(BaseModel):
|
||||
poll_id: int
|
||||
poll_title: str
|
||||
total_responses: int
|
||||
completed_responses: int
|
||||
reward_sum_kopeks: int
|
||||
reward_sum_rubles: float
|
||||
questions: list[PollQuestionStats]
|
||||
|
||||
|
||||
class PollAnswerResponse(BaseModel):
|
||||
question_id: Optional[int]
|
||||
question_text: Optional[str]
|
||||
option_id: Optional[int]
|
||||
option_text: Optional[str]
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class PollUserResponse(BaseModel):
|
||||
id: int
|
||||
user_id: Optional[int]
|
||||
user_telegram_id: Optional[int]
|
||||
user_username: Optional[str]
|
||||
sent_at: datetime
|
||||
started_at: Optional[datetime]
|
||||
completed_at: Optional[datetime]
|
||||
reward_given: bool
|
||||
reward_amount_kopeks: int
|
||||
reward_amount_rubles: float
|
||||
answers: list[PollAnswerResponse]
|
||||
|
||||
|
||||
class PollResponsesListResponse(BaseModel):
|
||||
items: list[PollUserResponse]
|
||||
total: int
|
||||
limit: int
|
||||
offset: int
|
||||
@@ -3,7 +3,7 @@ from __future__ import annotations
|
||||
from datetime import datetime
|
||||
from typing import Dict, Optional
|
||||
|
||||
from pydantic import BaseModel, Field, validator
|
||||
from pydantic import BaseModel, ConfigDict, Field, validator
|
||||
|
||||
|
||||
def _normalize_period_discounts(value: Optional[Dict[object, object]]) -> Optional[Dict[int, int]]:
|
||||
@@ -23,6 +23,8 @@ def _normalize_period_discounts(value: Optional[Dict[object, object]]) -> Option
|
||||
|
||||
|
||||
class PromoGroupResponse(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
name: str
|
||||
server_discount_percent: int
|
||||
@@ -33,8 +35,8 @@ class PromoGroupResponse(BaseModel):
|
||||
apply_discounts_to_addons: bool
|
||||
is_default: bool
|
||||
members_count: int = 0
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
created_at: Optional[datetime] = None
|
||||
updated_at: Optional[datetime] = None
|
||||
|
||||
|
||||
class _PromoGroupBase(BaseModel):
|
||||
|
||||
+45
-7
@@ -36,13 +36,51 @@ curl -H "X-API-Key: super-secret-token" http://127.0.0.1:8080/health
|
||||
1. При необходимости отредактируйте `miniapp/app-config.json`, чтобы настроить инструкции и ссылки на нужные клиенты.
|
||||
2. Убедитесь, что файлы доступны для чтения пользователем веб-сервера.
|
||||
|
||||
## 5. Настройка кнопки в Telegram
|
||||
## 5. Размещение статических файлов в Docker
|
||||
|
||||
Если reverse-proxy (nginx или Caddy) работает внутри контейнера, смонтируйте каталог `miniapp` внутрь него по пути `/miniapp`. Та
|
||||
ким образом файлы `index.html` и `app-config.json` будут доступны серверу без копирования образа каждый раз при обновлении.
|
||||
|
||||
### Пример для docker-compose с Caddy
|
||||
|
||||
```yaml
|
||||
services:
|
||||
caddy:
|
||||
image: caddy:2
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- ./miniapp:/miniapp:ro # монтируем локальную папку внутрь контейнера
|
||||
- ./deploy/Caddyfile:/etc/caddy/Caddyfile:ro
|
||||
ports:
|
||||
- "80:80"
|
||||
- "443:443"
|
||||
```
|
||||
|
||||
### Пример для docker-compose с nginx
|
||||
|
||||
```yaml
|
||||
services:
|
||||
nginx:
|
||||
image: nginx:1.25-alpine
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- ./miniapp:/miniapp:ro # общий каталог статических файлов
|
||||
- ./deploy/nginx.conf:/etc/nginx/conf.d/default.conf:ro
|
||||
ports:
|
||||
- "80:80"
|
||||
- "443:443"
|
||||
```
|
||||
|
||||
Внутри конфигурации прокси укажите путь `/miniapp` как корневой для статических файлов или используйте директиву `alias`.
|
||||
При пересборке проекта структура останется той же, достаточно обновить файлы в локальной папке.
|
||||
|
||||
## 6. Настройка кнопки в Telegram
|
||||
|
||||
1. В дминке бота настраиваем параметры - Конфигурации бота - Прочее - Miniapp
|
||||
2. Перезапустите бота.
|
||||
3. При необходимости задайте кастомную кнопку на миниапп через `@BotFather` (команда `/setmenu` -> Web App URL).
|
||||
|
||||
## 6. Конфигурация nginx
|
||||
## 7. Конфигурация nginx
|
||||
|
||||
```nginx
|
||||
server {
|
||||
@@ -54,7 +92,7 @@ server {
|
||||
ssl_certificate_key /etc/letsencrypt/live/miniapp.example.com/privkey.pem;
|
||||
|
||||
# Статические файлы мини-приложения
|
||||
root /var/www/remnawave-miniapp;
|
||||
root /miniapp;
|
||||
index index.html;
|
||||
|
||||
location = /miniapp/app-config.json {
|
||||
@@ -79,12 +117,12 @@ server {
|
||||
}
|
||||
```
|
||||
|
||||
## 7. Конфигурация Caddy
|
||||
## 8. Конфигурация Caddy
|
||||
|
||||
```caddy
|
||||
miniapp.example.com {
|
||||
encode gzip zstd
|
||||
root * /var/www/remnawave-miniapp
|
||||
root * /miniapp
|
||||
file_server
|
||||
|
||||
@config path /app-config.json
|
||||
@@ -98,13 +136,13 @@ miniapp.example.com {
|
||||
```
|
||||
Caddy автоматически выпустит сертификаты через ACME. Убедитесь, что порт 443 проброшен и домен указывает на сервер.
|
||||
|
||||
## 8. Проверка работы
|
||||
## 9. Проверка работы
|
||||
|
||||
1. Откройте мини-приложение прямо в Telegram или через браузер: `https://miniapp.example.com`.
|
||||
2. В консоли разработчика убедитесь, что запрос к `https://miniapp.example.com/miniapp/subscription` возвращает JSON с данными подписки.
|
||||
3. Проверьте, что ссылки из блока «Подключить подписку» открываются и копируются без ошибок.
|
||||
|
||||
## 9. Диагностика
|
||||
## 10. Диагностика
|
||||
|
||||
| Симптом | Возможная причина | Проверка |
|
||||
|---------|------------------|----------|
|
||||
|
||||
@@ -135,10 +135,94 @@ curl -X POST "http://127.0.0.1:8080/tokens" \
|
||||
| `PATCH` | `/promo-offers/templates/{id}` | Обновить текст, кнопки и параметры шаблона.
|
||||
| `GET` | `/promo-offers/logs` | Журнал операций с промо-предложениями (активации, списания, выключения).
|
||||
| `GET` | `/tokens` | Управление токенами доступа.
|
||||
| `GET` | `/polls` | Список опросов с постраничной навигацией.
|
||||
| `GET` | `/polls/{id}` | Детали опроса с вопросами и вариантами ответов.
|
||||
| `POST` | `/polls` | Создать опрос: заголовок, описание, вопросы и варианты.
|
||||
| `DELETE` | `/polls/{id}` | Удалить опрос целиком.
|
||||
| `GET` | `/polls/{id}/stats` | Сводная статистика по ответам и начисленным наградам.
|
||||
| `GET` | `/polls/{id}/responses` | Ответы пользователей с детализацией по вопросам.
|
||||
| `GET` | `/logs/monitoring` | Логи мониторинга бота с пагинацией и фильтрами по типу события.
|
||||
| `GET` | `/logs/monitoring/event-types` | Справочник доступных типов событий мониторинга.
|
||||
| `GET` | `/logs/support` | Журнал действий модераторов поддержки (блокировки, закрытия тикетов).
|
||||
| `GET` | `/logs/support/actions` | Справочник возможных действий в аудите поддержки.
|
||||
| `GET` | `/logs/system` | Предпросмотр системного лог-файла бота с метаданными.
|
||||
| `GET` | `/logs/system/download` | Скачивание полного лог-файла бота (`text/plain`).
|
||||
|
||||
> Раздел **promo-offers** в Swagger объединяет работу с персональными предложениями: выдачу скидок/бонусов пользователям, настройку
|
||||
> текстов шаблонов и просмотр журнала операций (активации, автосписания, отключения просроченных акций).
|
||||
|
||||
### Логи бота
|
||||
|
||||
В административном API появился раздел **logs**. Он позволяет:
|
||||
|
||||
- Просматривать общие логи мониторинга (`GET /logs/monitoring`) с поддержкой пагинации (`limit`, `offset`) и фильтра по типу события (`event_type`).
|
||||
- Получать справочник доступных типов событий (`GET /logs/monitoring/event-types`). Это удобно для построения фильтров во внешней админке.
|
||||
- Отслеживать действия модераторов поддержки (`GET /logs/support`) с пагинацией и возможностью фильтровать по конкретному действию (`action`).
|
||||
- Запрашивать список возможных действий для UI (`GET /logs/support/actions`).
|
||||
- Просматривать системный лог-файл бота (`GET /logs/system`). Endpoint возвращает метаданные (путь, время изменения, размер в байтах/символах) и фрагмент конца файла, размер которого можно регулировать параметром `preview_limit` (от 500 до 20 000 символов).
|
||||
- Скачивать полный системный лог в текстовом формате (`GET /logs/system/download`).
|
||||
|
||||
Все эндпоинты защищены токеном API и возвращают структуру с общим количеством записей, текущим `limit`/`offset` и массивом объектов. Это упрощает реализацию таблиц и постраничной навигации во внешних административных интерфейсах.
|
||||
|
||||
### Управление опросами
|
||||
|
||||
Раздел **polls** в административном API позволяет создавать и анализировать опросы, которые бот рассылает пользователям.
|
||||
|
||||
#### Список и детали
|
||||
|
||||
- `GET /polls` — возвращает массив объектов с базовой информацией: название, описание, флаги награды, количество вопросов и ответов.
|
||||
- `GET /polls/{id}` — раскрывает структуру конкретного опроса, включая упорядоченные вопросы и варианты ответов. Подходит для предпросмотра перед публикацией.
|
||||
|
||||
#### Создание опроса
|
||||
|
||||
Для создания опроса отправьте JSON, соответствующий схеме:
|
||||
|
||||
```json
|
||||
{
|
||||
"title": "Оценка нового тарифа",
|
||||
"description": "Помогите улучшить продукт — ответ займет до 2 минут",
|
||||
"reward_enabled": true,
|
||||
"reward_amount_kopeks": 1000,
|
||||
"questions": [
|
||||
{
|
||||
"text": "Насколько вы довольны скоростью соединения?",
|
||||
"options": [
|
||||
{ "text": "Очень доволен" },
|
||||
{ "text": "Скорее доволен" },
|
||||
{ "text": "Нейтрально" },
|
||||
{ "text": "Скорее недоволен" },
|
||||
{ "text": "Очень недоволен" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"text": "Какие улучшения вы ждёте?",
|
||||
"options": [
|
||||
{ "text": "Стабильность" },
|
||||
{ "text": "Скорость" },
|
||||
{ "text": "Поддержка" }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Требования валидации:
|
||||
|
||||
- Заголовок (`title`) — от 1 до 255 символов, не пустой после обрезки пробелов.
|
||||
- Описание (`description`) — до 4000 символов, пробелы по краям удаляются.
|
||||
- Если `reward_enabled=true`, сумма вознаграждения (`reward_amount_kopeks`) должна быть положительной. При `false` значение автоматически сбрасывается в `0`.
|
||||
- Каждый вопрос содержит минимум два уникальных варианта ответа.
|
||||
|
||||
В ответ API вернёт созданный опрос с назначенными идентификаторами и полем `reward_amount_rubles`, которое удобно показывать в интерфейсе.
|
||||
|
||||
#### Удаление и статистика
|
||||
|
||||
- `DELETE /polls/{id}` — удаляет опрос и связанные с ним вопросы/ответы. Используйте с осторожностью, операция необратима.
|
||||
- `GET /polls/{id}/stats` — агрегированная статистика: общее количество ответов, завершённых прохождений и сумма выданных наград. Для каждого вопроса возвращается количество выборов по вариантам.
|
||||
- `GET /polls/{id}/responses` — список ответов пользователей с пагинацией (`limit`, `offset`). Каждый элемент содержит временные метки (`sent_at`, `started_at`, `completed_at`), данные пользователя (ID, username, Telegram ID), информацию о выданной награде и массив ответов с текстами вопросов/вариантов.
|
||||
|
||||
Такой формат позволяет без дополнительного запроса показать детализацию на фронтенде или выгрузить данные в CSV.
|
||||
|
||||
### RemnaWave интеграция
|
||||
|
||||
После включения веб-API в Swagger (`WEB_API_DOCS_ENABLED=true`) появится раздел **remnawave**. Он объединяет эндпоинты для управления панелью RemnaWave и синхронизации данных бота:
|
||||
|
||||
@@ -13,6 +13,14 @@ from app.database.database import init_db
|
||||
from app.services.monitoring_service import monitoring_service
|
||||
from app.services.maintenance_service import maintenance_service
|
||||
from app.services.payment_service import PaymentService
|
||||
from app.services.payment_verification_service import (
|
||||
PENDING_MAX_AGE,
|
||||
SUPPORTED_MANUAL_CHECK_METHODS,
|
||||
auto_payment_verification_service,
|
||||
get_enabled_auto_methods,
|
||||
method_display_name,
|
||||
)
|
||||
from app.database.models import PaymentMethod
|
||||
from app.services.version_service import version_service
|
||||
from app.external.webhook_server import WebhookServer
|
||||
from app.external.heleket_webhook import start_heleket_webhook_server
|
||||
@@ -28,6 +36,7 @@ from app.services.system_settings_service import bot_configuration_service
|
||||
from app.services.external_admin_service import ensure_external_admin_token
|
||||
from app.services.broadcast_service import broadcast_service
|
||||
from app.utils.startup_timeline import StartupTimeline
|
||||
from app.utils.timezone import TimezoneAwareFormatter
|
||||
|
||||
|
||||
class GracefulExit:
|
||||
@@ -41,13 +50,20 @@ class GracefulExit:
|
||||
|
||||
|
||||
async def main():
|
||||
formatter = TimezoneAwareFormatter(
|
||||
'%(asctime)s - %(name)s - %(levelname)s - %(message)s',
|
||||
timezone_name=settings.TIMEZONE,
|
||||
)
|
||||
|
||||
file_handler = logging.FileHandler(settings.LOG_FILE, encoding='utf-8')
|
||||
file_handler.setFormatter(formatter)
|
||||
|
||||
stream_handler = logging.StreamHandler(sys.stdout)
|
||||
stream_handler.setFormatter(formatter)
|
||||
|
||||
logging.basicConfig(
|
||||
level=getattr(logging, settings.LOG_LEVEL),
|
||||
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
|
||||
handlers=[
|
||||
logging.FileHandler(settings.LOG_FILE, encoding='utf-8'),
|
||||
logging.StreamHandler(sys.stdout)
|
||||
]
|
||||
handlers=[file_handler, stream_handler],
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -214,6 +230,67 @@ async def main():
|
||||
logger.error(f"❌ Ошибка запуска автосинхронизации RemnaWave: {e}")
|
||||
|
||||
payment_service = PaymentService(bot)
|
||||
auto_payment_verification_service.set_payment_service(payment_service)
|
||||
|
||||
verification_providers: list[str] = []
|
||||
auto_verification_active = False
|
||||
async with timeline.stage(
|
||||
"Сервис проверки пополнений",
|
||||
"💳",
|
||||
success_message="Ручная проверка активна",
|
||||
) as stage:
|
||||
for method in SUPPORTED_MANUAL_CHECK_METHODS:
|
||||
if method == PaymentMethod.YOOKASSA and settings.is_yookassa_enabled():
|
||||
verification_providers.append("YooKassa")
|
||||
elif method == PaymentMethod.MULENPAY and settings.is_mulenpay_enabled():
|
||||
verification_providers.append(settings.get_mulenpay_display_name())
|
||||
elif method == PaymentMethod.PAL24 and settings.is_pal24_enabled():
|
||||
verification_providers.append("PayPalych")
|
||||
elif method == PaymentMethod.WATA and settings.is_wata_enabled():
|
||||
verification_providers.append("WATA")
|
||||
elif method == PaymentMethod.HELEKET and settings.is_heleket_enabled():
|
||||
verification_providers.append("Heleket")
|
||||
elif method == PaymentMethod.CRYPTOBOT and settings.is_cryptobot_enabled():
|
||||
verification_providers.append("CryptoBot")
|
||||
|
||||
if verification_providers:
|
||||
hours = int(PENDING_MAX_AGE.total_seconds() // 3600)
|
||||
stage.log(
|
||||
"Ожидающие пополнения автоматически отбираются не старше "
|
||||
f"{hours}ч"
|
||||
)
|
||||
stage.log(
|
||||
"Доступна ручная проверка для: "
|
||||
+ ", ".join(sorted(verification_providers))
|
||||
)
|
||||
stage.success(
|
||||
f"Активно провайдеров: {len(verification_providers)}"
|
||||
)
|
||||
else:
|
||||
stage.skip("Нет активных провайдеров для ручной проверки")
|
||||
|
||||
if settings.is_payment_verification_auto_check_enabled():
|
||||
auto_methods = get_enabled_auto_methods()
|
||||
if auto_methods:
|
||||
interval_minutes = settings.get_payment_verification_auto_check_interval()
|
||||
auto_labels = ", ".join(
|
||||
sorted(method_display_name(method) for method in auto_methods)
|
||||
)
|
||||
stage.log(
|
||||
"Автопроверка каждые "
|
||||
f"{interval_minutes} мин: {auto_labels}"
|
||||
)
|
||||
else:
|
||||
stage.log(
|
||||
"Автопроверка включена, но нет активных провайдеров"
|
||||
)
|
||||
else:
|
||||
stage.log("Автопроверка отключена настройками")
|
||||
|
||||
await auto_payment_verification_service.start()
|
||||
auto_verification_active = auto_payment_verification_service.is_running()
|
||||
if auto_verification_active:
|
||||
stage.log("Фоновая автопроверка запущена")
|
||||
|
||||
async with timeline.stage(
|
||||
"Внешняя админка",
|
||||
@@ -334,9 +411,15 @@ async def main():
|
||||
"🛡️",
|
||||
success_message="Служба техработ запущена",
|
||||
) as stage:
|
||||
if not maintenance_service._check_task or maintenance_service._check_task.done():
|
||||
if not settings.is_maintenance_monitoring_enabled():
|
||||
maintenance_task = None
|
||||
stage.skip("Мониторинг техработ отключен настройками")
|
||||
elif not maintenance_service._check_task or maintenance_service._check_task.done():
|
||||
maintenance_task = asyncio.create_task(maintenance_service.start_monitoring())
|
||||
stage.log(f"Интервал проверки: {settings.MAINTENANCE_CHECK_INTERVAL}с")
|
||||
stage.log(
|
||||
f"Повторных попыток проверки: {settings.get_maintenance_retry_attempts()}"
|
||||
)
|
||||
else:
|
||||
maintenance_task = None
|
||||
stage.skip("Служба техработ уже активна")
|
||||
@@ -423,6 +506,18 @@ async def main():
|
||||
f"Проверка версий: {'Включен' if version_check_task else 'Отключен'}",
|
||||
f"Отчеты: {'Включен' if reporting_service.is_running() else 'Отключен'}",
|
||||
]
|
||||
services_lines.append(
|
||||
"Проверка пополнений: "
|
||||
+ ("Включена" if verification_providers else "Отключена")
|
||||
)
|
||||
services_lines.append(
|
||||
"Автопроверка пополнений: "
|
||||
+ (
|
||||
"Включена"
|
||||
if auto_payment_verification_service.is_running()
|
||||
else "Отключена"
|
||||
)
|
||||
)
|
||||
timeline.log_section("Активные фоновые сервисы", services_lines, icon="📄")
|
||||
|
||||
timeline.log_summary()
|
||||
@@ -484,7 +579,14 @@ async def main():
|
||||
if settings.is_version_check_enabled():
|
||||
logger.info("🔄 Перезапуск сервиса проверки версий...")
|
||||
version_check_task = asyncio.create_task(version_service.start_periodic_check())
|
||||
|
||||
|
||||
if auto_verification_active and not auto_payment_verification_service.is_running():
|
||||
logger.warning(
|
||||
"Сервис автопроверки пополнений остановился, пробуем перезапустить..."
|
||||
)
|
||||
await auto_payment_verification_service.start()
|
||||
auto_verification_active = auto_payment_verification_service.is_running()
|
||||
|
||||
if polling_task.done():
|
||||
exception = polling_task.exception()
|
||||
if exception:
|
||||
@@ -503,7 +605,15 @@ async def main():
|
||||
timeline.log_summary()
|
||||
summary_logged = True
|
||||
logger.info("🛑 Начинается корректное завершение работы...")
|
||||
|
||||
|
||||
logger.info("ℹ️ Остановка сервиса автопроверки пополнений...")
|
||||
try:
|
||||
await auto_payment_verification_service.stop()
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
f"Ошибка остановки сервиса автопроверки пополнений: {error}"
|
||||
)
|
||||
|
||||
if yookassa_server_task and not yookassa_server_task.done():
|
||||
logger.info("ℹ️ Остановка YooKassa webhook сервера...")
|
||||
yookassa_server_task.cancel()
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision: str = "9f0f2d5a1c7b"
|
||||
down_revision: Union[str, None] = "8fd1e338eb45"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"polls",
|
||||
sa.Column("id", sa.Integer(), primary_key=True),
|
||||
sa.Column("title", sa.String(length=255), nullable=False),
|
||||
sa.Column("description", sa.Text(), nullable=True),
|
||||
sa.Column(
|
||||
"reward_enabled",
|
||||
sa.Boolean(),
|
||||
nullable=False,
|
||||
server_default=sa.text("false"),
|
||||
),
|
||||
sa.Column(
|
||||
"reward_amount_kopeks",
|
||||
sa.Integer(),
|
||||
nullable=False,
|
||||
server_default="0",
|
||||
),
|
||||
sa.Column("created_by", sa.Integer(), nullable=True),
|
||||
sa.Column(
|
||||
"created_at",
|
||||
sa.DateTime(),
|
||||
nullable=False,
|
||||
server_default=sa.func.now(),
|
||||
),
|
||||
sa.Column(
|
||||
"updated_at",
|
||||
sa.DateTime(),
|
||||
nullable=False,
|
||||
server_default=sa.func.now(),
|
||||
),
|
||||
sa.ForeignKeyConstraint(["created_by"], ["users.id"], ondelete="SET NULL"),
|
||||
)
|
||||
op.create_index("ix_polls_id", "polls", ["id"])
|
||||
|
||||
op.create_table(
|
||||
"poll_questions",
|
||||
sa.Column("id", sa.Integer(), primary_key=True),
|
||||
sa.Column("poll_id", sa.Integer(), nullable=False),
|
||||
sa.Column("text", sa.Text(), nullable=False),
|
||||
sa.Column(
|
||||
"order",
|
||||
sa.Integer(),
|
||||
nullable=False,
|
||||
server_default="0",
|
||||
),
|
||||
sa.ForeignKeyConstraint(["poll_id"], ["polls.id"], ondelete="CASCADE"),
|
||||
)
|
||||
op.create_index("ix_poll_questions_id", "poll_questions", ["id"])
|
||||
op.create_index("ix_poll_questions_poll_id", "poll_questions", ["poll_id"])
|
||||
|
||||
op.create_table(
|
||||
"poll_options",
|
||||
sa.Column("id", sa.Integer(), primary_key=True),
|
||||
sa.Column("question_id", sa.Integer(), nullable=False),
|
||||
sa.Column("text", sa.Text(), nullable=False),
|
||||
sa.Column(
|
||||
"order",
|
||||
sa.Integer(),
|
||||
nullable=False,
|
||||
server_default="0",
|
||||
),
|
||||
sa.ForeignKeyConstraint(["question_id"], ["poll_questions.id"], ondelete="CASCADE"),
|
||||
)
|
||||
op.create_index("ix_poll_options_id", "poll_options", ["id"])
|
||||
op.create_index("ix_poll_options_question_id", "poll_options", ["question_id"])
|
||||
|
||||
op.create_table(
|
||||
"poll_responses",
|
||||
sa.Column("id", sa.Integer(), primary_key=True),
|
||||
sa.Column("poll_id", sa.Integer(), nullable=False),
|
||||
sa.Column("user_id", sa.Integer(), nullable=False),
|
||||
sa.Column(
|
||||
"sent_at",
|
||||
sa.DateTime(),
|
||||
nullable=False,
|
||||
server_default=sa.func.now(),
|
||||
),
|
||||
sa.Column("started_at", sa.DateTime(), nullable=True),
|
||||
sa.Column("completed_at", sa.DateTime(), nullable=True),
|
||||
sa.Column(
|
||||
"reward_given",
|
||||
sa.Boolean(),
|
||||
nullable=False,
|
||||
server_default=sa.text("false"),
|
||||
),
|
||||
sa.Column(
|
||||
"reward_amount_kopeks",
|
||||
sa.Integer(),
|
||||
nullable=False,
|
||||
server_default="0",
|
||||
),
|
||||
sa.ForeignKeyConstraint(["poll_id"], ["polls.id"], ondelete="CASCADE"),
|
||||
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"),
|
||||
sa.UniqueConstraint("poll_id", "user_id", name="uq_poll_user"),
|
||||
)
|
||||
op.create_index("ix_poll_responses_id", "poll_responses", ["id"])
|
||||
op.create_index("ix_poll_responses_poll_id", "poll_responses", ["poll_id"])
|
||||
op.create_index("ix_poll_responses_user_id", "poll_responses", ["user_id"])
|
||||
|
||||
op.create_table(
|
||||
"poll_answers",
|
||||
sa.Column("id", sa.Integer(), primary_key=True),
|
||||
sa.Column("response_id", sa.Integer(), nullable=False),
|
||||
sa.Column("question_id", sa.Integer(), nullable=False),
|
||||
sa.Column("option_id", sa.Integer(), nullable=False),
|
||||
sa.Column(
|
||||
"created_at",
|
||||
sa.DateTime(),
|
||||
nullable=False,
|
||||
server_default=sa.func.now(),
|
||||
),
|
||||
sa.ForeignKeyConstraint(["option_id"], ["poll_options.id"], ondelete="CASCADE"),
|
||||
sa.ForeignKeyConstraint(["question_id"], ["poll_questions.id"], ondelete="CASCADE"),
|
||||
sa.ForeignKeyConstraint(["response_id"], ["poll_responses.id"], ondelete="CASCADE"),
|
||||
sa.UniqueConstraint("response_id", "question_id", name="uq_poll_answer_unique"),
|
||||
)
|
||||
op.create_index("ix_poll_answers_id", "poll_answers", ["id"])
|
||||
op.create_index("ix_poll_answers_response_id", "poll_answers", ["response_id"])
|
||||
op.create_index("ix_poll_answers_question_id", "poll_answers", ["question_id"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_poll_answers_question_id", table_name="poll_answers")
|
||||
op.drop_index("ix_poll_answers_response_id", table_name="poll_answers")
|
||||
op.drop_index("ix_poll_answers_id", table_name="poll_answers")
|
||||
op.drop_table("poll_answers")
|
||||
|
||||
op.drop_index("ix_poll_responses_user_id", table_name="poll_responses")
|
||||
op.drop_index("ix_poll_responses_poll_id", table_name="poll_responses")
|
||||
op.drop_index("ix_poll_responses_id", table_name="poll_responses")
|
||||
op.drop_table("poll_responses")
|
||||
|
||||
op.drop_index("ix_poll_options_question_id", table_name="poll_options")
|
||||
op.drop_index("ix_poll_options_id", table_name="poll_options")
|
||||
op.drop_table("poll_options")
|
||||
|
||||
op.drop_index("ix_poll_questions_poll_id", table_name="poll_questions")
|
||||
op.drop_index("ix_poll_questions_id", table_name="poll_questions")
|
||||
op.drop_table("poll_questions")
|
||||
|
||||
op.drop_index("ix_polls_id", table_name="polls")
|
||||
op.drop_table("polls")
|
||||
@@ -1,5 +1,7 @@
|
||||
"""Глобальные фикстуры и настройки окружения для тестов."""
|
||||
|
||||
import asyncio
|
||||
import inspect
|
||||
import os
|
||||
import sys
|
||||
import types
|
||||
@@ -149,3 +151,50 @@ if "yookassa" not in sys.modules:
|
||||
def fixed_datetime() -> datetime:
|
||||
"""Возвращает фиксированную отметку времени для воспроизводимых проверок."""
|
||||
return datetime(2024, 1, 1, 12, 0, 0, tzinfo=timezone.utc)
|
||||
|
||||
|
||||
def pytest_configure(config: pytest.Config) -> None:
|
||||
"""Регистрируем маркеры для асинхронных тестов."""
|
||||
|
||||
config.addinivalue_line(
|
||||
"markers",
|
||||
"asyncio: запуск асинхронного теста через встроенный цикл событий",
|
||||
)
|
||||
config.addinivalue_line(
|
||||
"markers",
|
||||
"anyio: запуск асинхронного теста через встроенный цикл событий",
|
||||
)
|
||||
|
||||
|
||||
def _unwrap_test(obj): # noqa: ANN001 - вспомогательная функция для определения coroutine
|
||||
"""Возвращает исходную функцию, снимая обёртки pytest и декораторов."""
|
||||
|
||||
unwrapped = obj
|
||||
while hasattr(unwrapped, "__wrapped__"):
|
||||
unwrapped = unwrapped.__wrapped__
|
||||
return unwrapped
|
||||
|
||||
|
||||
@pytest.hookimpl(tryfirst=True)
|
||||
def pytest_pyfunc_call(pyfuncitem: pytest.Function) -> bool | None:
|
||||
"""Позволяет запускать async def тесты без дополнительных плагинов."""
|
||||
|
||||
test_func = _unwrap_test(pyfuncitem.obj)
|
||||
if not inspect.iscoroutinefunction(test_func):
|
||||
return None
|
||||
|
||||
loop = asyncio.new_event_loop()
|
||||
try:
|
||||
asyncio.set_event_loop(loop)
|
||||
signature = inspect.signature(test_func)
|
||||
call_kwargs = {
|
||||
name: value
|
||||
for name, value in pyfuncitem.funcargs.items()
|
||||
if name in signature.parameters
|
||||
}
|
||||
loop.run_until_complete(pyfuncitem.obj(**call_kwargs))
|
||||
finally:
|
||||
asyncio.set_event_loop(None)
|
||||
loop.close()
|
||||
|
||||
return True
|
||||
|
||||
@@ -2,8 +2,10 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Optional
|
||||
from typing import Any, Dict, Optional, Sequence
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
@@ -16,6 +18,57 @@ from app.config import settings # noqa: E402
|
||||
from app.services.mulenpay_service import MulenPayService # noqa: E402
|
||||
|
||||
|
||||
class _DummyResponse:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
status: int,
|
||||
body: str = "{}",
|
||||
headers: Optional[Dict[str, str]] = None,
|
||||
url: str = "https://mulenpay.test/endpoint",
|
||||
) -> None:
|
||||
self.status = status
|
||||
self._body = body
|
||||
self.headers = headers or {"Content-Type": "application/json"}
|
||||
self.url = url
|
||||
|
||||
async def __aenter__(self) -> "_DummyResponse":
|
||||
return self
|
||||
|
||||
async def __aexit__(self, exc_type, exc, tb) -> bool: # pragma: no cover - interface
|
||||
return False
|
||||
|
||||
async def text(self) -> str:
|
||||
return self._body
|
||||
|
||||
|
||||
class _DummySession:
|
||||
def __init__(self, result: Any) -> None:
|
||||
self._result = result
|
||||
|
||||
async def __aenter__(self) -> "_DummySession":
|
||||
return self
|
||||
|
||||
async def __aexit__(self, exc_type, exc, tb) -> bool: # pragma: no cover - interface
|
||||
return False
|
||||
|
||||
def request(self, *args: Any, **kwargs: Any) -> Any:
|
||||
if isinstance(self._result, BaseException):
|
||||
raise self._result
|
||||
return self._result
|
||||
|
||||
|
||||
def _session_factory(responses: Sequence[Any]) -> Any:
|
||||
call_state = {"index": 0}
|
||||
|
||||
def _factory(*_args: Any, **_kwargs: Any) -> _DummySession:
|
||||
index = min(call_state["index"], len(responses) - 1)
|
||||
call_state["index"] += 1
|
||||
return _DummySession(responses[index])
|
||||
|
||||
return _factory
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def anyio_backend() -> str:
|
||||
return "asyncio"
|
||||
@@ -105,3 +158,88 @@ async def test_get_payment(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr(service, "_request", fake_request, raising=False)
|
||||
result = await service.get_payment(123)
|
||||
assert result == {"id": 123, "status": "paid"}
|
||||
|
||||
|
||||
@pytest.mark.anyio("asyncio")
|
||||
async def test_request_success(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
_enable_service(monkeypatch)
|
||||
service = MulenPayService()
|
||||
|
||||
response_payload = {"ok": True}
|
||||
monkeypatch.setattr(
|
||||
"app.services.mulenpay_service.aiohttp.ClientSession",
|
||||
_session_factory([
|
||||
_DummyResponse(status=200, body=json.dumps(response_payload)),
|
||||
]),
|
||||
)
|
||||
|
||||
result = await service._request("GET", "/ping")
|
||||
assert result == response_payload
|
||||
|
||||
|
||||
@pytest.mark.anyio("asyncio")
|
||||
async def test_request_retries_on_server_error(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
_enable_service(monkeypatch)
|
||||
service = MulenPayService()
|
||||
service._max_retries = 2
|
||||
|
||||
sleep_calls: list[float] = []
|
||||
|
||||
async def fake_sleep(delay: float) -> None:
|
||||
sleep_calls.append(delay)
|
||||
|
||||
monkeypatch.setattr(
|
||||
"app.services.mulenpay_service.asyncio.sleep",
|
||||
fake_sleep,
|
||||
)
|
||||
|
||||
monkeypatch.setattr(
|
||||
"app.services.mulenpay_service.aiohttp.ClientSession",
|
||||
_session_factory(
|
||||
[
|
||||
_DummyResponse(status=502, body="{\"error\": \"bad gateway\"}"),
|
||||
_DummyResponse(status=200, body="{\"ok\": true}"),
|
||||
]
|
||||
),
|
||||
)
|
||||
|
||||
result = await service._request("GET", "/retry")
|
||||
assert result == {"ok": True}
|
||||
assert sleep_calls == [service._retry_delay]
|
||||
|
||||
|
||||
@pytest.mark.anyio("asyncio")
|
||||
async def test_request_returns_none_after_timeouts(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
_enable_service(monkeypatch)
|
||||
service = MulenPayService()
|
||||
service._max_retries = 2
|
||||
|
||||
async def fake_sleep(_delay: float) -> None:
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(
|
||||
"app.services.mulenpay_service.asyncio.sleep",
|
||||
fake_sleep,
|
||||
)
|
||||
|
||||
monkeypatch.setattr(
|
||||
"app.services.mulenpay_service.aiohttp.ClientSession",
|
||||
_session_factory([asyncio.TimeoutError()]),
|
||||
)
|
||||
|
||||
result = await service._request("GET", "/timeout")
|
||||
assert result is None
|
||||
|
||||
|
||||
@pytest.mark.anyio("asyncio")
|
||||
async def test_request_reraises_cancelled(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
_enable_service(monkeypatch)
|
||||
service = MulenPayService()
|
||||
|
||||
monkeypatch.setattr(
|
||||
"app.services.mulenpay_service.aiohttp.ClientSession",
|
||||
_session_factory([asyncio.CancelledError()]),
|
||||
)
|
||||
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
await service._request("GET", "/cancel")
|
||||
|
||||
@@ -42,6 +42,9 @@ class StubPal24Client:
|
||||
async def get_payment_status(self, payment_id: str) -> Dict[str, Any]:
|
||||
return {"id": payment_id, "status": "SUCCESS"}
|
||||
|
||||
async def get_bill_payments(self, bill_id: str) -> Dict[str, Any]:
|
||||
return {"id": bill_id, "payments": [{"id": "PAY-1"}]}
|
||||
|
||||
|
||||
def _enable_pal24(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr(type(settings), "is_pal24_enabled", lambda self: True, raising=False)
|
||||
@@ -77,7 +80,7 @@ async def test_create_bill_success(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
assert client.calls and client.calls[0]["amount"] == Decimal("500.00")
|
||||
assert client.calls[0]["shop_id"] == "shop42"
|
||||
assert client.calls[0]["description"] == "Пополнение"
|
||||
assert client.calls[0]["custom"] == json.dumps({"extra": "value"}, ensure_ascii=False, separators=(",", ":"))
|
||||
assert client.calls[0]["custom"] == {"extra": "value"}
|
||||
assert client.calls[0]["payment_method"] == "BANK_CARD"
|
||||
|
||||
|
||||
@@ -96,6 +99,17 @@ async def test_create_bill_requires_configuration(monkeypatch: pytest.MonkeyPatc
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.anyio("asyncio")
|
||||
async def test_get_bill_payments(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
_enable_pal24(monkeypatch)
|
||||
client = StubPal24Client()
|
||||
service = Pal24Service(client)
|
||||
|
||||
result = await service.get_bill_payments("BILL42")
|
||||
|
||||
assert result == {"id": "BILL42", "payments": [{"id": "PAY-1"}]}
|
||||
|
||||
|
||||
def test_parse_postback_success(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
_enable_pal24(monkeypatch)
|
||||
sig = Pal24Client.calculate_signature("100.00", "INV1", api_token="sigsecret")
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import sys
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
import pytest
|
||||
@@ -39,14 +40,44 @@ class DummyLocalPayment:
|
||||
|
||||
|
||||
class StubHeleketService:
|
||||
def __init__(self, response: Optional[Dict[str, Any]]) -> None:
|
||||
def __init__(
|
||||
self,
|
||||
response: Optional[Dict[str, Any]],
|
||||
*,
|
||||
info_response: Optional[Dict[str, Any]] = None,
|
||||
) -> None:
|
||||
self.response = response
|
||||
self.info_response = info_response
|
||||
self.calls: list[Dict[str, Any]] = []
|
||||
self.info_calls: list[Dict[str, Optional[str]]] = []
|
||||
self.list_response: Optional[Dict[str, Any]] = None
|
||||
self.list_calls: list[Dict[str, Optional[str]]] = []
|
||||
|
||||
async def create_payment(self, payload: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
||||
self.calls.append(payload)
|
||||
return self.response
|
||||
|
||||
async def get_payment_info(
|
||||
self,
|
||||
*,
|
||||
uuid: Optional[str] = None,
|
||||
order_id: Optional[str] = None,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
self.info_calls.append({"uuid": uuid, "order_id": order_id})
|
||||
return self.info_response
|
||||
|
||||
async def list_payments(
|
||||
self,
|
||||
*,
|
||||
date_from: Optional[str] = None,
|
||||
date_to: Optional[str] = None,
|
||||
cursor: Optional[str] = None,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
self.list_calls.append(
|
||||
{"date_from": date_from, "date_to": date_to, "cursor": cursor}
|
||||
)
|
||||
return self.list_response
|
||||
|
||||
|
||||
def _make_service(stub: Optional[StubHeleketService]) -> PaymentService:
|
||||
service = PaymentService.__new__(PaymentService) # type: ignore[call-arg]
|
||||
@@ -156,3 +187,129 @@ async def test_create_heleket_payment_handles_empty_response(monkeypatch: pytest
|
||||
|
||||
assert result is None
|
||||
assert called is False
|
||||
|
||||
|
||||
@pytest.mark.anyio("asyncio")
|
||||
async def test_sync_heleket_payment_status_success(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
info_response = {
|
||||
"state": 0,
|
||||
"result": {
|
||||
"uuid": "heleket-uuid",
|
||||
"order_id": "order-123",
|
||||
"status": "paid",
|
||||
"payment_amount": "100.00",
|
||||
},
|
||||
}
|
||||
stub = StubHeleketService(response=None, info_response=info_response)
|
||||
service = _make_service(stub)
|
||||
db = DummySession()
|
||||
|
||||
payment = SimpleNamespace(
|
||||
id=55,
|
||||
uuid="heleket-uuid",
|
||||
order_id="order-123",
|
||||
status="check",
|
||||
user_id=7,
|
||||
)
|
||||
|
||||
async def fake_get_by_id(db, payment_id):
|
||||
assert payment_id == payment.id
|
||||
return payment
|
||||
|
||||
captured: Dict[str, Any] = {}
|
||||
|
||||
async def fake_process(self, db, payload, *, metadata_key):
|
||||
captured["payload"] = payload
|
||||
captured["metadata_key"] = metadata_key
|
||||
return SimpleNamespace(transaction_id=999, **payload)
|
||||
|
||||
monkeypatch.setattr(heleket_crud, "get_heleket_payment_by_id", fake_get_by_id, raising=False)
|
||||
monkeypatch.setattr(PaymentService, "_process_heleket_payload", fake_process, raising=False)
|
||||
|
||||
result = await service.sync_heleket_payment_status(db, local_payment_id=payment.id)
|
||||
|
||||
assert result is not None
|
||||
assert result.transaction_id == 999
|
||||
assert captured["metadata_key"] == "last_status_check"
|
||||
assert captured["payload"]["uuid"] == payment.uuid
|
||||
assert stub.info_calls == [{"uuid": payment.uuid, "order_id": payment.order_id}]
|
||||
|
||||
|
||||
@pytest.mark.anyio("asyncio")
|
||||
async def test_sync_heleket_payment_status_without_response(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
stub = StubHeleketService(response=None, info_response=None)
|
||||
service = _make_service(stub)
|
||||
db = DummySession()
|
||||
|
||||
payment = SimpleNamespace(
|
||||
id=12,
|
||||
uuid="heleket-uuid",
|
||||
order_id="order-123",
|
||||
status="check",
|
||||
user_id=5,
|
||||
)
|
||||
|
||||
async def fake_get_by_id(db, payment_id):
|
||||
assert payment_id == payment.id
|
||||
return payment
|
||||
|
||||
async def fake_process(*args, **kwargs): # pragma: no cover - ensure not called
|
||||
raise AssertionError("_process_heleket_payload should not be called")
|
||||
|
||||
monkeypatch.setattr(heleket_crud, "get_heleket_payment_by_id", fake_get_by_id, raising=False)
|
||||
monkeypatch.setattr(PaymentService, "_process_heleket_payload", fake_process, raising=False)
|
||||
|
||||
result = await service.sync_heleket_payment_status(db, local_payment_id=payment.id)
|
||||
|
||||
assert result is payment
|
||||
assert stub.info_calls == [{"uuid": payment.uuid, "order_id": payment.order_id}]
|
||||
assert stub.list_calls # fallback to history should be attempted
|
||||
|
||||
|
||||
@pytest.mark.anyio("asyncio")
|
||||
async def test_sync_heleket_payment_status_history_fallback(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
stub = StubHeleketService(response=None, info_response=None)
|
||||
stub.list_response = {
|
||||
"state": 0,
|
||||
"result": {
|
||||
"items": [
|
||||
{
|
||||
"uuid": "heleket-uuid",
|
||||
"order_id": "order-123",
|
||||
"status": "paid",
|
||||
"payment_amount": "150.00",
|
||||
}
|
||||
],
|
||||
"paginate": {"nextCursor": None},
|
||||
},
|
||||
}
|
||||
service = _make_service(stub)
|
||||
db = DummySession()
|
||||
|
||||
payment = SimpleNamespace(
|
||||
id=77,
|
||||
uuid="heleket-uuid",
|
||||
order_id="order-123",
|
||||
status="check",
|
||||
user_id=8,
|
||||
)
|
||||
|
||||
async def fake_get_by_id(db, payment_id):
|
||||
assert payment_id == payment.id
|
||||
return payment
|
||||
|
||||
captured: Dict[str, Any] = {}
|
||||
|
||||
async def fake_process(self, db, payload, *, metadata_key):
|
||||
captured["payload"] = payload
|
||||
captured["metadata_key"] = metadata_key
|
||||
return SimpleNamespace(**payload)
|
||||
|
||||
monkeypatch.setattr(heleket_crud, "get_heleket_payment_by_id", fake_get_by_id, raising=False)
|
||||
monkeypatch.setattr(PaymentService, "_process_heleket_payload", fake_process, raising=False)
|
||||
|
||||
result = await service.sync_heleket_payment_status(db, local_payment_id=payment.id)
|
||||
|
||||
assert result is not None
|
||||
assert captured["payload"]["status"] == "paid"
|
||||
assert stub.list_calls
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Optional
|
||||
from types import ModuleType, SimpleNamespace
|
||||
import sys
|
||||
from datetime import datetime
|
||||
|
||||
@@ -25,6 +26,9 @@ class DummySession:
|
||||
async def commit(self) -> None: # pragma: no cover - метод вызывается, но без логики
|
||||
return None
|
||||
|
||||
async def refresh(self, *_args: Any, **_kwargs: Any) -> None:
|
||||
return None
|
||||
|
||||
|
||||
class DummyLocalPayment:
|
||||
def __init__(self, payment_id: int = 501) -> None:
|
||||
@@ -139,3 +143,163 @@ async def test_create_mulenpay_payment_returns_none_without_service() -> None:
|
||||
description="Пополнение",
|
||||
)
|
||||
assert result is None
|
||||
|
||||
|
||||
@pytest.mark.anyio("asyncio")
|
||||
async def test_process_mulenpay_callback_avoids_duplicate_transactions(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
service = _make_service(None)
|
||||
db = DummySession()
|
||||
|
||||
class DummyPayment:
|
||||
def __init__(self) -> None:
|
||||
self.user_id = 42
|
||||
self.amount_kopeks = 1500
|
||||
self.description = "Пополнение"
|
||||
self.uuid = "mulen_1_test"
|
||||
self.transaction_id: Optional[int] = None
|
||||
self.mulen_payment_id: Optional[int] = None
|
||||
self.status = "created"
|
||||
self.is_paid = False
|
||||
|
||||
payment = DummyPayment()
|
||||
|
||||
async def fake_get_mulenpay_payment_by_uuid(
|
||||
_db: DummySession, uuid: str
|
||||
) -> DummyPayment:
|
||||
assert uuid == payment.uuid
|
||||
return payment
|
||||
|
||||
async def fake_update_mulenpay_payment_status(
|
||||
_db: DummySession, **kwargs: Any
|
||||
) -> DummyPayment:
|
||||
payment.status = kwargs.get("status", payment.status)
|
||||
payment.mulen_payment_id = kwargs.get("mulen_payment_id", payment.mulen_payment_id)
|
||||
return payment
|
||||
|
||||
transaction_calls: list[Dict[str, Any]] = []
|
||||
|
||||
class DummyTransaction:
|
||||
def __init__(self, transaction_id: int = 555) -> None:
|
||||
self.id = transaction_id
|
||||
|
||||
async def fake_create_transaction(_db: DummySession, **kwargs: Any) -> DummyTransaction:
|
||||
transaction_calls.append(kwargs)
|
||||
return DummyTransaction()
|
||||
|
||||
async def fake_link_payment(
|
||||
db: DummySession, *, payment: DummyPayment, transaction_id: int
|
||||
) -> DummyPayment:
|
||||
payment.transaction_id = transaction_id
|
||||
return payment
|
||||
|
||||
class DummyUser:
|
||||
def __init__(self) -> None:
|
||||
self.id = payment.user_id
|
||||
self.telegram_id = 99
|
||||
self.balance_kopeks = 0
|
||||
self.has_made_first_topup = False
|
||||
self.language = "ru"
|
||||
self.promo_group = None
|
||||
self.subscription = None
|
||||
|
||||
dummy_user = DummyUser()
|
||||
|
||||
async def fake_get_user_by_id(_db: DummySession, user_id: int) -> DummyUser:
|
||||
assert user_id == payment.user_id
|
||||
return dummy_user
|
||||
|
||||
balance_call: Dict[str, Any] = {}
|
||||
|
||||
async def fake_add_user_balance(
|
||||
_db: DummySession,
|
||||
user: DummyUser,
|
||||
amount_kopeks: int,
|
||||
description: str,
|
||||
*,
|
||||
create_transaction: bool = True,
|
||||
**_kwargs: Any,
|
||||
) -> bool:
|
||||
balance_call.update(
|
||||
{
|
||||
"create_transaction": create_transaction,
|
||||
"description": description,
|
||||
"amount_kopeks": amount_kopeks,
|
||||
}
|
||||
)
|
||||
user.balance_kopeks += amount_kopeks
|
||||
return True
|
||||
|
||||
async def fake_process_referral_topup(*_args: Any, **_kwargs: Any) -> None:
|
||||
return None
|
||||
|
||||
async def fake_auto_purchase_saved_cart_after_topup(*_args: Any, **_kwargs: Any) -> bool:
|
||||
return False
|
||||
|
||||
async def fake_has_user_cart(*_args: Any, **_kwargs: Any) -> bool:
|
||||
return False
|
||||
|
||||
referral_module = ModuleType("app.services.referral_service")
|
||||
referral_module.process_referral_topup = fake_process_referral_topup # type: ignore[attr-defined]
|
||||
monkeypatch.setitem(sys.modules, "app.services.referral_service", referral_module)
|
||||
|
||||
auto_module = ModuleType("app.services.subscription_auto_purchase_service")
|
||||
auto_module.auto_purchase_saved_cart_after_topup = ( # type: ignore[attr-defined]
|
||||
fake_auto_purchase_saved_cart_after_topup
|
||||
)
|
||||
monkeypatch.setitem(sys.modules, "app.services.subscription_auto_purchase_service", auto_module)
|
||||
|
||||
user_cart_module = ModuleType("app.services.user_cart_service")
|
||||
user_cart_module.user_cart_service = SimpleNamespace( # type: ignore[attr-defined]
|
||||
has_user_cart=fake_has_user_cart
|
||||
)
|
||||
monkeypatch.setitem(sys.modules, "app.services.user_cart_service", user_cart_module)
|
||||
|
||||
monkeypatch.setattr(
|
||||
payment_service_module,
|
||||
"get_mulenpay_payment_by_uuid",
|
||||
fake_get_mulenpay_payment_by_uuid,
|
||||
raising=False,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
payment_service_module,
|
||||
"update_mulenpay_payment_status",
|
||||
fake_update_mulenpay_payment_status,
|
||||
raising=False,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
payment_service_module,
|
||||
"create_transaction",
|
||||
fake_create_transaction,
|
||||
raising=False,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
payment_service_module,
|
||||
"link_mulenpay_payment_to_transaction",
|
||||
fake_link_payment,
|
||||
raising=False,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
payment_service_module,
|
||||
"get_user_by_id",
|
||||
fake_get_user_by_id,
|
||||
raising=False,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
payment_service_module,
|
||||
"add_user_balance",
|
||||
fake_add_user_balance,
|
||||
raising=False,
|
||||
)
|
||||
|
||||
result = await service.process_mulenpay_callback(
|
||||
db,
|
||||
{"uuid": payment.uuid, "payment_status": "success", "id": 123, "amount": 1500},
|
||||
)
|
||||
|
||||
assert result is True
|
||||
assert transaction_calls, "create_transaction should be called"
|
||||
assert balance_call["create_transaction"] is False
|
||||
assert dummy_user.balance_kopeks == payment.amount_kopeks
|
||||
assert payment.transaction_id is not None
|
||||
|
||||
@@ -4,6 +4,7 @@ from pathlib import Path
|
||||
from typing import Any, Dict, Optional
|
||||
import sys
|
||||
from datetime import datetime
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -34,7 +35,12 @@ class DummyLocalPayment:
|
||||
|
||||
|
||||
class StubPal24Service:
|
||||
def __init__(self, *, configured: bool = True, response: Optional[Dict[str, Any]] = None) -> None:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
configured: bool = True,
|
||||
response: Optional[Dict[str, Any]] = None,
|
||||
) -> None:
|
||||
self.is_configured = configured
|
||||
self.response = response or {
|
||||
"success": True,
|
||||
@@ -45,6 +51,12 @@ class StubPal24Service:
|
||||
}
|
||||
self.calls: list[Dict[str, Any]] = []
|
||||
self.raise_error: Optional[Exception] = None
|
||||
self.status_response: Optional[Dict[str, Any]] = {"status": "NEW"}
|
||||
self.payment_status_response: Optional[Dict[str, Any]] = None
|
||||
self.bill_payments_response: Optional[Dict[str, Any]] = None
|
||||
self.status_calls: list[str] = []
|
||||
self.payment_status_calls: list[str] = []
|
||||
self.bill_payments_calls: list[str] = []
|
||||
|
||||
async def create_bill(self, **kwargs: Any) -> Dict[str, Any]:
|
||||
self.calls.append(kwargs)
|
||||
@@ -52,6 +64,18 @@ class StubPal24Service:
|
||||
raise self.raise_error
|
||||
return self.response
|
||||
|
||||
async def get_bill_status(self, bill_id: str) -> Optional[Dict[str, Any]]:
|
||||
self.status_calls.append(bill_id)
|
||||
return self.status_response
|
||||
|
||||
async def get_payment_status(self, payment_id: str) -> Optional[Dict[str, Any]]:
|
||||
self.payment_status_calls.append(payment_id)
|
||||
return self.payment_status_response
|
||||
|
||||
async def get_bill_payments(self, bill_id: str) -> Optional[Dict[str, Any]]:
|
||||
self.bill_payments_calls.append(bill_id)
|
||||
return self.bill_payments_response
|
||||
|
||||
|
||||
def _make_service(stub: Optional[StubPal24Service]) -> PaymentService:
|
||||
service = PaymentService.__new__(PaymentService) # type: ignore[call-arg]
|
||||
@@ -198,3 +222,110 @@ async def test_create_pal24_payment_handles_api_errors(monkeypatch: pytest.Monke
|
||||
language="ru",
|
||||
)
|
||||
assert result is None
|
||||
|
||||
|
||||
@pytest.mark.anyio("asyncio")
|
||||
async def test_get_pal24_payment_status_updates_from_remote(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
stub = StubPal24Service()
|
||||
stub.status_response = {"status": "SUCCESS"}
|
||||
stub.payment_status_response = {
|
||||
"success": True,
|
||||
"id": "PAY-1",
|
||||
"bill_id": "BILL-1",
|
||||
"status": "SUCCESS",
|
||||
"payment_method": "SBP",
|
||||
"account_amount": "700.00",
|
||||
"from_card": "676754******1234",
|
||||
}
|
||||
stub.bill_payments_response = {
|
||||
"data": [
|
||||
{
|
||||
"id": "PAY-1",
|
||||
"bill_id": "BILL-1",
|
||||
"status": "SUCCESS",
|
||||
"from_card": "676754******1234",
|
||||
"payment_method": "SBP",
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
service = _make_service(stub)
|
||||
db = DummySession()
|
||||
|
||||
payment = SimpleNamespace(
|
||||
id=99,
|
||||
bill_id="BILL-1",
|
||||
payment_id=None,
|
||||
payment_status="NEW",
|
||||
payment_method=None,
|
||||
balance_amount=None,
|
||||
balance_currency=None,
|
||||
payer_account=None,
|
||||
status="NEW",
|
||||
is_paid=False,
|
||||
paid_at=None,
|
||||
transaction_id=None,
|
||||
user_id=1,
|
||||
)
|
||||
|
||||
async def fake_get_by_id(db: DummySession, payment_id: int) -> SimpleNamespace:
|
||||
assert payment_id == payment.id
|
||||
return payment
|
||||
|
||||
async def fake_update_status(
|
||||
db: DummySession,
|
||||
payment_obj: SimpleNamespace,
|
||||
*,
|
||||
status: str,
|
||||
**kwargs: Any,
|
||||
) -> SimpleNamespace:
|
||||
payment_obj.status = status
|
||||
payment_obj.last_status = status
|
||||
for key, value in kwargs.items():
|
||||
setattr(payment_obj, key, value)
|
||||
if "is_paid" in kwargs:
|
||||
payment_obj.is_paid = kwargs["is_paid"]
|
||||
await db.commit()
|
||||
return payment_obj
|
||||
|
||||
async def fake_finalize(
|
||||
self: PaymentService,
|
||||
db: DummySession,
|
||||
payment_obj: Any,
|
||||
*,
|
||||
payment_id: Optional[str] = None,
|
||||
trigger: str,
|
||||
) -> bool:
|
||||
return False
|
||||
|
||||
monkeypatch.setattr(
|
||||
payment_service_module,
|
||||
"get_pal24_payment_by_id",
|
||||
fake_get_by_id,
|
||||
raising=False,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
payment_service_module,
|
||||
"update_pal24_payment_status",
|
||||
fake_update_status,
|
||||
raising=False,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
PaymentService,
|
||||
"_finalize_pal24_payment",
|
||||
fake_finalize,
|
||||
raising=False,
|
||||
)
|
||||
|
||||
result = await service.get_pal24_payment_status(db, local_payment_id=payment.id)
|
||||
|
||||
assert result is not None
|
||||
assert payment.status == "SUCCESS"
|
||||
assert payment.payment_id == "PAY-1"
|
||||
assert payment.payment_status == "SUCCESS"
|
||||
assert payment.payment_method == "sbp"
|
||||
assert payment.is_paid is True
|
||||
assert stub.status_calls == ["BILL-1"]
|
||||
assert stub.payment_status_calls in ([], ["PAY-1"])
|
||||
assert result["remote_status"] == "SUCCESS"
|
||||
assert result["remote_data"] and "bill_status" in result["remote_data"]
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
"""Тесты для Telegram Stars-сценариев внутри PaymentService."""
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
from decimal import Decimal
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Optional
|
||||
import sys
|
||||
@@ -25,12 +27,17 @@ class DummyBot:
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.calls: list[Dict[str, Any]] = []
|
||||
self.sent_messages: list[Dict[str, Any]] = []
|
||||
|
||||
async def create_invoice_link(self, **kwargs: Any) -> str:
|
||||
"""Эмулируем создание платежной ссылки и сохраняем параметры вызова."""
|
||||
self.calls.append(kwargs)
|
||||
return "https://t.me/invoice/stars"
|
||||
|
||||
async def send_message(self, **kwargs: Any) -> None:
|
||||
"""Фиксируем отправленные сообщения пользователю."""
|
||||
self.sent_messages.append(kwargs)
|
||||
|
||||
|
||||
def _make_service(bot: Optional[DummyBot]) -> PaymentService:
|
||||
"""Создаёт экземпляр PaymentService без выполнения полного конструктора."""
|
||||
@@ -41,6 +48,80 @@ def _make_service(bot: Optional[DummyBot]) -> PaymentService:
|
||||
return service
|
||||
|
||||
|
||||
class DummySession:
|
||||
"""Минимальная заглушка AsyncSession для проверки сценариев Stars."""
|
||||
|
||||
def __init__(self, pending_subscription: "DummySubscription") -> None:
|
||||
self.pending_subscription = pending_subscription
|
||||
self.commits: int = 0
|
||||
self.refreshed: list[Any] = []
|
||||
|
||||
async def execute(self, *_args: Any, **_kwargs: Any) -> Any:
|
||||
class _Result:
|
||||
def __init__(self, subscription: "DummySubscription") -> None:
|
||||
self._subscription = subscription
|
||||
|
||||
def scalar_one_or_none(self) -> "DummySubscription":
|
||||
return self._subscription
|
||||
|
||||
return _Result(self.pending_subscription)
|
||||
|
||||
async def commit(self) -> None:
|
||||
self.commits += 1
|
||||
|
||||
async def refresh(self, obj: Any) -> None:
|
||||
self.refreshed.append(obj)
|
||||
|
||||
|
||||
class DummySubscription:
|
||||
"""Упрощённая модель подписки для тестов."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
subscription_id: int,
|
||||
*,
|
||||
traffic_limit_gb: int = 0,
|
||||
device_limit: int = 1,
|
||||
period_days: int = 30,
|
||||
) -> None:
|
||||
self.id = subscription_id
|
||||
self.traffic_limit_gb = traffic_limit_gb
|
||||
self.device_limit = device_limit
|
||||
self.status = "pending"
|
||||
self.start_date = datetime(2024, 1, 1)
|
||||
self.end_date = self.start_date + timedelta(days=period_days)
|
||||
|
||||
|
||||
class DummyUser:
|
||||
"""Минимальные данные пользователя для тестов Stars-покупки."""
|
||||
|
||||
def __init__(self, user_id: int = 501, telegram_id: int = 777) -> None:
|
||||
self.id = user_id
|
||||
self.telegram_id = telegram_id
|
||||
self.language = "ru"
|
||||
self.balance_kopeks = 0
|
||||
self.has_made_first_topup = False
|
||||
self.promo_group = None
|
||||
self.subscription = None
|
||||
|
||||
|
||||
class DummyTransaction:
|
||||
"""Локальная транзакция, созданная в тестах."""
|
||||
|
||||
def __init__(self, external_id: str) -> None:
|
||||
self.external_id = external_id
|
||||
|
||||
|
||||
class DummySubscriptionService:
|
||||
"""Заглушка SubscriptionService, запоминающая вызовы."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.calls: list[tuple[Any, Any]] = []
|
||||
|
||||
async def create_remnawave_user(self, db: Any, subscription: Any) -> object:
|
||||
self.calls.append((db, subscription))
|
||||
return object()
|
||||
|
||||
@pytest.mark.anyio("asyncio")
|
||||
async def test_create_stars_invoice_calculates_stars(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Количество звёзд должно рассчитываться по курсу с округлением вниз и нижним порогом 1."""
|
||||
@@ -140,3 +221,127 @@ async def test_create_stars_invoice_requires_bot() -> None:
|
||||
amount_kopeks=1000,
|
||||
description="Пополнение",
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.anyio("asyncio")
|
||||
async def test_process_stars_payment_simple_subscription_success(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Оплата простой подписки через Stars активирует pending подписку и уведомляет пользователя."""
|
||||
|
||||
bot = DummyBot()
|
||||
service = _make_service(bot)
|
||||
|
||||
pending_subscription = DummySubscription(subscription_id=321, device_limit=2)
|
||||
db = DummySession(pending_subscription)
|
||||
user = DummyUser(user_id=900, telegram_id=123456)
|
||||
activated_subscription = DummySubscription(subscription_id=321, device_limit=2)
|
||||
|
||||
transaction_holder: Dict[str, DummyTransaction] = {}
|
||||
|
||||
async def fake_create_transaction(**kwargs: Any) -> DummyTransaction:
|
||||
transaction = DummyTransaction(external_id=kwargs.get("external_id", ""))
|
||||
transaction_holder["value"] = transaction
|
||||
return transaction
|
||||
|
||||
async def fake_get_user_by_id(_db: Any, _user_id: int) -> DummyUser:
|
||||
return user
|
||||
|
||||
async def fake_activate_pending_subscription(
|
||||
db: Any,
|
||||
user_id: int,
|
||||
period_days: Optional[int] = None,
|
||||
) -> DummySubscription:
|
||||
activated_subscription.start_date = pending_subscription.start_date
|
||||
activated_subscription.end_date = activated_subscription.start_date + timedelta(
|
||||
days=period_days or 30
|
||||
)
|
||||
return activated_subscription
|
||||
|
||||
subscription_service_stub = DummySubscriptionService()
|
||||
admin_calls: list[Dict[str, Any]] = []
|
||||
|
||||
class AdminNotificationStub:
|
||||
def __init__(self, _bot: Any) -> None:
|
||||
self.bot = _bot
|
||||
|
||||
async def send_subscription_purchase_notification(
|
||||
self,
|
||||
db: Any,
|
||||
user_obj: Any,
|
||||
subscription: Any,
|
||||
transaction: Any,
|
||||
period_days: int,
|
||||
was_trial_conversion: bool,
|
||||
) -> None:
|
||||
admin_calls.append(
|
||||
{
|
||||
"user": user_obj,
|
||||
"subscription": subscription,
|
||||
"transaction": transaction,
|
||||
"period": period_days,
|
||||
"was_trial": was_trial_conversion,
|
||||
}
|
||||
)
|
||||
|
||||
monkeypatch.setattr(
|
||||
"app.services.payment.stars.create_transaction",
|
||||
fake_create_transaction,
|
||||
raising=False,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"app.services.payment.stars.get_user_by_id",
|
||||
fake_get_user_by_id,
|
||||
raising=False,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"app.database.crud.subscription.activate_pending_subscription",
|
||||
fake_activate_pending_subscription,
|
||||
raising=False,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"app.services.subscription_service.SubscriptionService",
|
||||
lambda: subscription_service_stub,
|
||||
raising=False,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"app.services.admin_notification_service.AdminNotificationService",
|
||||
AdminNotificationStub,
|
||||
raising=False,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
type(settings),
|
||||
"format_price",
|
||||
lambda self, amount: f"{amount / 100:.0f}₽",
|
||||
raising=False,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
settings,
|
||||
"SIMPLE_SUBSCRIPTION_PERIOD_DAYS",
|
||||
30,
|
||||
raising=False,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"app.services.payment.stars.TelegramStarsService.calculate_rubles_from_stars",
|
||||
lambda stars: Decimal("100"),
|
||||
raising=False,
|
||||
)
|
||||
|
||||
payload = f"simple_sub_{user.id}_{pending_subscription.id}_30"
|
||||
result = await service.process_stars_payment(
|
||||
db=db,
|
||||
user_id=user.id,
|
||||
stars_amount=5,
|
||||
payload=payload,
|
||||
telegram_payment_charge_id="charge12345",
|
||||
)
|
||||
|
||||
assert result is True
|
||||
assert user.balance_kopeks == 0, "Баланс не должен меняться при оплате подписки"
|
||||
assert subscription_service_stub.calls == [(db, activated_subscription)]
|
||||
assert len(admin_calls) == 1
|
||||
assert admin_calls[0]["subscription"] is activated_subscription
|
||||
assert admin_calls[0]["period"] == 30
|
||||
assert bot.sent_messages, "Пользователь должен получить уведомление"
|
||||
assert "Подписка успешно активирована" in bot.sent_messages[0]["text"]
|
||||
assert transaction_holder["value"].external_id == "charge12345"
|
||||
|
||||
@@ -6,7 +6,7 @@ from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace, ModuleType
|
||||
from typing import Any, Dict
|
||||
from typing import Any, Dict, Optional
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
@@ -30,11 +30,81 @@ class DummyBot:
|
||||
self.sent_messages.append({"args": args, "kwargs": kwargs})
|
||||
|
||||
|
||||
class FakeScalarResult:
|
||||
def __init__(self, items: list[Any]) -> None:
|
||||
self._items = list(items)
|
||||
|
||||
def all(self) -> list[Any]: # pragma: no cover - утилитарный метод
|
||||
return list(self._items)
|
||||
|
||||
def first(self) -> Any: # pragma: no cover - утилитарный метод
|
||||
return self._items[0] if self._items else None
|
||||
|
||||
def one(self) -> Any: # pragma: no cover - утилитарный метод
|
||||
if len(self._items) != 1:
|
||||
raise ValueError("Expected exactly one result")
|
||||
return self._items[0]
|
||||
|
||||
def one_or_none(self) -> Any: # pragma: no cover - утилитарный метод
|
||||
if not self._items:
|
||||
return None
|
||||
if len(self._items) > 1:
|
||||
raise ValueError("Expected zero or one result")
|
||||
return self._items[0]
|
||||
|
||||
def __iter__(self): # pragma: no cover - утилитарный метод
|
||||
return iter(self._items)
|
||||
|
||||
|
||||
class FakeResult:
|
||||
def __init__(self, value: Any = None) -> None:
|
||||
self._value = value
|
||||
|
||||
def _as_iterable(self) -> list[Any]:
|
||||
if isinstance(self._value, list):
|
||||
return self._value
|
||||
if self._value is None:
|
||||
return []
|
||||
return [self._value]
|
||||
|
||||
def scalar(self) -> Any:
|
||||
items = self._as_iterable()
|
||||
return items[0] if items else None
|
||||
|
||||
def scalar_one_or_none(self) -> Any:
|
||||
items = self._as_iterable()
|
||||
if not items:
|
||||
return None
|
||||
if len(items) > 1:
|
||||
raise ValueError("Expected zero or one result")
|
||||
return items[0]
|
||||
|
||||
def first(self) -> Any: # pragma: no cover - утилитарный метод
|
||||
items = self._as_iterable()
|
||||
return items[0] if items else None
|
||||
|
||||
def all(self) -> list[Any]: # pragma: no cover - утилитарный метод
|
||||
return list(self._as_iterable())
|
||||
|
||||
def one_or_none(self) -> Any: # pragma: no cover - утилитарный метод
|
||||
items = self._as_iterable()
|
||||
if not items:
|
||||
return None
|
||||
if len(items) > 1:
|
||||
raise ValueError("Expected zero or one result")
|
||||
return items[0]
|
||||
|
||||
def scalars(self) -> FakeScalarResult: # pragma: no cover - утилитарный метод
|
||||
return FakeScalarResult(self._as_iterable())
|
||||
|
||||
|
||||
class FakeSession:
|
||||
def __init__(self) -> None:
|
||||
self.commits = 0
|
||||
self.refreshed: list[Any] = []
|
||||
self.added: list[Any] = []
|
||||
self.execute_statements: list[Any] = []
|
||||
self.execute_results: list[Any] = []
|
||||
|
||||
async def commit(self) -> None:
|
||||
self.commits += 1
|
||||
@@ -48,6 +118,20 @@ class FakeSession:
|
||||
def add(self, obj: Any) -> None: # pragma: no cover - используется при создании транзакций
|
||||
self.added.append(obj)
|
||||
|
||||
async def execute(self, statement: Any, *args: Any, **kwargs: Any) -> FakeResult:
|
||||
self.execute_statements.append(statement)
|
||||
if self.execute_results:
|
||||
result = self.execute_results.pop(0)
|
||||
if callable(result): # pragma: no cover - гибкость для будущих тестов
|
||||
result = result(statement, *args, **kwargs)
|
||||
else:
|
||||
result = None
|
||||
|
||||
if isinstance(result, FakeResult):
|
||||
return result
|
||||
|
||||
return FakeResult(result)
|
||||
|
||||
|
||||
def _make_service(bot: DummyBot) -> PaymentService:
|
||||
service = PaymentService.__new__(PaymentService) # type: ignore[call-arg]
|
||||
@@ -490,11 +574,172 @@ async def test_process_yookassa_webhook_success(monkeypatch: pytest.MonkeyPatch)
|
||||
assert result is True
|
||||
assert transactions and transactions[0]["amount_kopeks"] == 10000
|
||||
assert payment.transaction_id == 999
|
||||
assert payment.is_paid is True
|
||||
assert user.balance_kopeks == 10000
|
||||
assert bot.sent_messages
|
||||
assert admin_calls
|
||||
|
||||
|
||||
@pytest.mark.anyio("asyncio")
|
||||
async def test_process_yookassa_webhook_restores_missing_payment(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
bot = DummyBot()
|
||||
service = _make_service(bot)
|
||||
fake_session = FakeSession()
|
||||
|
||||
restored_payment = SimpleNamespace(
|
||||
yookassa_payment_id="yk_456",
|
||||
user_id=21,
|
||||
amount_kopeks=0,
|
||||
status="pending",
|
||||
is_paid=False,
|
||||
transaction_id=None,
|
||||
description="",
|
||||
payment_method_type=None,
|
||||
confirmation_url=None,
|
||||
metadata_json=None,
|
||||
test_mode=False,
|
||||
refundable=False,
|
||||
)
|
||||
|
||||
get_calls = {"count": 0}
|
||||
|
||||
async def fake_get_payment(db, payment_id):
|
||||
get_calls["count"] += 1
|
||||
if get_calls["count"] == 1:
|
||||
return None
|
||||
return restored_payment
|
||||
|
||||
async def fake_create_payment(**kwargs: Any):
|
||||
restored_payment.user_id = kwargs["user_id"]
|
||||
restored_payment.amount_kopeks = kwargs["amount_kopeks"]
|
||||
restored_payment.status = kwargs["status"]
|
||||
restored_payment.description = kwargs["description"]
|
||||
restored_payment.payment_method_type = kwargs["payment_method_type"]
|
||||
restored_payment.confirmation_url = kwargs["confirmation_url"]
|
||||
restored_payment.metadata_json = kwargs["metadata_json"]
|
||||
restored_payment.test_mode = kwargs["test_mode"]
|
||||
restored_payment.yookassa_payment_id = kwargs["yookassa_payment_id"]
|
||||
restored_payment.yookassa_created_at = kwargs["yookassa_created_at"]
|
||||
return restored_payment
|
||||
|
||||
async def fake_update_status(
|
||||
db,
|
||||
yookassa_payment_id,
|
||||
status,
|
||||
is_paid,
|
||||
is_captured,
|
||||
captured_at,
|
||||
payment_method_type,
|
||||
):
|
||||
restored_payment.status = status
|
||||
restored_payment.is_paid = is_paid
|
||||
restored_payment.is_captured = is_captured
|
||||
restored_payment.captured_at = captured_at
|
||||
restored_payment.payment_method_type = payment_method_type
|
||||
return restored_payment
|
||||
|
||||
async def fake_link(db, yookassa_payment_id, transaction_id):
|
||||
restored_payment.transaction_id = transaction_id
|
||||
|
||||
monkeypatch.setattr(payment_service_module, "get_yookassa_payment_by_id", fake_get_payment)
|
||||
monkeypatch.setattr(payment_service_module, "create_yookassa_payment", fake_create_payment)
|
||||
monkeypatch.setattr(payment_service_module, "update_yookassa_payment_status", fake_update_status)
|
||||
monkeypatch.setattr(payment_service_module, "link_yookassa_payment_to_transaction", fake_link)
|
||||
|
||||
transactions: list[Dict[str, Any]] = []
|
||||
|
||||
async def fake_create_transaction(db, **kwargs):
|
||||
transactions.append(kwargs)
|
||||
return SimpleNamespace(id=555, **kwargs)
|
||||
|
||||
monkeypatch.setattr(payment_service_module, "create_transaction", fake_create_transaction)
|
||||
|
||||
user = SimpleNamespace(
|
||||
id=21,
|
||||
telegram_id=2100,
|
||||
balance_kopeks=0,
|
||||
has_made_first_topup=False,
|
||||
promo_group=None,
|
||||
subscription=None,
|
||||
referred_by_id=None,
|
||||
referrer=None,
|
||||
)
|
||||
|
||||
async def fake_get_user(db, user_id):
|
||||
return user
|
||||
|
||||
monkeypatch.setattr(payment_service_module, "get_user_by_id", fake_get_user)
|
||||
monkeypatch.setattr(type(settings), "format_price", lambda self, amount: f"{amount / 100:.2f}₽", raising=False)
|
||||
|
||||
referral_mock = SimpleNamespace(process_referral_topup=AsyncMock())
|
||||
monkeypatch.setitem(sys.modules, "app.services.referral_service", referral_mock)
|
||||
|
||||
admin_calls: list[Any] = []
|
||||
|
||||
class DummyAdminService:
|
||||
def __init__(self, bot):
|
||||
self.bot = bot
|
||||
|
||||
async def send_balance_topup_notification(self, *args, **kwargs):
|
||||
admin_calls.append((args, kwargs))
|
||||
|
||||
monkeypatch.setitem(sys.modules, "app.services.admin_notification_service", SimpleNamespace(AdminNotificationService=lambda bot: DummyAdminService(bot)))
|
||||
service.build_topup_success_keyboard = AsyncMock(return_value=None)
|
||||
|
||||
payload = {
|
||||
"object": {
|
||||
"id": "yk_456",
|
||||
"status": "succeeded",
|
||||
"paid": True,
|
||||
"amount": {"value": "150.00", "currency": "RUB"},
|
||||
"metadata": {"user_id": "21", "payment_purpose": "balance_topup"},
|
||||
"description": "Пополнение",
|
||||
"payment_method": {"type": "bank_card"},
|
||||
"created_at": "2024-01-02T12:00:00Z",
|
||||
"captured_at": "2024-01-02T12:05:00Z",
|
||||
"confirmation": {"confirmation_url": "https://pay.example"},
|
||||
}
|
||||
}
|
||||
|
||||
result = await service.process_yookassa_webhook(fake_session, payload)
|
||||
|
||||
assert result is True
|
||||
assert get_calls["count"] >= 2 # повторный запрос после восстановления
|
||||
assert restored_payment.amount_kopeks == 15000
|
||||
assert restored_payment.is_paid is True
|
||||
assert transactions and transactions[0]["amount_kopeks"] == 15000
|
||||
assert restored_payment.transaction_id == 555
|
||||
assert user.balance_kopeks == 15000
|
||||
assert bot.sent_messages
|
||||
assert admin_calls
|
||||
|
||||
|
||||
@pytest.mark.anyio("asyncio")
|
||||
async def test_process_yookassa_webhook_missing_metadata(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
service = _make_service(DummyBot())
|
||||
db = FakeSession()
|
||||
|
||||
async def fake_get_payment(db_session, payment_id):
|
||||
return None
|
||||
|
||||
create_mock = AsyncMock()
|
||||
update_mock = AsyncMock()
|
||||
|
||||
monkeypatch.setattr(payment_service_module, "get_yookassa_payment_by_id", fake_get_payment)
|
||||
monkeypatch.setattr(payment_service_module, "create_yookassa_payment", create_mock)
|
||||
monkeypatch.setattr(payment_service_module, "update_yookassa_payment_status", update_mock)
|
||||
|
||||
payload = {"object": {"id": "yk_missing", "status": "succeeded", "paid": True}}
|
||||
|
||||
result = await service.process_yookassa_webhook(db, payload)
|
||||
|
||||
assert result is False
|
||||
create_mock.assert_not_awaited()
|
||||
update_mock.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.anyio("asyncio")
|
||||
async def test_process_yookassa_webhook_missing_id(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
bot = DummyBot()
|
||||
@@ -668,6 +913,21 @@ async def test_get_pal24_payment_status_auto_finalize(monkeypatch: pytest.Monkey
|
||||
},
|
||||
}
|
||||
|
||||
async def get_payment_status(self, payment_id: str) -> Optional[Dict[str, Any]]:
|
||||
return None
|
||||
|
||||
async def get_bill_payments(self, bill_id: str) -> Optional[Dict[str, Any]]:
|
||||
return {
|
||||
"data": [
|
||||
{
|
||||
"id": "trs-auto-1",
|
||||
"bill_id": bill_id,
|
||||
"status": "SUCCESS",
|
||||
"payment_method": "SBP",
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
service.pal24_service = DummyPal24Service()
|
||||
|
||||
fake_session = FakeSession()
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
from pathlib import Path
|
||||
import sys
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT_DIR = Path(__file__).resolve().parents[2]
|
||||
if str(ROOT_DIR) not in sys.path:
|
||||
sys.path.insert(0, str(ROOT_DIR))
|
||||
|
||||
from app.services import referral_service # noqa: E402
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_commission_accrues_before_minimum_first_topup(monkeypatch):
|
||||
user = SimpleNamespace(
|
||||
id=1,
|
||||
telegram_id=101,
|
||||
full_name="Test User",
|
||||
referred_by_id=2,
|
||||
has_made_first_topup=False,
|
||||
)
|
||||
referrer = SimpleNamespace(
|
||||
id=2,
|
||||
telegram_id=202,
|
||||
full_name="Referrer",
|
||||
)
|
||||
|
||||
db = SimpleNamespace(
|
||||
commit=AsyncMock(),
|
||||
execute=AsyncMock(),
|
||||
)
|
||||
|
||||
get_user_mock = AsyncMock(side_effect=[user, referrer])
|
||||
monkeypatch.setattr(referral_service, "get_user_by_id", get_user_mock)
|
||||
add_user_balance_mock = AsyncMock()
|
||||
monkeypatch.setattr(referral_service, "add_user_balance", add_user_balance_mock)
|
||||
create_referral_earning_mock = AsyncMock()
|
||||
monkeypatch.setattr(referral_service, "create_referral_earning", create_referral_earning_mock)
|
||||
|
||||
monkeypatch.setattr(referral_service.settings, "REFERRAL_MINIMUM_TOPUP_KOPEKS", 20000)
|
||||
monkeypatch.setattr(referral_service.settings, "REFERRAL_FIRST_TOPUP_BONUS_KOPEKS", 5000)
|
||||
monkeypatch.setattr(referral_service.settings, "REFERRAL_INVITER_BONUS_KOPEKS", 10000)
|
||||
monkeypatch.setattr(referral_service.settings, "REFERRAL_COMMISSION_PERCENT", 25)
|
||||
|
||||
topup_amount = 15000
|
||||
|
||||
result = await referral_service.process_referral_topup(db, user.id, topup_amount)
|
||||
|
||||
assert result is True
|
||||
assert user.has_made_first_topup is False
|
||||
|
||||
add_user_balance_mock.assert_awaited_once()
|
||||
add_call = add_user_balance_mock.await_args
|
||||
assert add_call.args[1] is referrer
|
||||
assert add_call.args[2] == 3750
|
||||
assert "Комиссия" in add_call.args[3]
|
||||
assert add_call.kwargs.get("bot") is None
|
||||
|
||||
create_referral_earning_mock.assert_awaited_once()
|
||||
earning_call = create_referral_earning_mock.await_args
|
||||
assert earning_call.kwargs["amount_kopeks"] == 3750
|
||||
assert earning_call.kwargs["reason"] == "referral_commission_topup"
|
||||
|
||||
db.commit.assert_not_awaited()
|
||||
db.execute.assert_not_awaited()
|
||||
@@ -0,0 +1,121 @@
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
import sys
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import pytest
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
ROOT_DIR = Path(__file__).resolve().parents[2]
|
||||
if str(ROOT_DIR) not in sys.path:
|
||||
sys.path.insert(0, str(ROOT_DIR))
|
||||
|
||||
from app.services.remnawave_service import RemnaWaveService
|
||||
|
||||
|
||||
def _create_service() -> RemnaWaveService:
|
||||
service = RemnaWaveService.__new__(RemnaWaveService)
|
||||
service._panel_timezone = ZoneInfo("UTC")
|
||||
return service
|
||||
|
||||
|
||||
def _make_panel_user(telegram_id: int, expire_at: str, status: str = "ACTIVE") -> dict:
|
||||
return {
|
||||
"telegramId": telegram_id,
|
||||
"expireAt": expire_at,
|
||||
"status": status,
|
||||
}
|
||||
|
||||
|
||||
def test_deduplicate_prefers_latest_expire_date():
|
||||
service = _create_service()
|
||||
|
||||
telegram_id = 100
|
||||
older = _make_panel_user(telegram_id, datetime(2025, 1, 1, 0, 0, 0).isoformat())
|
||||
newer = _make_panel_user(telegram_id, datetime(2025, 2, 1, 0, 0, 0).isoformat())
|
||||
|
||||
deduplicated = service._deduplicate_panel_users_by_telegram_id([older, newer])
|
||||
|
||||
assert deduplicated[telegram_id] is newer
|
||||
|
||||
|
||||
def test_deduplicate_prefers_active_status_on_same_expire():
|
||||
service = _create_service()
|
||||
|
||||
telegram_id = 200
|
||||
expire = datetime(2025, 1, 1, 0, 0, 0).isoformat()
|
||||
disabled = _make_panel_user(telegram_id, expire, status="DISABLED")
|
||||
active = _make_panel_user(telegram_id, expire, status="ACTIVE")
|
||||
|
||||
deduplicated = service._deduplicate_panel_users_by_telegram_id([disabled, active])
|
||||
|
||||
assert deduplicated[telegram_id] is active
|
||||
|
||||
|
||||
def test_deduplicate_ignores_records_without_expire_date():
|
||||
service = _create_service()
|
||||
|
||||
telegram_id = 300
|
||||
missing_expire = _make_panel_user(telegram_id, "")
|
||||
valid = _make_panel_user(telegram_id, datetime(2025, 3, 1, 0, 0, 0).isoformat())
|
||||
|
||||
deduplicated = service._deduplicate_panel_users_by_telegram_id([missing_expire, valid])
|
||||
|
||||
assert deduplicated[telegram_id] is valid
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_or_create_user_handles_unique_violation(monkeypatch):
|
||||
service = _create_service()
|
||||
db = AsyncMock()
|
||||
|
||||
panel_user = {"telegramId": 555, "username": "existing"}
|
||||
existing_user = object()
|
||||
|
||||
create_user_mock = AsyncMock(
|
||||
side_effect=IntegrityError("stmt", "params", Exception("unique"))
|
||||
)
|
||||
get_user_mock = AsyncMock(return_value=existing_user)
|
||||
rollback_mock = AsyncMock()
|
||||
|
||||
db.rollback = rollback_mock
|
||||
|
||||
monkeypatch.setattr("app.services.remnawave_service.create_user", create_user_mock)
|
||||
monkeypatch.setattr(
|
||||
"app.services.remnawave_service.get_user_by_telegram_id",
|
||||
get_user_mock,
|
||||
)
|
||||
|
||||
user, created = await service._get_or_create_bot_user_from_panel(db, panel_user)
|
||||
|
||||
assert user is existing_user
|
||||
assert created is False
|
||||
create_user_mock.assert_awaited_once()
|
||||
get_user_mock.assert_awaited_once_with(db, 555)
|
||||
rollback_mock.assert_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_or_create_user_creates_new(monkeypatch):
|
||||
service = _create_service()
|
||||
db = AsyncMock()
|
||||
|
||||
panel_user = {"telegramId": 777, "username": "new_user"}
|
||||
new_user = object()
|
||||
|
||||
create_user_mock = AsyncMock(return_value=new_user)
|
||||
|
||||
monkeypatch.setattr("app.services.remnawave_service.create_user", create_user_mock)
|
||||
|
||||
user, created = await service._get_or_create_bot_user_from_panel(db, panel_user)
|
||||
|
||||
assert user is new_user
|
||||
assert created is True
|
||||
create_user_mock.assert_awaited_once_with(
|
||||
db=db,
|
||||
telegram_id=777,
|
||||
username="new_user",
|
||||
first_name="Panel User 777",
|
||||
language="ru",
|
||||
)
|
||||
@@ -0,0 +1,300 @@
|
||||
import pytest
|
||||
from datetime import datetime, timedelta
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
from app.config import settings
|
||||
from app.database.models import User
|
||||
from app.services.subscription_auto_purchase_service import auto_purchase_saved_cart_after_topup
|
||||
from app.services.subscription_purchase_service import (
|
||||
PurchaseDevicesConfig,
|
||||
PurchaseOptionsContext,
|
||||
PurchasePeriodConfig,
|
||||
PurchasePricingResult,
|
||||
PurchaseSelection,
|
||||
PurchaseServersConfig,
|
||||
PurchaseTrafficConfig,
|
||||
)
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
|
||||
class DummyTexts:
|
||||
def t(self, key: str, default: str):
|
||||
return default
|
||||
|
||||
def format_price(self, value: int) -> str:
|
||||
return f"{value / 100:.0f} ₽"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_auto_purchase_saved_cart_after_topup_success(monkeypatch):
|
||||
monkeypatch.setattr(settings, "AUTO_PURCHASE_AFTER_TOPUP_ENABLED", True)
|
||||
|
||||
user = MagicMock(spec=User)
|
||||
user.id = 42
|
||||
user.telegram_id = 4242
|
||||
user.balance_kopeks = 200_000
|
||||
user.language = "ru"
|
||||
user.subscription = None
|
||||
|
||||
cart_data = {
|
||||
"period_days": 30,
|
||||
"countries": ["ru"],
|
||||
"traffic_gb": 0,
|
||||
"devices": 1,
|
||||
}
|
||||
|
||||
traffic_config = PurchaseTrafficConfig(
|
||||
selectable=False,
|
||||
mode="fixed",
|
||||
options=[],
|
||||
default_value=0,
|
||||
current_value=0,
|
||||
)
|
||||
servers_config = PurchaseServersConfig(
|
||||
options=[],
|
||||
min_selectable=0,
|
||||
max_selectable=0,
|
||||
default_selection=["ru"],
|
||||
)
|
||||
devices_config = PurchaseDevicesConfig(
|
||||
minimum=1,
|
||||
maximum=5,
|
||||
default=1,
|
||||
current=1,
|
||||
price_per_device=0,
|
||||
discounted_price_per_device=0,
|
||||
price_label="0 ₽",
|
||||
)
|
||||
|
||||
period_config = PurchasePeriodConfig(
|
||||
id="days:30",
|
||||
days=30,
|
||||
months=1,
|
||||
label="30 дней",
|
||||
base_price=100_000,
|
||||
base_price_label="1000 ₽",
|
||||
base_price_original=100_000,
|
||||
base_price_original_label=None,
|
||||
discount_percent=0,
|
||||
per_month_price=100_000,
|
||||
per_month_price_label="1000 ₽",
|
||||
traffic=traffic_config,
|
||||
servers=servers_config,
|
||||
devices=devices_config,
|
||||
)
|
||||
|
||||
context = PurchaseOptionsContext(
|
||||
user=user,
|
||||
subscription=None,
|
||||
currency="RUB",
|
||||
balance_kopeks=user.balance_kopeks,
|
||||
periods=[period_config],
|
||||
default_period=period_config,
|
||||
period_map={"days:30": period_config},
|
||||
server_uuid_to_id={"ru": 1},
|
||||
payload={},
|
||||
)
|
||||
|
||||
base_pricing = PurchasePricingResult(
|
||||
selection=PurchaseSelection(
|
||||
period=period_config,
|
||||
traffic_value=0,
|
||||
servers=["ru"],
|
||||
devices=1,
|
||||
),
|
||||
server_ids=[1],
|
||||
server_prices_for_period=[100_000],
|
||||
base_original_total=100_000,
|
||||
discounted_total=100_000,
|
||||
promo_discount_value=0,
|
||||
promo_discount_percent=0,
|
||||
final_total=100_000,
|
||||
months=1,
|
||||
details={"servers_individual_prices": [100_000]},
|
||||
)
|
||||
|
||||
class DummyMiniAppService:
|
||||
async def build_options(self, db, user):
|
||||
return context
|
||||
|
||||
async def calculate_pricing(self, db, ctx, selection):
|
||||
return PurchasePricingResult(
|
||||
selection=selection,
|
||||
server_ids=base_pricing.server_ids,
|
||||
server_prices_for_period=base_pricing.server_prices_for_period,
|
||||
base_original_total=base_pricing.base_original_total,
|
||||
discounted_total=base_pricing.discounted_total,
|
||||
promo_discount_value=base_pricing.promo_discount_value,
|
||||
promo_discount_percent=base_pricing.promo_discount_percent,
|
||||
final_total=base_pricing.final_total,
|
||||
months=base_pricing.months,
|
||||
details=base_pricing.details,
|
||||
)
|
||||
|
||||
async def submit_purchase(self, db, prepared_context, pricing):
|
||||
return {
|
||||
"subscription": MagicMock(),
|
||||
"transaction": MagicMock(),
|
||||
"was_trial_conversion": False,
|
||||
"message": "🎉 Subscription purchased",
|
||||
}
|
||||
|
||||
monkeypatch.setattr(
|
||||
"app.services.subscription_auto_purchase_service.MiniAppSubscriptionPurchaseService",
|
||||
lambda: DummyMiniAppService(),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"app.services.subscription_auto_purchase_service.user_cart_service.get_user_cart",
|
||||
AsyncMock(return_value=cart_data),
|
||||
)
|
||||
delete_cart_mock = AsyncMock()
|
||||
monkeypatch.setattr(
|
||||
"app.services.subscription_auto_purchase_service.user_cart_service.delete_user_cart",
|
||||
delete_cart_mock,
|
||||
)
|
||||
clear_draft_mock = AsyncMock()
|
||||
monkeypatch.setattr(
|
||||
"app.services.subscription_auto_purchase_service.clear_subscription_checkout_draft",
|
||||
clear_draft_mock,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"app.services.subscription_auto_purchase_service.get_texts",
|
||||
lambda lang: DummyTexts(),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"app.services.subscription_auto_purchase_service.format_period_description",
|
||||
lambda days, lang: f"{days} дней",
|
||||
)
|
||||
|
||||
admin_service_mock = MagicMock()
|
||||
admin_service_mock.send_subscription_purchase_notification = AsyncMock()
|
||||
monkeypatch.setattr(
|
||||
"app.services.subscription_auto_purchase_service.AdminNotificationService",
|
||||
lambda bot: admin_service_mock,
|
||||
)
|
||||
|
||||
bot = AsyncMock()
|
||||
db_session = AsyncMock(spec=AsyncSession)
|
||||
|
||||
result = await auto_purchase_saved_cart_after_topup(db_session, user, bot=bot)
|
||||
|
||||
assert result is True
|
||||
delete_cart_mock.assert_awaited_once_with(user.id)
|
||||
clear_draft_mock.assert_awaited_once_with(user.id)
|
||||
bot.send_message.assert_awaited()
|
||||
admin_service_mock.send_subscription_purchase_notification.assert_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_auto_purchase_saved_cart_after_topup_extension(monkeypatch):
|
||||
monkeypatch.setattr(settings, "AUTO_PURCHASE_AFTER_TOPUP_ENABLED", True)
|
||||
|
||||
subscription = MagicMock()
|
||||
subscription.id = 99
|
||||
subscription.is_trial = False
|
||||
subscription.status = "active"
|
||||
subscription.end_date = datetime.utcnow()
|
||||
subscription.device_limit = 1
|
||||
subscription.traffic_limit_gb = 100
|
||||
subscription.connected_squads = ["squad-a"]
|
||||
|
||||
user = MagicMock(spec=User)
|
||||
user.id = 7
|
||||
user.telegram_id = 7007
|
||||
user.balance_kopeks = 200_000
|
||||
user.language = "ru"
|
||||
user.subscription = subscription
|
||||
|
||||
cart_data = {
|
||||
"cart_mode": "extend",
|
||||
"subscription_id": subscription.id,
|
||||
"period_days": 30,
|
||||
"total_price": 31_000,
|
||||
"description": "Продление подписки на 30 дней",
|
||||
"device_limit": 2,
|
||||
"traffic_limit_gb": 500,
|
||||
"squad_uuid": "squad-b",
|
||||
"consume_promo_offer": True,
|
||||
}
|
||||
|
||||
subtract_mock = AsyncMock(return_value=True)
|
||||
monkeypatch.setattr(
|
||||
"app.services.subscription_auto_purchase_service.subtract_user_balance",
|
||||
subtract_mock,
|
||||
)
|
||||
|
||||
async def extend_stub(db, current_subscription, days):
|
||||
current_subscription.end_date = current_subscription.end_date + timedelta(days=days)
|
||||
return current_subscription
|
||||
|
||||
monkeypatch.setattr(
|
||||
"app.services.subscription_auto_purchase_service.extend_subscription",
|
||||
extend_stub,
|
||||
)
|
||||
|
||||
create_transaction_mock = AsyncMock(return_value=MagicMock())
|
||||
monkeypatch.setattr(
|
||||
"app.services.subscription_auto_purchase_service.create_transaction",
|
||||
create_transaction_mock,
|
||||
)
|
||||
|
||||
service_mock = MagicMock()
|
||||
service_mock.update_remnawave_user = AsyncMock()
|
||||
monkeypatch.setattr(
|
||||
"app.services.subscription_auto_purchase_service.SubscriptionService",
|
||||
lambda: service_mock,
|
||||
)
|
||||
|
||||
monkeypatch.setattr(
|
||||
"app.services.subscription_auto_purchase_service.user_cart_service.get_user_cart",
|
||||
AsyncMock(return_value=cart_data),
|
||||
)
|
||||
delete_cart_mock = AsyncMock()
|
||||
monkeypatch.setattr(
|
||||
"app.services.subscription_auto_purchase_service.user_cart_service.delete_user_cart",
|
||||
delete_cart_mock,
|
||||
)
|
||||
clear_draft_mock = AsyncMock()
|
||||
monkeypatch.setattr(
|
||||
"app.services.subscription_auto_purchase_service.clear_subscription_checkout_draft",
|
||||
clear_draft_mock,
|
||||
)
|
||||
|
||||
monkeypatch.setattr(
|
||||
"app.services.subscription_auto_purchase_service.get_texts",
|
||||
lambda lang: DummyTexts(),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"app.services.subscription_auto_purchase_service.format_period_description",
|
||||
lambda days, lang: f"{days} дней",
|
||||
)
|
||||
|
||||
admin_service_mock = MagicMock()
|
||||
admin_service_mock.send_subscription_extension_notification = AsyncMock()
|
||||
monkeypatch.setattr(
|
||||
"app.services.subscription_auto_purchase_service.AdminNotificationService",
|
||||
lambda bot: admin_service_mock,
|
||||
)
|
||||
|
||||
bot = AsyncMock()
|
||||
db_session = AsyncMock(spec=AsyncSession)
|
||||
|
||||
result = await auto_purchase_saved_cart_after_topup(db_session, user, bot=bot)
|
||||
|
||||
assert result is True
|
||||
subtract_mock.assert_awaited_once_with(
|
||||
db_session,
|
||||
user,
|
||||
cart_data["total_price"],
|
||||
cart_data["description"],
|
||||
consume_promo_offer=True,
|
||||
)
|
||||
assert subscription.device_limit == 2
|
||||
assert subscription.traffic_limit_gb == 500
|
||||
assert "squad-b" in subscription.connected_squads
|
||||
delete_cart_mock.assert_awaited_once_with(user.id)
|
||||
clear_draft_mock.assert_awaited_once_with(user.id)
|
||||
admin_service_mock.send_subscription_extension_notification.assert_awaited()
|
||||
bot.send_message.assert_awaited()
|
||||
service_mock.update_remnawave_user.assert_awaited()
|
||||
create_transaction_mock.assert_awaited()
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user