Compare commits

...

186 Commits

Author SHA1 Message Date
Egor 2613d42b57 Merge pull request #29 from Fr1ngg/dev
Правки реф в админке, промокодах, в разделе с подписками. Поправлены кнопки обновления во всех разделах, вырезаны лишние кнопки. Добавлена модель бд для отслеживания конверсии перехода из триальных в платные подписки
2025-09-06 02:40:43 +03:00
Egor a06d55af38 Update subscriptions.py 2025-09-06 02:34:32 +03:00
Egor bf28617d99 Update servers.py 2025-09-06 02:32:32 +03:00
Egor 878a28cb11 Update subscription_conversion.py 2025-09-06 02:24:18 +03:00
Egor ade062c7cd Update subscription.py 2025-09-06 02:19:51 +03:00
Egor 5d774bd1aa Update subscription.py 2025-09-06 02:15:29 +03:00
Egor 8d81bfcf94 Update universal_migration.py 2025-09-06 02:10:10 +03:00
Egor 095c3a8490 Update subscription.py 2025-09-06 02:05:49 +03:00
Egor f7c8c82775 Update subscription.py 2025-09-06 02:04:17 +03:00
Egor 3d75543cf0 Create subscription_conversion.py 2025-09-06 02:03:04 +03:00
Egor 1223948596 Update models.py 2025-09-06 02:02:02 +03:00
Egor f12964ecc8 Update subscription.py 2025-09-06 01:54:51 +03:00
Egor 693dce2e28 Update subscription.py 2025-09-06 01:46:49 +03:00
Egor 166050c581 Update users.py 2025-09-06 01:29:14 +03:00
Egor d499574c4d Update referral.py 2025-09-06 01:27:27 +03:00
Egor 98363e2c28 Update users.py 2025-09-06 01:21:53 +03:00
Egor d87622417a Update promocodes.py 2025-09-06 01:07:52 +03:00
Egor 8be1af5adb Update admin.py 2025-09-06 01:06:55 +03:00
Egor 72a2bb7d04 Merge pull request #28 from Fr1ngg/dev
Правка раздела рефералов в админка, его починка, актуализация данных и доработка + правки раздела статистики в админке
2025-09-06 01:02:19 +03:00
Egor a847d4c1f6 Update statistics.py 2025-09-06 00:58:43 +03:00
Egor b860752d80 Update referrals.py 2025-09-06 00:53:34 +03:00
Egor 0a466b973b Update statistics.py 2025-09-06 00:52:50 +03:00
Egor 9ca0ca2db2 Update referrals.py 2025-09-06 00:39:33 +03:00
Egor af3f746ae1 Update referrals.py 2025-09-06 00:31:15 +03:00
Egor c64c53a26b Update referral.py 2025-09-06 00:30:13 +03:00
Egor 765e912dd1 Update referrals.py 2025-09-06 00:23:42 +03:00
Egor f205acfb0a Update referral.py 2025-09-06 00:16:51 +03:00
Egor 3c424143dc Update referrals.py 2025-09-06 00:11:51 +03:00
Egor ba87ffa0be Update referrals.py 2025-09-06 00:03:09 +03:00
Egor f211c93794 Merge pull request #27 from Fr1ngg/dev
Доработка статистики рефералов, плюс уведомления для рефералов и приглашаюших
2025-09-05 23:15:18 +03:00
Egor 8994e3e9ba Update inline.py 2025-09-05 23:03:19 +03:00
Egor 6018c07c0d Update start.py 2025-09-05 22:57:59 +03:00
Egor 41bfffdf12 Update start.py 2025-09-05 22:52:07 +03:00
Egor 1a06b88db9 Update inline.py 2025-09-05 22:48:27 +03:00
Egor bd6d9013ae Update referral_service.py 2025-09-05 22:47:48 +03:00
Egor 093d178bf8 Update referral.py 2025-09-05 22:46:08 +03:00
Egor 6169f34911 Update user_utils.py 2025-09-05 22:33:13 +03:00
Egor 3aca29777c Update user_utils.py 2025-09-05 22:28:32 +03:00
Egor c7f2b0752b Update referral.py 2025-09-05 22:21:22 +03:00
Egor e113b7e842 Update config.py 2025-09-05 22:18:10 +03:00
Egor 359bbaa31c Update config.py 2025-09-05 22:17:02 +03:00
Egor 862aeee558 Update config.py 2025-09-05 22:16:19 +03:00
Egor 4c3a3b91a6 Update user.py 2025-09-05 22:15:41 +03:00
Egor 199e78cb25 Update inline.py 2025-09-05 22:14:25 +03:00
Egor 87845e2691 Update payment_service.py 2025-09-05 22:13:52 +03:00
Egor 0c30d1ea30 Update start.py 2025-09-05 22:12:16 +03:00
Egor 2f64aa0961 Update user_utils.py 2025-09-05 22:05:08 +03:00
Egor 2266609111 Update referral_service.py 2025-09-05 22:03:28 +03:00
Egor 28e9cf6ab7 Update referral.py 2025-09-05 18:51:37 +03:00
Egor 7c55c54f23 Merge pull request #26 from Fr1ngg/dev
Dev
2025-09-05 18:46:43 +03:00
Egor 060d0a6970 Update README.md 2025-09-05 18:45:41 +03:00
Egor f0465a9ea4 Update .env.example 2025-09-05 18:44:57 +03:00
Egor 29db1f4e56 Update user.py 2025-09-05 18:40:05 +03:00
Egor 755ef03f98 Update user.py 2025-09-05 18:32:09 +03:00
Egor 458b71ea28 Update user.py 2025-09-05 18:28:34 +03:00
Egor f1f409dc63 Update payment_service.py 2025-09-05 18:27:39 +03:00
Egor 64d0f9fc2d Update user.py 2025-09-05 18:17:45 +03:00
Egor 01db9036f5 Update referral_service.py 2025-09-05 18:08:33 +03:00
Egor 674e1b73f6 Update user.py 2025-09-05 18:06:27 +03:00
Egor 9bfb9bc711 Update subscription.py 2025-09-05 17:49:11 +03:00
Egor 96f905563f Update subscription.py 2025-09-05 17:45:58 +03:00
Egor 6b278de538 Update subscription.py 2025-09-05 17:41:26 +03:00
Egor c6fc770fc1 Update models.py 2025-09-05 17:25:53 +03:00
Egor e50f208064 Update models.py 2025-09-05 17:25:21 +03:00
Egor 637af5c216 Update models.py 2025-09-05 17:20:28 +03:00
Egor 08373be215 Update universal_migration.py 2025-09-05 16:46:30 +03:00
Egor 5e4c7e339a Update balance.py 2025-09-05 16:43:21 +03:00
Egor 476dbcca93 Update subscription.py 2025-09-05 16:42:15 +03:00
Egor e8e4330b1c Update referral.py 2025-09-05 16:39:34 +03:00
Egor d5c0146d48 Update balance.py 2025-09-05 16:38:41 +03:00
Egor 83c461230c Update referral_service.py 2025-09-05 16:37:17 +03:00
Egor f27a51639c Update models.py 2025-09-05 16:35:58 +03:00
Egor ab8d4f0340 Update config.py 2025-09-05 16:35:11 +03:00
Egor 1ee15bf6d3 Merge pull request #25 from yazhog/feature/change-buttons
Немного юзерфрендли
2025-09-05 14:41:46 +03:00
yazhog 56238ad93c Немного юзерфрендли 2025-09-05 14:22:02 +03:00
Egor 2fa2c6278c Update README.md 2025-09-05 05:56:00 +03:00
Egor 519978f634 Merge pull request #24 from Fr1ngg/dev
Изменение логики расчета цен за доп. услуги (трафик, сервера, устройства). Теперь рассчитывается цена за месяц
2025-09-05 02:45:20 +03:00
Egor 7095d8f015 Update inline.py 2025-09-05 02:25:19 +03:00
Egor e8dcfa0687 Update inline.py 2025-09-05 02:22:22 +03:00
Egor a0e12fa89a Update subscription.py 2025-09-05 02:21:40 +03:00
Egor 097be9aa0f Update inline.py 2025-09-05 02:13:47 +03:00
Egor f50b5296f4 Update subscription.py 2025-09-05 02:08:59 +03:00
Egor 14e94053ee Update inline.py 2025-09-05 02:07:40 +03:00
Egor 3c27fa4d90 Update subscription.py 2025-09-05 01:56:33 +03:00
Egor 2a78ace675 Update subscription.py 2025-09-05 01:40:31 +03:00
Egor 81774f87d8 Update subscription.py 2025-09-05 01:36:49 +03:00
Egor 6735c2b38f Update subscription_service.py 2025-09-05 01:30:49 +03:00
Egor 8bdc5a2332 Update subscription.py 2025-09-05 01:29:13 +03:00
Egor d26262bfbf Update subscription_service.py 2025-09-05 01:28:55 +03:00
Egor 48f2f01bdd Update __init__.py 2025-09-05 01:28:00 +03:00
Egor ef68a43589 Create pricing_utils.py 2025-09-05 01:27:26 +03:00
Egor 733c1b72ec Update subscription.py 2025-09-05 01:25:59 +03:00
Egor c9ca360286 Update subscription_service.py 2025-09-05 01:18:14 +03:00
Egor 5aad1e025b Update users.py 2025-09-04 21:56:27 +03:00
Egor e811e7c877 Merge pull request #22 from Fr1ngg/dev
Dev
2025-09-04 21:45:36 +03:00
Egor 932e9f292b Update admin.py 2025-09-04 21:42:20 +03:00
Egor 03749574d6 Update users.py 2025-09-04 21:33:09 +03:00
Egor 1059a51688 Update users.py 2025-09-04 21:30:31 +03:00
Egor a1963d5b17 Update users.py 2025-09-04 21:26:07 +03:00
Egor 426e736cd5 Update users.py 2025-09-04 21:18:18 +03:00
Egor fb03cff403 Update users.py 2025-09-04 21:11:28 +03:00
Egor 40780f1184 Update users.py 2025-09-04 21:00:58 +03:00
Egor c76290b51e Update users.py 2025-09-04 20:55:01 +03:00
Egor 5b6dd3058e Update users.py 2025-09-04 20:46:52 +03:00
Egor c8d91a1ec1 Update admin.py 2025-09-04 20:43:55 +03:00
Egor 21952f84eb Update states.py 2025-09-04 20:42:54 +03:00
Egor 7bdfaa312b Update users.py 2025-09-04 20:42:19 +03:00
Egor 1829a469e7 Merge pull request #21 from Fr1ngg/dev
Dev
2025-09-04 18:40:02 +03:00
Egor ddd37a4e98 Update monitoring_service.py 2025-09-04 18:38:44 +03:00
Egor 2804c701c0 Update monitoring_service.py 2025-09-04 18:35:59 +03:00
Egor b3c85b2db5 Delete app/handlers/subscription_notifications.py 2025-09-04 18:28:42 +03:00
Egor 987b8170c6 Update monitoring_service.py 2025-09-04 18:26:38 +03:00
Egor ab49f3cd1e Merge pull request #20 from Fr1ngg/dev
Dev
2025-09-04 16:50:49 +03:00
Egor 732e8f7b8b Update README.md 2025-09-04 16:48:09 +03:00
Egor 9273a4e15b Update .env.example 2025-09-04 16:47:42 +03:00
Egor d89293f76a Update user_service.py 2025-09-04 16:45:57 +03:00
Egor 174462183d Update config.py 2025-09-04 16:18:34 +03:00
Egor 783927be37 Update subscription_service.py 2025-09-04 16:13:58 +03:00
Egor 98c68dd8dc Update inline.py 2025-09-04 16:01:38 +03:00
Egor 13585eab54 Update config.py 2025-09-04 15:53:22 +03:00
Egor 169336da43 Update config.py 2025-09-04 15:48:36 +03:00
Egor 9ef875f6bf Update config.py 2025-09-04 15:46:36 +03:00
Egor 3f54706a72 Update config.py 2025-09-04 15:39:09 +03:00
Egor 835e5e6f7f Update config.py 2025-09-04 15:24:29 +03:00
Egor b292442e8d Update config.py 2025-09-04 15:16:56 +03:00
Egor 9faaf5c963 Update inline.py 2025-09-04 15:13:50 +03:00
Egor 117e88fe4a Update subscription.py 2025-09-04 15:04:07 +03:00
Egor 96bf08a07c Update inline.py 2025-09-04 15:02:59 +03:00
Egor 4523403de5 Update config.py 2025-09-04 15:01:25 +03:00
Egor 02682e88c1 Update subscription.py 2025-09-04 14:48:57 +03:00
Egor 323b9a7e18 Update subscription.py 2025-09-04 14:48:10 +03:00
Egor 100dc3730f Update subscription.py 2025-09-04 14:43:09 +03:00
Egor 919047317a Update subscription.py 2025-09-04 14:38:12 +03:00
Egor 6ae5b59ed1 Update subscription.py 2025-09-04 14:37:47 +03:00
Egor b3123e2e85 Update subscription.py 2025-09-04 14:34:09 +03:00
Egor fbe0ca790e Update subscription.py 2025-09-04 14:31:40 +03:00
Egor 785648ba7a Update subscription.py 2025-09-04 14:27:22 +03:00
Egor 06f8728efa Update inline.py 2025-09-04 14:21:00 +03:00
Egor 99f102a06e Update config.py 2025-09-04 14:19:23 +03:00
Egor d759dd3a66 Update user_service.py 2025-09-04 13:58:58 +03:00
Egor 73dc22b0b8 Update texts.py 2025-09-04 04:27:33 +03:00
Egor 5a082f0eb8 Update texts.py 2025-09-04 04:20:58 +03:00
Egor 073d5a119b Update subscription.py 2025-09-04 04:20:36 +03:00
Egor 2dab106bef Update README.md 2025-09-04 04:13:00 +03:00
Egor ec8c7b7242 Update .env.example 2025-09-04 04:12:46 +03:00
Egor a4b96a089f Update user_service.py 2025-09-04 04:07:09 +03:00
Egor 269f8f6220 Update user_service.py 2025-09-04 04:04:19 +03:00
Egor f1beda2b89 Update user_service.py 2025-09-04 04:01:03 +03:00
Egor 6a00db32ae Update config.py 2025-09-04 03:28:51 +03:00
Egor 276bc5eb40 Update .env.example 2025-09-04 03:27:58 +03:00
Egor 8678afd055 Update subscription.py 2025-09-04 03:26:34 +03:00
Egor 31340ad4a5 Update subscription_service.py 2025-09-04 03:25:08 +03:00
Egor 98ce47ce71 Update inline.py 2025-09-04 03:24:07 +03:00
Egor 3462f5b3b1 Update subscription.py 2025-09-04 02:57:42 +03:00
Egor 7ec606f93c Update config.py 2025-09-03 21:11:15 +03:00
Egor 7d13bf47aa Update README.md 2025-09-03 21:08:32 +03:00
Egor 87773806fc Update .env.example 2025-09-03 21:08:09 +03:00
Egor dad6200ad8 Update bot.py 2025-09-03 21:07:40 +03:00
Egor c3991162c2 Update payment_service.py 2025-09-03 21:06:48 +03:00
Egor 08feb1f876 Update telegram_stars.py 2025-09-03 21:05:33 +03:00
Egor 6f15070052 Update balance.py 2025-09-03 21:05:13 +03:00
Egor 0c4e73ab92 Create stars_payments.py 2025-09-03 21:04:50 +03:00
Egor 48902c4968 Update telegram_stars.py 2025-09-03 20:57:01 +03:00
Egor 3c715577a6 Update telegram_stars.py 2025-09-03 20:55:25 +03:00
Egor 8e67c7a97a Update texts.py 2025-09-03 20:09:01 +03:00
Egor c938d48026 Update texts.py 2025-09-03 20:02:46 +03:00
Egor 99a4927f8a Update texts.py 2025-09-03 19:53:10 +03:00
Egor df5b8dcdcd Update inline.py 2025-09-03 19:52:51 +03:00
Egor d4a1a2e64d Update menu.py 2025-09-03 19:52:34 +03:00
Egor 5e2df269a3 Update start.py 2025-09-03 19:52:16 +03:00
Egor a2056e0441 Update docker-registry.yml 2025-09-03 18:38:28 +03:00
Egor a6bbd8f8fb Add files via upload 2025-09-03 06:39:35 +03:00
Egor 9f4e0cf5f8 Update menu.py 2025-09-03 06:34:44 +03:00
Egor c7e8365d3f Update start.py 2025-09-03 06:33:37 +03:00
Egor 49b10b490e Update inline.py 2025-09-03 06:29:20 +03:00
Egor f26bca8627 Update texts.py 2025-09-03 06:27:46 +03:00
Egor df139b3d34 Update texts.py 2025-09-03 06:22:09 +03:00
Egor 05d1edf278 Update menu.py 2025-09-03 06:21:21 +03:00
Egor a518df8ff0 Update menu.py 2025-09-03 06:16:38 +03:00
Egor 0a13fe4f40 Update menu.py 2025-09-03 06:13:15 +03:00
Egor 6410bd1085 Update menu.py 2025-09-03 06:03:26 +03:00
Egor 55c4549cff Update inline.py 2025-09-03 06:01:29 +03:00
Egor e898948073 Update texts.py 2025-09-03 05:59:21 +03:00
Egor 5f1c8d8ac0 Update README.md 2025-09-03 05:02:51 +03:00
Egor 8d1f8f2355 Update README.md 2025-09-03 04:56:05 +03:00
Egor fbbbad608b Update .env.example 2025-09-03 04:55:38 +03:00
37 changed files with 4824 additions and 1384 deletions
+20 -13
View File
@@ -26,14 +26,25 @@ REMNAWAVE_API_KEY=
# Для панелей установленных скриптом eGames прописывать ключ в формате XXXXXXX:DDDDDDDD - https://panel.example.com/auth/login?XXXXXXX=DDDDDDDD
REMNAWAVE_SECRET_KEY=your_secret_key_here
# ========= ПОДПИСКИ =========
# ===== ТРИАЛ ПОДПИСКА =====
TRIAL_DURATION_DAYS=3
TRIAL_TRAFFIC_LIMIT_GB=10
TRIAL_DEVICE_LIMIT=1
TRIAL_SQUAD_UUID=
DEFAULT_TRAFFIC_RESET_STRATEGY=MONTH
# ===== ПЛАТНАЯ ПОДПИСКА =====
# Сколько устройств доступно по дефолту при покупке платной подписки
DEFAULT_DEVICE_LIMIT=3
# Максимум устройств достопных к покупке (0 = Нет лимита)
MAX_DEVICES_LIMIT=15
# Дефолт параметры для подписок выданных через админку
DEFAULT_TRAFFIC_LIMIT_GB=100
DEFAULT_DEVICE_LIMIT=1
# ===== ГЛОБАЛЬНЫЙ ПАРАМЕТР ДЛЯ ВСЕХ ПОДПИСОК =====
DEFAULT_TRAFFIC_RESET_STRATEGY=MONTH
# ===== НАСТРОЙКИ ТРАФИКА =====
# Режим выбора трафика:
@@ -62,21 +73,16 @@ PRICE_90_DAYS=36900
PRICE_180_DAYS=69900
PRICE_360_DAYS=109900
# Цены за трафик
PRICE_TRAFFIC_5GB=1000
PRICE_TRAFFIC_10GB=2000
PRICE_TRAFFIC_25GB=3000
PRICE_TRAFFIC_50GB=4000
PRICE_TRAFFIC_100GB=6000
PRICE_TRAFFIC_250GB=10000
PRICE_TRAFFIC_UNLIMITED=15000
# Выводимые пакеты трафика и их цены в копейках
TRAFFIC_PACKAGES_CONFIG="5:2000:false,10:3500:false,25:7000:false,50:11000:true,100:15000:true,250:17000:false,500:19000:false,1000:19500:true,0:20000:true"
# Цена за дополнительное устройство
# Цена за дополнительное устройство (DEFAULT_DEVICE_LIMIT идет бесплатно!)
PRICE_PER_DEVICE=5000
# ===== РЕФЕРАЛЬНАЯ СИСТЕМА =====
REFERRAL_REGISTRATION_REWARD=10000
REFERRED_USER_REWARD=10000
REFERRAL_MINIMUM_TOPUP_KOPEKS=10000
REFERRAL_FIRST_TOPUP_BONUS_KOPEKS=10000
REFERRAL_INVITER_BONUS_KOPEKS=10000
REFERRAL_COMMISSION_PERCENT=25
# ===== АВТОПРОДЛЕНИЕ =====
@@ -88,6 +94,7 @@ MIN_BALANCE_FOR_AUTOPAY_KOPEKS=10000
# Telegram Stars (работает автоматически)
TELEGRAM_STARS_ENABLED=true
TELEGRAM_STARS_RATE_RUB=1.3
# Tribute (https://tribute.app)
TRIBUTE_ENABLED=false
+3
View File
@@ -4,12 +4,14 @@ on:
branches:
- main
- master
- dev
tags:
- 'v*'
pull_request:
branches:
- main
- master
- dev
env:
REGISTRY: ghcr.io
IMAGE_NAME: fr1ngg/remnawave-bedolaga-telegram-bot
@@ -42,6 +44,7 @@ jobs:
type=semver,pattern={{major}}.{{minor}}
type=semver,pattern={{major}}
type=raw,value=latest,enable={{is_default_branch}}
type=raw,value=dev,enable=${{ github.ref == 'refs/heads/dev' }}
- name: Build and push Docker image
uses: docker/build-push-action@v5
with:
+536 -301
View File
File diff suppressed because it is too large Load Diff
+5
View File
@@ -25,6 +25,7 @@ from app.handlers.admin import (
statistics as admin_statistics, servers as admin_servers,
maintenance as admin_maintenance
)
from app.handlers.stars_payments import register_stars_handlers
logger = logging.getLogger(__name__)
@@ -70,6 +71,7 @@ async def setup_bot() -> tuple[Bot, Dispatcher]:
dp.callback_query.middleware(LoggingMiddleware())
dp.message.middleware(AuthMiddleware())
dp.callback_query.middleware(AuthMiddleware())
dp.pre_checkout_query.middleware(AuthMiddleware())
dp.message.middleware(MaintenanceMiddleware())
dp.callback_query.middleware(MaintenanceMiddleware())
dp.message.middleware(ThrottlingMiddleware())
@@ -98,6 +100,9 @@ async def setup_bot() -> tuple[Bot, Dispatcher]:
common.register_handlers(dp)
register_stars_handlers(dp)
logger.info("🌟 Зарегистрированы обработчики Telegram Stars платежей")
try:
await maintenance_service.start_monitoring()
logger.info("Мониторинг техработ запущен")
+177 -20
View File
@@ -1,5 +1,5 @@
import os
from typing import List, Optional, Union
from typing import List, Optional, Union, Dict
from pydantic_settings import BaseSettings
from pydantic import field_validator, Field
from pathlib import Path
@@ -25,6 +25,7 @@ class Settings(BaseSettings):
DEFAULT_DEVICE_LIMIT: int = 1
TRIAL_SQUAD_UUID: str
DEFAULT_TRAFFIC_RESET_STRATEGY: str = "MONTH"
MAX_DEVICES_LIMIT: int = 20
TRIAL_WARNING_HOURS: int = 2
ENABLE_NOTIFICATIONS: bool = True
@@ -44,22 +45,31 @@ class Settings(BaseSettings):
PRICE_180_DAYS: int = 499000
PRICE_360_DAYS: int = 899000
PRICE_TRAFFIC_5GB: int = 10000
PRICE_TRAFFIC_10GB: int = 19000
PRICE_TRAFFIC_25GB: int = 45000
PRICE_TRAFFIC_50GB: int = 85000
PRICE_TRAFFIC_100GB: int = 159000
PRICE_TRAFFIC_250GB: int = 369000
PRICE_TRAFFIC_UNLIMITED: int = 0
PRICE_TRAFFIC_5GB: int = 2000
PRICE_TRAFFIC_10GB: int = 3500
PRICE_TRAFFIC_25GB: int = 7000
PRICE_TRAFFIC_50GB: int = 11000
PRICE_TRAFFIC_100GB: int = 15000
PRICE_TRAFFIC_250GB: int = 17000
PRICE_TRAFFIC_500GB: int = 19000
PRICE_TRAFFIC_1000GB: int = 19500
PRICE_TRAFFIC_UNLIMITED: int = 20000
TRAFFIC_PACKAGES_CONFIG: str = ""
PRICE_PER_DEVICE: int = 5000
TRAFFIC_SELECTION_MODE: str = "selectable"
FIXED_TRAFFIC_LIMIT_GB: int = 100
REFERRAL_REGISTRATION_REWARD: int = 5000
REFERRED_USER_REWARD: int = 2500
REFERRAL_COMMISSION_PERCENT: int = 10
REFERRAL_MINIMUM_TOPUP_KOPEKS: int = 10000
REFERRAL_FIRST_TOPUP_BONUS_KOPEKS: int = 10000
REFERRAL_INVITER_BONUS_KOPEKS: int = 10000
REFERRAL_COMMISSION_PERCENT: int = 25
REFERRAL_NOTIFICATIONS_ENABLED: bool = True
REFERRAL_NOTIFICATION_RETRY_ATTEMPTS: int = 3
REFERRED_USER_REWARD: int = 0
AUTOPAY_WARNING_DAYS: str = "3,1"
@@ -75,6 +85,7 @@ class Settings(BaseSettings):
MAINTENANCE_MESSAGE: str = "🔧 Ведутся технические работы. Сервис временно недоступен. Попробуйте позже."
TELEGRAM_STARS_ENABLED: bool = True
TELEGRAM_STARS_RATE_RUB: float = 1.3
TRIBUTE_ENABLED: bool = False
TRIBUTE_API_KEY: Optional[str] = None
@@ -281,6 +292,150 @@ class Settings(BaseSettings):
service_name=self.PAYMENT_SERVICE_NAME,
description=description
)
def get_stars_rate(self) -> float:
return self.TELEGRAM_STARS_RATE_RUB
def stars_to_rubles(self, stars: int) -> float:
return stars * self.get_stars_rate()
def rubles_to_stars(self, rubles: float) -> int:
return max(1, int(rubles / self.get_stars_rate()))
def get_referral_settings(self) -> Dict:
return {
"minimum_topup_kopeks": self.REFERRAL_MINIMUM_TOPUP_KOPEKS,
"first_topup_bonus_kopeks": self.REFERRAL_FIRST_TOPUP_BONUS_KOPEKS,
"inviter_bonus_kopeks": self.REFERRAL_INVITER_BONUS_KOPEKS,
"commission_percent": self.REFERRAL_COMMISSION_PERCENT,
"notifications_enabled": self.REFERRAL_NOTIFICATIONS_ENABLED,
"referred_user_reward": self.REFERRED_USER_REWARD
}
def is_referral_notifications_enabled(self) -> bool:
return self.REFERRAL_NOTIFICATIONS_ENABLED
def get_traffic_packages(self) -> List[Dict]:
import logging
logger = logging.getLogger(__name__)
try:
packages = []
config_str = self.TRAFFIC_PACKAGES_CONFIG.strip()
logger.info(f"CONFIG STRING: '{config_str}'")
if not config_str:
logger.info("CONFIG EMPTY, USING FALLBACK")
return self._get_fallback_traffic_packages()
logger.info("PARSING CONFIG...")
for package_config in config_str.split(','):
package_config = package_config.strip()
if not package_config:
continue
parts = package_config.split(':')
if len(parts) != 3:
continue
try:
gb = int(parts[0])
price = int(parts[1])
enabled = parts[2].lower() == 'true'
packages.append({
"gb": gb,
"price": price,
"enabled": enabled
})
except ValueError:
continue
logger.info(f"PARSED {len(packages)} packages from config")
return packages if packages else self._get_fallback_traffic_packages()
except Exception as e:
logger.info(f"ERROR PARSING CONFIG: {e}")
return self._get_fallback_traffic_packages()
def _get_fallback_traffic_packages(self) -> List[Dict]:
try:
if self.TRAFFIC_PACKAGES_CONFIG.strip():
packages = []
for package_config in self.TRAFFIC_PACKAGES_CONFIG.split(','):
package_config = package_config.strip()
if not package_config:
continue
parts = package_config.split(':')
if len(parts) != 3:
continue
try:
gb = int(parts[0])
price = int(parts[1])
enabled = parts[2].lower() == 'true'
packages.append({
"gb": gb,
"price": price,
"enabled": enabled
})
except ValueError:
continue
if packages:
return packages
except Exception as e:
pass
return [
{"gb": 5, "price": self.PRICE_TRAFFIC_5GB, "enabled": True},
{"gb": 10, "price": self.PRICE_TRAFFIC_10GB, "enabled": True},
{"gb": 25, "price": self.PRICE_TRAFFIC_25GB, "enabled": True},
{"gb": 50, "price": self.PRICE_TRAFFIC_50GB, "enabled": True},
{"gb": 100, "price": self.PRICE_TRAFFIC_100GB, "enabled": True},
{"gb": 250, "price": self.PRICE_TRAFFIC_250GB, "enabled": True},
{"gb": 500, "price": self.PRICE_TRAFFIC_500GB, "enabled": True},
{"gb": 1000, "price": self.PRICE_TRAFFIC_1000GB, "enabled": True},
{"gb": 0, "price": self.PRICE_TRAFFIC_UNLIMITED, "enabled": True},
]
def get_traffic_price(self, gb: int) -> int:
packages = self.get_traffic_packages()
for package in packages:
if package["gb"] == gb and package["enabled"]:
return package["price"]
enabled_packages = [pkg for pkg in packages if pkg["enabled"]]
if not enabled_packages:
return 0
unlimited_package = next((pkg for pkg in enabled_packages if pkg["gb"] == 0), None)
finite_packages = [pkg for pkg in enabled_packages if pkg["gb"] > 0]
if finite_packages:
max_package = max(finite_packages, key=lambda x: x["gb"])
if gb > max_package["gb"]:
if unlimited_package:
return unlimited_package["price"]
else:
return max_package["price"]
suitable_packages = [pkg for pkg in finite_packages if pkg["gb"] >= gb]
if suitable_packages:
nearest_package = min(suitable_packages, key=lambda x: x["gb"])
return nearest_package["price"]
if unlimited_package:
return unlimited_package["price"]
return 0
model_config = {
"env_file": ".env",
@@ -300,12 +455,14 @@ PERIOD_PRICES = {
360: settings.PRICE_360_DAYS,
}
TRAFFIC_PRICES = {
5: settings.PRICE_TRAFFIC_5GB,
10: settings.PRICE_TRAFFIC_10GB,
25: settings.PRICE_TRAFFIC_25GB,
50: settings.PRICE_TRAFFIC_50GB,
100: settings.PRICE_TRAFFIC_100GB,
250: settings.PRICE_TRAFFIC_250GB,
0: settings.PRICE_TRAFFIC_UNLIMITED,
}
def get_traffic_prices() -> Dict[int, int]:
packages = settings.get_traffic_packages()
return {package["gb"]: package["price"] for package in packages}
TRAFFIC_PRICES = get_traffic_prices()
def refresh_traffic_prices():
global TRAFFIC_PRICES
TRAFFIC_PRICES = get_traffic_prices()
refresh_traffic_prices()
+81 -44
View File
@@ -91,6 +91,7 @@ async def get_referral_earnings_sum(
async def get_referral_statistics(db: AsyncSession) -> dict:
users_with_referrals_result = await db.execute(
select(func.count(func.distinct(User.id)))
.where(User.referred_by_id.isnot(None))
@@ -117,41 +118,72 @@ async def get_referral_statistics(db: AsyncSession) -> dict:
total_paid = referral_paid + transaction_paid
top_referrers_result = await db.execute(
referrals_stats_result = await db.execute(
select(
User.referred_by_id.label('referrer_id'),
func.count(User.id).label('referrals_count'),
func.coalesce(func.sum(ReferralEarning.amount_kopeks), 0).label('referral_earnings'),
func.coalesce(func.sum(Transaction.amount_kopeks), 0).label('transaction_earnings')
)
.outerjoin(
ReferralEarning,
ReferralEarning.user_id == User.referred_by_id
)
.outerjoin(
Transaction,
and_(
Transaction.user_id == User.referred_by_id,
Transaction.type == TransactionType.REFERRAL_REWARD.value
)
)
.where(
and_(
User.referred_by_id.isnot(None),
User.referred_by_id != User.id
)
func.count(User.id).label('referrals_count')
)
.where(User.referred_by_id.isnot(None))
.group_by(User.referred_by_id)
.order_by(func.count(User.id).desc())
.limit(5)
)
top_referrers_raw = top_referrers_result.all()
referrals_stats = {row.referrer_id: row.referrals_count for row in referrals_stats_result.all()}
referral_earnings_result = await db.execute(
select(
ReferralEarning.user_id.label('referrer_id'),
func.sum(ReferralEarning.amount_kopeks).label('referral_earnings')
)
.group_by(ReferralEarning.user_id)
)
referral_earnings = {row.referrer_id: row.referral_earnings for row in referral_earnings_result.all()}
transaction_earnings_result = await db.execute(
select(
Transaction.user_id.label('referrer_id'),
func.sum(Transaction.amount_kopeks).label('transaction_earnings')
)
.where(Transaction.type == TransactionType.REFERRAL_REWARD.value)
.group_by(Transaction.user_id)
)
transaction_earnings = {row.referrer_id: row.transaction_earnings for row in transaction_earnings_result.all()}
top_referrers_data = {}
for referrer_id, count in referrals_stats.items():
if referrer_id not in top_referrers_data:
top_referrers_data[referrer_id] = {
'referrals_count': 0,
'total_earned': 0
}
top_referrers_data[referrer_id]['referrals_count'] = count
for referrer_id, earnings in referral_earnings.items():
if referrer_id not in top_referrers_data:
top_referrers_data[referrer_id] = {
'referrals_count': 0,
'total_earned': 0
}
top_referrers_data[referrer_id]['total_earned'] += earnings or 0
for referrer_id, earnings in transaction_earnings.items():
if referrer_id not in top_referrers_data:
top_referrers_data[referrer_id] = {
'referrals_count': 0,
'total_earned': 0
}
top_referrers_data[referrer_id]['total_earned'] += earnings or 0
sorted_referrers = sorted(
top_referrers_data.items(),
key=lambda x: (x[1]['total_earned'], x[1]['referrals_count']),
reverse=True
)
top_referrers = []
for row in top_referrers_raw:
for referrer_id, stats in sorted_referrers[:5]:
user_result = await db.execute(
select(User.id, User.username, User.first_name, User.last_name, User.telegram_id)
.where(User.id == row.referrer_id)
.where(User.id == referrer_id)
)
user = user_result.first()
@@ -166,23 +198,22 @@ async def get_referral_statistics(db: AsyncSession) -> dict:
else:
display_name = f"ID{user.telegram_id}"
total_earned = (row.referral_earnings or 0) + (row.transaction_earnings or 0)
top_referrers.append({
"user_id": row.referrer_id,
"user_id": user.telegram_id,
"display_name": display_name,
"username": user.username,
"total_earned_kopeks": total_earned,
"referrals_count": row.referrals_count
"telegram_id": user.telegram_id,
"total_earned_kopeks": stats['total_earned'],
"referrals_count": stats['referrals_count']
})
today = datetime.utcnow().date()
today = datetime.utcnow().replace(hour=0, minute=0, second=0, microsecond=0)
today_referral_earnings = await db.execute(
today_referral_earnings_result = await db.execute(
select(func.coalesce(func.sum(ReferralEarning.amount_kopeks), 0))
.where(ReferralEarning.created_at >= today)
)
today_transaction_earnings = await db.execute(
today_transaction_earnings_result = await db.execute(
select(func.coalesce(func.sum(Transaction.amount_kopeks), 0))
.where(
and_(
@@ -191,14 +222,14 @@ async def get_referral_statistics(db: AsyncSession) -> dict:
)
)
)
today_earnings = today_referral_earnings.scalar() + today_transaction_earnings.scalar()
today_earnings = today_referral_earnings_result.scalar() + today_transaction_earnings_result.scalar()
week_ago = datetime.utcnow() - timedelta(days=7)
week_referral_earnings = await db.execute(
week_referral_earnings_result = await db.execute(
select(func.coalesce(func.sum(ReferralEarning.amount_kopeks), 0))
.where(ReferralEarning.created_at >= week_ago)
)
week_transaction_earnings = await db.execute(
week_transaction_earnings_result = await db.execute(
select(func.coalesce(func.sum(Transaction.amount_kopeks), 0))
.where(
and_(
@@ -207,14 +238,14 @@ async def get_referral_statistics(db: AsyncSession) -> dict:
)
)
)
week_earnings = week_referral_earnings.scalar() + week_transaction_earnings.scalar()
week_earnings = week_referral_earnings_result.scalar() + week_transaction_earnings_result.scalar()
month_ago = datetime.utcnow() - timedelta(days=30)
month_referral_earnings = await db.execute(
month_referral_earnings_result = await db.execute(
select(func.coalesce(func.sum(ReferralEarning.amount_kopeks), 0))
.where(ReferralEarning.created_at >= month_ago)
)
month_transaction_earnings = await db.execute(
month_transaction_earnings_result = await db.execute(
select(func.coalesce(func.sum(Transaction.amount_kopeks), 0))
.where(
and_(
@@ -223,7 +254,9 @@ async def get_referral_statistics(db: AsyncSession) -> dict:
)
)
)
month_earnings = month_referral_earnings.scalar() + month_transaction_earnings.scalar()
month_earnings = month_referral_earnings_result.scalar() + month_transaction_earnings_result.scalar()
logger.info(f"Реферальная статистика: {users_with_referrals} рефералов, {active_referrers} рефереров, выплачено {total_paid} копеек")
return {
"users_with_referrals": users_with_referrals,
@@ -248,13 +281,17 @@ async def get_user_referral_stats(db: AsyncSession, user_id: int) -> dict:
month_ago = datetime.utcnow() - timedelta(days=30)
month_earned = await get_referral_earnings_sum(db, user_id, start_date=month_ago)
from app.database.models import Subscription, SubscriptionStatus
current_time = datetime.utcnow()
active_referrals_result = await db.execute(
select(func.count(User.id))
.join(User.subscription)
.join(Subscription, User.id == Subscription.user_id)
.where(
and_(
User.referred_by_id == user_id,
User.subscription.has()
Subscription.status == SubscriptionStatus.ACTIVE.value,
Subscription.end_date > current_time
)
)
)
@@ -265,4 +302,4 @@ async def get_user_referral_stats(db: AsyncSession, user_id: int) -> dict:
"active_referrals": active_referrals,
"total_earned_kopeks": total_earned,
"month_earned_kopeks": month_earned
}
}
+187 -18
View File
@@ -9,6 +9,7 @@ from app.database.models import (
Subscription, SubscriptionStatus, User,
SubscriptionServer
)
from app.utils.pricing_utils import calculate_months_from_days, get_remaining_months
from app.config import settings
logger = logging.getLogger(__name__)
@@ -353,6 +354,37 @@ async def get_subscriptions_statistics(db: AsyncSession) -> dict:
)
purchased_month = month_result.scalar()
try:
from app.database.crud.subscription_conversion import get_conversion_statistics
conversion_stats = await get_conversion_statistics(db)
trial_to_paid_conversion = conversion_stats.get("conversion_rate", 0)
renewals_count = conversion_stats.get("month_conversions", 0)
logger.info(f"📊 Статистика конверсии из таблицы conversions:")
logger.info(f" Общее количество конверсий: {conversion_stats.get('total_conversions', 0)}")
logger.info(f" Процент конверсии: {trial_to_paid_conversion}%")
logger.info(f" Конверсий за месяц: {renewals_count}")
except ImportError:
logger.warning("⚠️ Таблица subscription_conversions не найдена, используем старую логику")
users_with_paid_result = await db.execute(
select(func.count(User.id))
.where(User.has_had_paid_subscription == True)
)
users_with_paid = users_with_paid_result.scalar()
total_users_result = await db.execute(select(func.count(User.id)))
total_users = total_users_result.scalar()
if total_users > 0:
trial_to_paid_conversion = round((users_with_paid / total_users) * 100, 1)
else:
trial_to_paid_conversion = 0
renewals_count = 0
return {
"total_subscriptions": total_subscriptions,
"active_subscriptions": active_subscriptions,
@@ -360,10 +392,11 @@ async def get_subscriptions_statistics(db: AsyncSession) -> dict:
"paid_subscriptions": paid_subscriptions,
"purchased_today": purchased_today,
"purchased_week": purchased_week,
"purchased_month": purchased_month
"purchased_month": purchased_month,
"trial_to_paid_conversion": trial_to_paid_conversion,
"renewals_count": renewals_count
}
async def update_subscription_usage(
db: AsyncSession,
subscription: Subscription,
@@ -407,9 +440,19 @@ async def add_subscription_servers(
server_squad_ids: List[int],
paid_prices: List[int] = None
) -> Subscription:
if paid_prices is None:
paid_prices = [0] * len(server_squad_ids)
months_remaining = get_remaining_months(subscription.end_date)
paid_prices = []
from app.database.models import ServerSquad
for server_id in server_squad_ids:
result = await db.execute(
select(ServerSquad.price_kopeks)
.where(ServerSquad.id == server_id)
)
server_price_per_month = result.scalar() or 0
total_price_for_period = server_price_per_month * months_remaining
paid_prices.append(total_price_for_period)
for i, server_id in enumerate(server_squad_ids):
subscription_server = SubscriptionServer(
@@ -422,9 +465,79 @@ async def add_subscription_servers(
await db.commit()
await db.refresh(subscription)
logger.info(f"🌍 К подписке {subscription.id} добавлено {len(server_squad_ids)} серверов")
logger.info(f"🌍 К подписке {subscription.id} добавлено {len(server_squad_ids)} серверов с ценами: {paid_prices}")
return subscription
async def get_server_monthly_price(
db: AsyncSession,
server_squad_id: int
) -> int:
from app.database.models import ServerSquad
result = await db.execute(
select(ServerSquad.price_kopeks)
.where(ServerSquad.id == server_squad_id)
)
return result.scalar() or 0
async def get_servers_monthly_prices(
db: AsyncSession,
server_squad_ids: List[int]
) -> List[int]:
prices = []
for server_id in server_squad_ids:
price = await get_server_monthly_price(db, server_id)
prices.append(price)
return prices
async def calculate_subscription_total_cost(
db: AsyncSession,
period_days: int,
traffic_gb: int,
server_squad_ids: List[int],
devices: int
) -> Tuple[int, dict]:
from app.config import PERIOD_PRICES
months_in_period = calculate_months_from_days(period_days)
base_price = PERIOD_PRICES.get(period_days, 0)
traffic_price_per_month = settings.get_traffic_price(traffic_gb)
total_traffic_price = traffic_price_per_month * months_in_period
servers_prices = await get_servers_monthly_prices(db, server_squad_ids)
servers_price_per_month = sum(servers_prices)
total_servers_price = servers_price_per_month * months_in_period
additional_devices = max(0, devices - settings.DEFAULT_DEVICE_LIMIT)
devices_price_per_month = additional_devices * settings.PRICE_PER_DEVICE
total_devices_price = devices_price_per_month * months_in_period
total_cost = base_price + total_traffic_price + total_servers_price + total_devices_price
details = {
'base_price': base_price,
'traffic_price_per_month': traffic_price_per_month,
'total_traffic_price': total_traffic_price,
'servers_price_per_month': servers_price_per_month,
'total_servers_price': total_servers_price,
'devices_price_per_month': devices_price_per_month,
'total_devices_price': total_devices_price,
'months_in_period': months_in_period,
'servers_individual_prices': [price * months_in_period for price in servers_prices]
}
logger.info(f"📊 Расчет стоимости подписки на {period_days} дней ({months_in_period} мес):")
logger.info(f" Базовый период: {base_price/100}")
logger.info(f" Трафик: {traffic_price_per_month/100}₽/мес × {months_in_period} = {total_traffic_price/100}")
logger.info(f" Серверы: {servers_price_per_month/100}₽/мес × {months_in_period} = {total_servers_price/100}")
logger.info(f" Устройства: {devices_price_per_month/100}₽/мес × {months_in_period} = {total_devices_price/100}")
logger.info(f" ИТОГО: {total_cost/100}")
return total_cost, details
async def get_subscription_server_ids(
db: AsyncSession,
subscription_id: int
@@ -497,30 +610,43 @@ async def get_subscription_renewal_cost(
period_days: int
) -> int:
try:
from app.config import PERIOD_PRICES, TRAFFIC_PRICES, settings
from app.config import PERIOD_PRICES
months_in_period = calculate_months_from_days(period_days)
base_price = PERIOD_PRICES.get(period_days, 0)
servers_info = await get_subscription_servers(db, subscription_id)
servers_cost = sum(server_info['paid_price_kopeks'] for server_info in servers_info)
subscription = await db.get(Subscription, subscription_id)
if not subscription:
return base_price
traffic_cost = 0
if subscription.traffic_limit_gb > 0:
traffic_cost = TRAFFIC_PRICES.get(subscription.traffic_limit_gb, 0)
servers_info = await get_subscription_servers(db, subscription_id)
servers_price_per_month = 0
for server_info in servers_info:
from app.database.models import ServerSquad
result = await db.execute(
select(ServerSquad.price_kopeks)
.where(ServerSquad.id == server_info['server_id'])
)
current_server_price = result.scalar() or 0
servers_price_per_month += current_server_price
devices_cost = max(0, subscription.device_limit - settings.DEFAULT_DEVICE_LIMIT) * settings.PRICE_PER_DEVICE
total_servers_cost = servers_price_per_month * months_in_period
total_cost = base_price + servers_cost + traffic_cost + devices_cost
traffic_price_per_month = settings.get_traffic_price(subscription.traffic_limit_gb)
total_traffic_cost = traffic_price_per_month * months_in_period
logger.info(f"💰 Расчет продления подписки {subscription_id} на {period_days} дней:")
additional_devices = max(0, subscription.device_limit - settings.DEFAULT_DEVICE_LIMIT)
devices_price_per_month = additional_devices * settings.PRICE_PER_DEVICE
total_devices_cost = devices_price_per_month * months_in_period
total_cost = base_price + total_servers_cost + total_traffic_cost + total_devices_cost
logger.info(f"💰 Расчет продления подписки {subscription_id} на {period_days} дней ({months_in_period} мес):")
logger.info(f" 📅 Период: {base_price/100}")
logger.info(f" 🌍 Серверы: {servers_cost/100}")
logger.info(f" 📊 Трафик: {traffic_cost/100}")
logger.info(f" 📱 Устройства: {devices_cost/100}")
logger.info(f" 🌍 Серверы: {servers_price_per_month/100}₽/мес × {months_in_period} = {total_servers_cost/100}")
logger.info(f" 📊 Трафик: {traffic_price_per_month/100}₽/мес × {months_in_period} = {total_traffic_cost/100}")
logger.info(f" 📱 Устройства: {devices_price_per_month/100}₽/мес × {months_in_period} = {total_devices_cost/100}")
logger.info(f" 💎 ИТОГО: {total_cost/100}")
return total_cost
@@ -530,6 +656,49 @@ async def get_subscription_renewal_cost(
from app.config import PERIOD_PRICES
return PERIOD_PRICES.get(period_days, 0)
async def calculate_addon_cost_for_remaining_period(
db: AsyncSession,
subscription: Subscription,
additional_traffic_gb: int = 0,
additional_devices: int = 0,
additional_server_ids: List[int] = None
) -> int:
if additional_server_ids is None:
additional_server_ids = []
months_to_pay = get_remaining_months(subscription.end_date)
total_cost = 0
if additional_traffic_gb > 0:
traffic_price_per_month = settings.get_traffic_price(additional_traffic_gb)
traffic_total_cost = traffic_price_per_month * months_to_pay
total_cost += traffic_total_cost
logger.info(f"Трафик +{additional_traffic_gb}ГБ: {traffic_price_per_month/100}₽/мес × {months_to_pay} = {traffic_total_cost/100}")
if additional_devices > 0:
devices_price_per_month = additional_devices * settings.PRICE_PER_DEVICE
devices_total_cost = devices_price_per_month * months_to_pay
total_cost += devices_total_cost
logger.info(f"Устройства +{additional_devices}: {devices_price_per_month/100}₽/мес × {months_to_pay} = {devices_total_cost/100}")
if additional_server_ids:
from app.database.models import ServerSquad
for server_id in additional_server_ids:
result = await db.execute(
select(ServerSquad.price_kopeks, ServerSquad.display_name)
.where(ServerSquad.id == server_id)
)
server_data = result.first()
if server_data:
server_price_per_month, server_name = server_data
server_total_cost = server_price_per_month * months_to_pay
total_cost += server_total_cost
logger.info(f"Сервер {server_name}: {server_price_per_month/100}₽/мес × {months_to_pay} = {server_total_cost/100}")
logger.info(f"💰 Итого доплата за {months_to_pay} мес: {total_cost/100}")
return total_cost
async def expire_subscription(
db: AsyncSession,
subscription: Subscription
@@ -0,0 +1,118 @@
import logging
from datetime import datetime, timedelta
from typing import Optional, List
from sqlalchemy import select, func
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.models import SubscriptionConversion, User
logger = logging.getLogger(__name__)
async def create_subscription_conversion(
db: AsyncSession,
user_id: int,
trial_duration_days: int,
payment_method: str,
first_payment_amount_kopeks: int,
first_paid_period_days: int
) -> SubscriptionConversion:
conversion = SubscriptionConversion(
user_id=user_id,
converted_at=datetime.utcnow(),
trial_duration_days=trial_duration_days,
payment_method=payment_method,
first_payment_amount_kopeks=first_payment_amount_kopeks,
first_paid_period_days=first_paid_period_days
)
db.add(conversion)
await db.commit()
await db.refresh(conversion)
logger.info(f"✅ Создана запись о конверсии для пользователя {user_id}: {trial_duration_days} дн. → {first_paid_period_days} дн. за {first_payment_amount_kopeks/100}")
return conversion
async def get_conversion_by_user_id(
db: AsyncSession,
user_id: int
) -> Optional[SubscriptionConversion]:
result = await db.execute(
select(SubscriptionConversion)
.where(SubscriptionConversion.user_id == user_id)
.order_by(SubscriptionConversion.converted_at.desc())
.limit(1)
)
return result.scalar_one_or_none()
async def get_conversion_statistics(db: AsyncSession) -> dict:
total_conversions_result = await db.execute(
select(func.count(SubscriptionConversion.id))
)
total_conversions = total_conversions_result.scalar()
users_with_paid_result = await db.execute(
select(func.count(User.id))
.where(User.has_had_paid_subscription == True)
)
users_with_paid = users_with_paid_result.scalar()
if total_conversions > 0:
conversion_rate = round((total_conversions / max(total_conversions, users_with_paid)) * 100, 1)
elif users_with_paid > 0:
conversion_rate = 100.0
else:
conversion_rate = 0.0
avg_trial_duration_result = await db.execute(
select(func.avg(SubscriptionConversion.trial_duration_days))
)
avg_trial_duration = avg_trial_duration_result.scalar() or 0
avg_first_payment_result = await db.execute(
select(func.avg(SubscriptionConversion.first_payment_amount_kopeks))
)
avg_first_payment = avg_first_payment_result.scalar() or 0
month_ago = datetime.utcnow() - timedelta(days=30)
month_conversions_result = await db.execute(
select(func.count(SubscriptionConversion.id))
.where(SubscriptionConversion.converted_at >= month_ago)
)
month_conversions = month_conversions_result.scalar()
logger.info(f"📊 Статистика конверсий:")
logger.info(f" Всего записей о конверсиях: {total_conversions}")
logger.info(f" Пользователей с платными подписками: {users_with_paid}")
logger.info(f" Рассчитанная конверсия: {conversion_rate}%")
return {
"total_conversions": total_conversions,
"conversion_rate": conversion_rate,
"avg_trial_duration_days": round(avg_trial_duration, 1),
"avg_first_payment_rubles": round((avg_first_payment or 0) / 100, 2),
"month_conversions": month_conversions
}
async def get_users_had_trial_count(db: AsyncSession) -> int:
conversions_count_result = await db.execute(
select(func.count(func.distinct(SubscriptionConversion.user_id)))
)
conversions_count = conversions_count_result.scalar()
paid_users_result = await db.execute(
select(func.count(User.id))
.where(User.has_had_paid_subscription == True)
)
paid_users_count = paid_users_result.scalar()
return max(conversions_count, paid_users_count)
+28 -12
View File
@@ -125,27 +125,43 @@ async def add_user_balance(
db: AsyncSession,
user: User,
amount_kopeks: int,
description: str = "Пополнение баланса"
description: str = "Пополнение баланса",
create_transaction: bool = True,
bot = None
) -> bool:
try:
old_balance = user.balance_kopeks
user.balance_kopeks += amount_kopeks
user.updated_at = datetime.utcnow()
from app.database.crud.transaction import create_transaction
from app.database.models import TransactionType
await create_transaction(
db=db,
user_id=user.id,
type=TransactionType.DEPOSIT,
amount_kopeks=amount_kopeks,
description=description
)
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,
amount_kopeks=amount_kopeks,
description=description
)
await db.commit()
await db.refresh(user)
topup_keywords = ["пополнение", "stars", "yookassa", "topup"]
exclude_keywords = ["комиссия", "бонус", "реферальн", "выплата", "вознаграждение"]
has_topup_keywords = any(word in description.lower() for word in topup_keywords)
has_exclude_keywords = any(word in description.lower() for word in exclude_keywords)
if has_topup_keywords and not has_exclude_keywords:
try:
from app.services.referral_service import process_referral_topup
await process_referral_topup(db, user.id, amount_kopeks, bot)
except Exception as e:
logger.error(f"Ошибка обработки реферального пополнения: {e}")
logger.info(f"💰 Баланс пользователя {user.telegram_id} изменен: {old_balance}{user.balance_kopeks} (изменение: +{amount_kopeks})")
return True
@@ -153,7 +169,7 @@ async def add_user_balance(
logger.error(f"Ошибка изменения баланса пользователя {user.id}: {e}")
await db.rollback()
return False
async def add_user_balance_by_id(
db: AsyncSession,
telegram_id: int,
+29 -1
View File
@@ -7,7 +7,7 @@ from sqlalchemy import (
ForeignKey, Float, JSON, BigInteger
)
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import relationship, Mapped
from sqlalchemy.orm import relationship, Mapped, mapped_column
from sqlalchemy.sql import func
@@ -125,6 +125,7 @@ class User(Base):
trojan_password = Column(String(255), nullable=True)
vless_uuid = Column(String(255), nullable=True)
ss_password = Column(String(255), nullable=True)
has_made_first_topup: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
@property
def balance_rubles(self) -> float:
@@ -333,6 +334,33 @@ class Transaction(Base):
def amount_rubles(self) -> float:
return self.amount_kopeks / 100
class SubscriptionConversion(Base):
__tablename__ = "subscription_conversions"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
converted_at = Column(DateTime, default=func.now())
trial_duration_days = Column(Integer, nullable=True)
payment_method = Column(String(50), nullable=True)
first_payment_amount_kopeks = Column(Integer, nullable=True)
first_paid_period_days = Column(Integer, nullable=True)
created_at = Column(DateTime, default=func.now())
user = relationship("User", backref="subscription_conversions")
@property
def first_payment_amount_rubles(self) -> float:
return (self.first_payment_amount_kopeks or 0) / 100
def __repr__(self):
return f"<SubscriptionConversion(user_id={self.user_id}, converted_at={self.converted_at})>"
class PromoCode(Base):
__tablename__ = "promocodes"
+221 -2
View File
@@ -241,6 +241,132 @@ async def add_remnawave_v2_columns():
logger.error(f"Ошибка при добавлении колонок RemnaWave v2.1.5: {e}")
return 0
async def add_referral_system_columns():
logger.info("=== МИГРАЦИЯ РЕФЕРАЛЬНОЙ СИСТЕМЫ ===")
try:
async with engine.begin() as conn:
db_type = await get_database_type()
column_exists = await check_column_exists('users', 'has_made_first_topup')
if not column_exists:
logger.info("Добавление колонки has_made_first_topup в таблицу users")
if db_type == 'sqlite':
column_def = 'BOOLEAN DEFAULT 0'
else:
column_def = 'BOOLEAN DEFAULT FALSE'
await conn.execute(text(f"ALTER TABLE users ADD COLUMN has_made_first_topup {column_def}"))
logger.info("Колонка has_made_first_topup успешно добавлена")
logger.info("Обновление существующих пользователей...")
if db_type == 'sqlite':
update_sql = """
UPDATE users
SET has_made_first_topup = 1
WHERE balance_kopeks > 0 OR has_had_paid_subscription = 1
"""
else:
update_sql = """
UPDATE users
SET has_made_first_topup = TRUE
WHERE balance_kopeks > 0 OR has_had_paid_subscription = TRUE
"""
result = await conn.execute(text(update_sql))
updated_count = result.rowcount
logger.info(f"Обновлено {updated_count} пользователей с has_made_first_topup = TRUE")
logger.info("✅ Миграция реферальной системы завершена")
return True
else:
logger.info("Колонка has_made_first_topup уже существует")
return True
except Exception as e:
logger.error(f"Ошибка миграции реферальной системы: {e}")
return False
async def create_subscription_conversions_table():
table_exists = await check_table_exists('subscription_conversions')
if table_exists:
logger.info("Таблица subscription_conversions уже существует")
return True
try:
async with engine.begin() as conn:
db_type = await get_database_type()
if db_type == 'sqlite':
create_sql = """
CREATE TABLE subscription_conversions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
converted_at DATETIME DEFAULT CURRENT_TIMESTAMP,
trial_duration_days INTEGER NULL,
payment_method VARCHAR(50) NULL,
first_payment_amount_kopeks INTEGER NULL,
first_paid_period_days INTEGER NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id)
);
CREATE INDEX idx_subscription_conversions_user_id ON subscription_conversions(user_id);
CREATE INDEX idx_subscription_conversions_converted_at ON subscription_conversions(converted_at);
"""
elif db_type == 'postgresql':
create_sql = """
CREATE TABLE subscription_conversions (
id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL,
converted_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
trial_duration_days INTEGER NULL,
payment_method VARCHAR(50) NULL,
first_payment_amount_kopeks INTEGER NULL,
first_paid_period_days INTEGER NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id)
);
CREATE INDEX idx_subscription_conversions_user_id ON subscription_conversions(user_id);
CREATE INDEX idx_subscription_conversions_converted_at ON subscription_conversions(converted_at);
"""
elif db_type == 'mysql':
create_sql = """
CREATE TABLE subscription_conversions (
id INT AUTO_INCREMENT PRIMARY KEY,
user_id INT NOT NULL,
converted_at DATETIME DEFAULT CURRENT_TIMESTAMP,
trial_duration_days INT NULL,
payment_method VARCHAR(50) NULL,
first_payment_amount_kopeks INT NULL,
first_paid_period_days INT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id)
);
CREATE INDEX idx_subscription_conversions_user_id ON subscription_conversions(user_id);
CREATE INDEX idx_subscription_conversions_converted_at ON subscription_conversions(converted_at);
"""
else:
logger.error(f"Неподдерживаемый тип БД для создания таблицы: {db_type}")
return False
await conn.execute(text(create_sql))
logger.info("✅ Таблица subscription_conversions успешно создана")
return True
except Exception as e:
logger.error(f"Ошибка создания таблицы subscription_conversions: {e}")
return False
async def fix_subscription_duplicates_universal():
async with engine.begin() as conn:
@@ -321,7 +447,6 @@ async def fix_subscription_duplicates_universal():
raise
async def run_universal_migration():
logger.info("=== НАЧАЛО УНИВЕРСАЛЬНОЙ МИГРАЦИИ ===")
try:
@@ -330,6 +455,10 @@ async def run_universal_migration():
await add_remnawave_v2_columns()
referral_migration_success = await add_referral_system_columns()
if not referral_migration_success:
logger.warning("⚠️ Проблемы с миграцией реферальной системы")
logger.info("=== СОЗДАНИЕ ТАБЛИЦЫ YOOKASSA ===")
yookassa_created = await create_yookassa_payments_table()
if yookassa_created:
@@ -337,6 +466,13 @@ async def run_universal_migration():
else:
logger.warning("⚠️ Проблемы с таблицей YooKassa payments")
logger.info("=== СОЗДАНИЕ ТАБЛИЦЫ КОНВЕРСИЙ ПОДПИСОК ===")
conversions_created = await create_subscription_conversions_table()
if conversions_created:
logger.info("✅ Таблица subscription_conversions готова")
else:
logger.warning("⚠️ Проблемы с таблицей subscription_conversions")
async with engine.begin() as conn:
total_subs = await conn.execute(text("SELECT COUNT(*) FROM subscriptions"))
unique_users = await conn.execute(text("SELECT COUNT(DISTINCT user_id) FROM subscriptions"))
@@ -349,6 +485,7 @@ async def run_universal_migration():
if total_count == unique_count:
logger.info("База данных уже в корректном состоянии")
logger.info("=== МИГРАЦИЯ ЗАВЕРШЕНА УСПЕШНО ===")
return True
deleted_count = await fix_subscription_duplicates_universal()
@@ -368,8 +505,90 @@ async def run_universal_migration():
return False
else:
logger.info("=== МИГРАЦИЯ ЗАВЕРШЕНА УСПЕШНО ===")
logger.info("✅ Реферальная система обновлена")
logger.info("✅ RemnaWave v2.1.5 колонки добавлены")
logger.info("✅ YooKassa таблица готова")
logger.info("✅ Таблица конверсий подписок создана")
logger.info("✅ Дубликаты подписок исправлены")
return True
except Exception as e:
logger.error(f"=== ОШИБКА ВЫПОЛНЕНИЯ МИГРАЦИИ: {e} ===")
return False
return False
async def check_migration_status():
logger.info("=== ПРОВЕРКА СТАТУСА МИГРАЦИЙ ===")
try:
status = {
"has_made_first_topup_column": False,
"yookassa_table": False,
"remnawave_v2_columns": False,
"subscription_duplicates": False,
"subscription_conversions_table": False
}
status["has_made_first_topup_column"] = await check_column_exists('users', 'has_made_first_topup')
status["yookassa_table"] = await check_table_exists('yookassa_payments')
status["subscription_conversions_table"] = await check_table_exists('subscription_conversions')
remnawave_columns = ['lifetime_used_traffic_bytes', 'last_remnawave_sync', 'trojan_password', 'vless_uuid', 'ss_password']
remnawave_status = []
for col in remnawave_columns:
exists = await check_column_exists('users', col)
remnawave_status.append(exists)
status["remnawave_v2_columns"] = all(remnawave_status)
async with engine.begin() as conn:
duplicates_check = await conn.execute(text("""
SELECT COUNT(*) FROM (
SELECT user_id, COUNT(*) as count
FROM subscriptions
GROUP BY user_id
HAVING COUNT(*) > 1
) as dups
"""))
duplicates_count = duplicates_check.fetchone()[0]
status["subscription_duplicates"] = (duplicates_count == 0)
check_names = {
"has_made_first_topup_column": "Колонка реферальной системы",
"yookassa_table": "Таблица YooKassa payments",
"subscription_conversions_table": "Таблица конверсий подписок",
"remnawave_v2_columns": "Колонки RemnaWave v2.1.5",
"subscription_duplicates": "Отсутствие дубликатов подписок"
}
for check_key, check_status in status.items():
check_name = check_names.get(check_key, check_key)
icon = "" if check_status else ""
logger.info(f"{icon} {check_name}: {'OK' if check_status else 'ТРЕБУЕТ ВНИМАНИЯ'}")
all_good = all(status.values())
if all_good:
logger.info("🎉 Все миграции выполнены успешно!")
try:
async with engine.begin() as conn:
conversions_count = await conn.execute(text("SELECT COUNT(*) FROM subscription_conversions"))
users_count = await conn.execute(text("SELECT COUNT(*) FROM users"))
conv_count = conversions_count.fetchone()[0]
usr_count = users_count.fetchone()[0]
logger.info(f"📊 Статистика: {usr_count} пользователей, {conv_count} конверсий записано")
except Exception as stats_error:
logger.debug(f"Не удалось получить дополнительную статистику: {stats_error}")
else:
logger.warning("⚠️ Некоторые миграции требуют внимания")
missing_migrations = [check_names[k] for k, v in status.items() if not v]
logger.warning(f"Требуют выполнения: {', '.join(missing_migrations)}")
return status
except Exception as e:
logger.error(f"Ошибка проверки статуса миграций: {e}")
return None
+22 -10
View File
@@ -13,6 +13,14 @@ class TelegramStarsService:
def __init__(self, bot: Bot):
self.bot = bot
@staticmethod
def calculate_stars_from_rubles(rubles: float) -> int:
return settings.rubles_to_stars(rubles)
@staticmethod
def calculate_rubles_from_stars(stars: int) -> float:
return settings.stars_to_rubles(stars)
async def create_invoice(
self,
chat_id: int,
@@ -23,7 +31,8 @@ class TelegramStarsService:
start_parameter: Optional[str] = None
) -> Optional[str]:
try:
stars_amount = max(1, amount_kopeks // 100)
amount_rubles = amount_kopeks / 100
stars_amount = self.calculate_stars_from_rubles(amount_rubles)
invoice_link = await self.bot.create_invoice_link(
title=title,
@@ -35,7 +44,10 @@ class TelegramStarsService:
start_parameter=start_parameter
)
logger.info(f"Создан Stars invoice на {stars_amount} звезд для {chat_id}")
logger.info(
f"Создан Stars invoice на {stars_amount} звезд (~{amount_rubles:.2f}₽) "
f"для {chat_id}, курс: {settings.get_stars_rate():.2f}₽/⭐"
)
return invoice_link
except Exception as e:
@@ -52,7 +64,8 @@ class TelegramStarsService:
keyboard: Optional[InlineKeyboardMarkup] = None
) -> Optional[Dict[str, Any]]:
try:
stars_amount = max(1, amount_kopeks // 100)
amount_rubles = amount_kopeks / 100
stars_amount = self.calculate_stars_from_rubles(amount_rubles)
message = await self.bot.send_invoice(
chat_id=chat_id,
@@ -65,10 +78,14 @@ class TelegramStarsService:
reply_markup=keyboard
)
logger.info(f"Отправлен Stars invoice {message.message_id} на {stars_amount} звезд")
logger.info(
f"Отправлен Stars invoice {message.message_id} на {stars_amount} звезд "
f"(~{amount_rubles:.2f}₽), курс: {settings.get_stars_rate():.2f}₽/⭐"
)
return {
"message_id": message.message_id,
"stars_amount": stars_amount,
"rubles_amount": amount_rubles,
"payload": payload
}
@@ -88,13 +105,8 @@ class TelegramStarsService:
ok=ok,
error_message=error_message
)
logger.info(f"Ответ на pre_checkout_query: ok={ok}")
return True
except Exception as e:
logger.error(f"Ошибка ответа на pre_checkout_query: {e}")
return False
def calculate_stars_amount(self, rubles: float) -> int:
return max(1, int(rubles))
def calculate_rubles_from_stars(self, stars: int) -> float:
return float(stars)
+34
View File
@@ -969,6 +969,38 @@ async def show_promocode_stats(
await callback.message.edit_text(text, reply_markup=keyboard)
await callback.answer()
@admin_required
@error_handler
async def show_general_promocode_stats(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession
):
total_codes = await get_promocodes_count(db)
active_codes = await get_promocodes_count(db, is_active=True)
text = f"""
📊 <b>Общая статистика промокодов</b>
📈 <b>Основные показатели:</b>
- Всего промокодов: {total_codes}
- Активных: {active_codes}
- Неактивных: {total_codes - active_codes}
Для детальной статистики выберите конкретный промокод из списка.
"""
keyboard = types.InlineKeyboardMarkup(inline_keyboard=[
[
types.InlineKeyboardButton(text="🎫 К промокодам", callback_data="admin_promo_list")
],
[
types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_promocodes")
]
])
await callback.message.edit_text(text, reply_markup=keyboard)
await callback.answer()
def register_handlers(dp: Dispatcher):
dp.callback_query.register(show_promocodes_menu, F.data == "admin_promocodes")
@@ -984,6 +1016,7 @@ def register_handlers(dp: Dispatcher):
dp.callback_query.register(start_edit_promocode_amount, F.data.startswith("promo_edit_amount_"))
dp.callback_query.register(start_edit_promocode_days, F.data.startswith("promo_edit_days_"))
dp.callback_query.register(start_edit_promocode_uses, F.data.startswith("promo_edit_uses_"))
dp.callback_query.register(show_general_promocode_stats, F.data == "admin_promo_general_stats")
dp.callback_query.register(
show_promocode_edit_menu,
@@ -997,3 +1030,4 @@ def register_handlers(dp: Dispatcher):
dp.message.register(process_promocode_value, AdminStates.setting_promocode_value)
dp.message.register(process_promocode_uses, AdminStates.setting_promocode_uses)
dp.message.register(process_promocode_expiry, AdminStates.setting_promocode_expiry)
+174 -30
View File
@@ -1,6 +1,7 @@
import logging
from aiogram import Dispatcher, types, F
from sqlalchemy.ext.asyncio import AsyncSession
import datetime
from app.config import settings
from app.database.models import User
@@ -19,54 +20,197 @@ async def show_referral_statistics(
db_user: User,
db: AsyncSession
):
stats = await get_referral_statistics(db)
avg_per_referrer = 0
if stats['active_referrers'] > 0:
avg_per_referrer = stats['total_paid_kopeks'] / stats['active_referrers']
text = f"""
try:
stats = await get_referral_statistics(db)
avg_per_referrer = 0
if stats.get('active_referrers', 0) > 0:
avg_per_referrer = stats.get('total_paid_kopeks', 0) / stats['active_referrers']
current_time = datetime.datetime.now().strftime("%H:%M:%S")
text = f"""
🤝 <b>Реферальная статистика</b>
<b>Общие показатели:</b>
- Пользователей с рефералами: {stats['users_with_referrals']}
- Активных рефереров: {stats['active_referrers']}
- Выплачено всего: {settings.format_price(stats['total_paid_kopeks'])}
- Пользователей с рефералами: {stats.get('users_with_referrals', 0)}
- Активных рефереров: {stats.get('active_referrers', 0)}
- Выплачено всего: {settings.format_price(stats.get('total_paid_kopeks', 0))}
<b>За период:</b>
- Сегодня: {settings.format_price(stats['today_earnings_kopeks'])}
- За неделю: {settings.format_price(stats['week_earnings_kopeks'])}
- За месяц: {settings.format_price(stats['month_earnings_kopeks'])}
- Сегодня: {settings.format_price(stats.get('today_earnings_kopeks', 0))}
- За неделю: {settings.format_price(stats.get('week_earnings_kopeks', 0))}
- За месяц: {settings.format_price(stats.get('month_earnings_kopeks', 0))}
<b>Средние показатели:</b>
- На одного реферера: {settings.format_price(int(avg_per_referrer))}
<b>Топ-5 рефереров:</b>
"""
for i, referrer in enumerate(stats['top_referrers'][:5], 1):
text += f"{i}. ID {referrer['user_id']}: {settings.format_price(referrer['total_earned_kopeks'])} ({referrer['referrals_count']} реф.)\n"
if not stats['top_referrers']:
text += "Нет данных\n"
text += f"""
top_referrers = stats.get('top_referrers', [])
if top_referrers:
for i, referrer in enumerate(top_referrers[:5], 1):
earned = referrer.get('total_earned_kopeks', 0)
count = referrer.get('referrals_count', 0)
user_id = referrer.get('user_id', 'N/A')
if count > 0:
text += f"{i}. ID {user_id}: {settings.format_price(earned)} ({count} реф.)\n"
else:
logger.warning(f"Реферер {user_id} имеет {count} рефералов, но есть в топе")
else:
text += "Нет данных\n"
text += f"""
<b>Настройки:</b>
- Бонус за регистрацию: {settings.format_price(settings.REFERRAL_REGISTRATION_REWARD)}
<b>Настройки реферальной системы:</b>
- Минимальное пополнение: {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.REFERRED_USER_REWARD)}
- Комиссия: {settings.REFERRAL_COMMISSION_PERCENT}%
- Комиссия с покупок: {settings.REFERRAL_COMMISSION_PERCENT}%
- Уведомления: {'✅ Включены' if settings.REFERRAL_NOTIFICATIONS_ENABLED else '❌ Отключены'}
<i>🕐 Обновлено: {current_time}</i>
"""
await callback.message.edit_text(
text,
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
keyboard = types.InlineKeyboardMarkup(inline_keyboard=[
[types.InlineKeyboardButton(text="🔄 Обновить", callback_data="admin_referrals")],
[types.InlineKeyboardButton(text="👥 Топ рефереров", callback_data="admin_referrals_top")],
[types.InlineKeyboardButton(text="⚙️ Настройки", callback_data="admin_referrals_settings")],
[types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_panel")]
])
)
try:
await callback.message.edit_text(text, reply_markup=keyboard)
await callback.answer("Обновлено")
except Exception as edit_error:
if "message is not modified" in str(edit_error):
await callback.answer("Данные актуальны")
else:
logger.error(f"Ошибка редактирования сообщения: {edit_error}")
await callback.answer("Ошибка обновления")
except Exception as e:
logger.error(f"Ошибка в show_referral_statistics: {e}", exc_info=True)
current_time = datetime.datetime.now().strftime("%H:%M:%S")
text = f"""
🤝 <b>Реферальная статистика</b>
<b>Ошибка загрузки данных</b>
<b>Текущие настройки:</b>
- Минимальное пополнение: {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.REFERRED_USER_REWARD)}
- Комиссия с покупок: {settings.REFERRAL_COMMISSION_PERCENT}%
<i>🕐 Время: {current_time}</i>
"""
keyboard = types.InlineKeyboardMarkup(inline_keyboard=[
[types.InlineKeyboardButton(text="🔄 Повторить", callback_data="admin_referrals")],
[types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_panel")]
])
try:
await callback.message.edit_text(text, reply_markup=keyboard)
except:
pass
await callback.answer("Произошла ошибка при загрузке статистики")
@admin_required
@error_handler
async def show_top_referrers(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession
):
try:
stats = await get_referral_statistics(db)
top_referrers = stats.get('top_referrers', [])
text = "🏆 <b>Топ рефереров</b>\n\n"
if top_referrers:
for i, referrer in enumerate(top_referrers[:20], 1):
earned = referrer.get('total_earned_kopeks', 0)
count = referrer.get('referrals_count', 0)
display_name = referrer.get('display_name', 'N/A')
username = referrer.get('username', '')
telegram_id = referrer.get('telegram_id', 'N/A')
if username:
display_text = f"@{username} (ID{telegram_id})"
elif display_name and display_name != f"ID{telegram_id}":
display_text = f"{display_name} (ID{telegram_id})"
else:
display_text = f"ID{telegram_id}"
emoji = ""
if i == 1:
emoji = "🥇 "
elif i == 2:
emoji = "🥈 "
elif i == 3:
emoji = "🥉 "
text += f"{emoji}{i}. {display_text}\n"
text += f" 💰 {settings.format_price(earned)} | 👥 {count} реф.\n\n"
else:
text += "Нет данных о рефererах\n"
keyboard = types.InlineKeyboardMarkup(inline_keyboard=[
[types.InlineKeyboardButton(text="⬅️ К статистике", callback_data="admin_referrals")]
])
await callback.message.edit_text(text, reply_markup=keyboard)
await callback.answer()
except Exception as e:
logger.error(f"Ошибка в show_top_referrers: {e}", exc_info=True)
await callback.answer("Ошибка загрузки топа рефереров")
@admin_required
@error_handler
async def show_referral_settings(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession
):
text = f"""
<b>Настройки реферальной системы</b>
<b>Бонусы и награды:</b>
Минимальная сумма пополнения для участия: {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.REFERRED_USER_REWARD)}
<b>Комиссионные:</b>
Процент с каждой покупки реферала: {settings.REFERRAL_COMMISSION_PERCENT}%
<b>Уведомления:</b>
Статус: {'✅ Включены' if settings.REFERRAL_NOTIFICATIONS_ENABLED else '❌ Отключены'}
Попытки отправки: {getattr(settings, 'REFERRAL_NOTIFICATION_RETRY_ATTEMPTS', 3)}
<i>💡 Для изменения настроек отредактируйте файл .env и перезапустите бота</i>
"""
keyboard = types.InlineKeyboardMarkup(inline_keyboard=[
[types.InlineKeyboardButton(text="⬅️ К статистике", callback_data="admin_referrals")]
])
await callback.message.edit_text(text, reply_markup=keyboard)
await callback.answer()
def register_handlers(dp: Dispatcher):
dp.callback_query.register(show_referral_statistics, F.data == "admin_referrals")
dp.callback_query.register(show_referral_statistics, F.data == "admin_referrals")
dp.callback_query.register(show_top_referrers, F.data == "admin_referrals_top")
dp.callback_query.register(show_referral_settings, F.data == "admin_referrals_settings")
+1 -8
View File
@@ -51,9 +51,6 @@ async def show_servers_menu(
types.InlineKeyboardButton(text="📊 Синхронизировать счетчики", callback_data="admin_servers_sync_counts"),
types.InlineKeyboardButton(text="📈 Подробная статистика", callback_data="admin_servers_stats")
],
[
types.InlineKeyboardButton(text=" Добавить сервер", callback_data="admin_servers_add")
],
[
types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_subscriptions")
]
@@ -130,10 +127,6 @@ async def show_servers_list(
keyboard.append(nav_row)
keyboard.extend([
[
types.InlineKeyboardButton(text="🔄 Обновить", callback_data="admin_servers_list"),
types.InlineKeyboardButton(text=" Добавить", callback_data="admin_servers_add")
],
[types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_servers")]
])
@@ -959,4 +952,4 @@ def register_handlers(dp: Dispatcher):
dp.callback_query.register(delete_server_confirm, F.data.startswith("admin_server_delete_") & ~F.data.contains("confirm"))
dp.callback_query.register(delete_server_execute, F.data.startswith("admin_server_delete_confirm_"))
dp.callback_query.register(handle_servers_pagination, F.data.startswith("admin_servers_list_page_"))
dp.callback_query.register(handle_servers_pagination, F.data.startswith("admin_servers_list_page_"))
+91 -45
View File
@@ -50,6 +50,8 @@ async def show_users_statistics(
total_users = stats['total_users']
active_rate = format_percentage(stats['active_users'] / total_users * 100 if total_users > 0 else 0)
current_time = format_datetime(datetime.utcnow())
text = f"""
👥 <b>Статистика пользователей</b>
@@ -66,16 +68,27 @@ async def show_users_statistics(
<b>Активность:</b>
- Коэффициент активности: {active_rate}
- Рост за месяц: +{stats['new_month']} ({format_percentage(stats['new_month'] / total_users * 100 if total_users > 0 else 0)})
<b>Обновлено:</b> {current_time}
"""
await callback.message.edit_text(
text,
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
[types.InlineKeyboardButton(text="🔄 Обновить", callback_data="admin_stats_users")],
[types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_statistics")]
])
)
await callback.answer()
keyboard = types.InlineKeyboardMarkup(inline_keyboard=[
[types.InlineKeyboardButton(text="🔄 Обновить", callback_data="admin_stats_users")],
[types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_statistics")]
])
try:
await callback.message.edit_text(text, reply_markup=keyboard)
except Exception as e:
if "message is not modified" in str(e):
await callback.answer("📊 Данные актуальны", show_alert=False)
else:
logger.error(f"Ошибка обновления статистики пользователей: {e}")
await callback.answer("❌ Ошибка обновления данных", show_alert=True)
return
await callback.answer("✅ Статистика обновлена")
@admin_required
@@ -89,6 +102,7 @@ async def show_subscriptions_statistics(
total_subs = stats['total_subscriptions']
conversion_rate = format_percentage(stats['paid_subscriptions'] / total_subs * 100 if total_subs > 0 else 0)
current_time = format_datetime(datetime.utcnow())
text = f"""
📱 <b>Статистика подписок</b>
@@ -107,16 +121,24 @@ async def show_subscriptions_statistics(
- Сегодня: {stats['purchased_today']}
- За неделю: {stats['purchased_week']}
- За месяц: {stats['purchased_month']}
<b>Обновлено:</b> {current_time}
"""
await callback.message.edit_text(
text,
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
[types.InlineKeyboardButton(text="🔄 Обновить", callback_data="admin_stats_subs")],
[types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_statistics")]
])
)
await callback.answer()
keyboard = types.InlineKeyboardMarkup(inline_keyboard=[
[types.InlineKeyboardButton(text="🔄 Обновить", callback_data="admin_stats_subs")],
[types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_statistics")]
])
try:
await callback.message.edit_text(text, reply_markup=keyboard)
await callback.answer("✅ Статистика обновлена")
except Exception as e:
if "message is not modified" in str(e):
await callback.answer("📊 Данные актуальны", show_alert=False)
else:
logger.error(f"Ошибка обновления статистики подписок: {e}")
await callback.answer("❌ Ошибка обновления данных", show_alert=True)
@admin_required
@@ -131,6 +153,7 @@ async def show_revenue_statistics(
month_stats = await get_transactions_statistics(db, month_start, now)
all_time_stats = await get_transactions_statistics(db)
current_time = format_datetime(datetime.utcnow())
text = f"""
💰 <b>Статистика доходов</b>
@@ -156,15 +179,23 @@ async def show_revenue_statistics(
if method and data['count'] > 0:
text += f"{method}: {data['count']} ({settings.format_price(data['amount'])})\n"
await callback.message.edit_text(
text,
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
[types.InlineKeyboardButton(text="📈 Период", callback_data="admin_revenue_period")],
[types.InlineKeyboardButton(text="🔄 Обновить", callback_data="admin_stats_revenue")],
[types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_statistics")]
])
)
await callback.answer()
text += f"\n<b>Обновлено:</b> {current_time}"
keyboard = types.InlineKeyboardMarkup(inline_keyboard=[
# [types.InlineKeyboardButton(text="📈 Период", callback_data="admin_revenue_period")],
[types.InlineKeyboardButton(text="🔄 Обновить", callback_data="admin_stats_revenue")],
[types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_statistics")]
])
try:
await callback.message.edit_text(text, reply_markup=keyboard)
await callback.answer("✅ Статистика обновлена")
except Exception as e:
if "message is not modified" in str(e):
await callback.answer("📊 Данные актуальны", show_alert=False)
else:
logger.error(f"Ошибка обновления статистики доходов: {e}")
await callback.answer("❌ Ошибка обновления данных", show_alert=True)
@admin_required
@@ -175,6 +206,7 @@ async def show_referral_statistics(
db: AsyncSession
):
stats = await get_referral_statistics(db)
current_time = format_datetime(datetime.utcnow())
avg_per_referrer = 0
if stats['active_referrers'] > 0:
@@ -194,7 +226,7 @@ async def show_referral_statistics(
- За месяц: {settings.format_price(stats['month_earnings_kopeks'])}
<b>Средние показатели:</b>
- На одного реферера: {settings.format_price(int(avg_per_referrer))}
- На одного рефререра: {settings.format_price(int(avg_per_referrer))}
<b>Топ рефереры:</b>
"""
@@ -208,14 +240,22 @@ async def show_referral_statistics(
else:
text += "Пока нет активных рефереров"
await callback.message.edit_text(
text,
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
[types.InlineKeyboardButton(text="🔄 Обновить", callback_data="admin_stats_referrals")],
[types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_statistics")]
])
)
await callback.answer()
text += f"\n<b>Обновлено:</b> {current_time}"
keyboard = types.InlineKeyboardMarkup(inline_keyboard=[
[types.InlineKeyboardButton(text="🔄 Обновить", callback_data="admin_stats_referrals")],
[types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_statistics")]
])
try:
await callback.message.edit_text(text, reply_markup=keyboard)
await callback.answer("✅ Статистика обновлена")
except Exception as e:
if "message is not modified" in str(e):
await callback.answer("📊 Данные актуальны", show_alert=False)
else:
logger.error(f"Ошибка обновления реферальной статистики: {e}")
await callback.answer("❌ Ошибка обновления данных", show_alert=True)
@admin_required
@@ -232,6 +272,7 @@ async def show_summary_statistics(
now = datetime.utcnow()
month_start = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
revenue_stats = await get_transactions_statistics(db, month_start, now)
current_time = format_datetime(datetime.utcnow())
conversion_rate = 0
if user_stats['total_users'] > 0:
@@ -263,18 +304,23 @@ async def show_summary_statistics(
- Пользователи: +{user_stats['new_month']} за месяц
- Продажи: +{sub_stats['purchased_month']} за месяц
<b>Обновлено:</b> {format_datetime(datetime.utcnow())}
<b>Обновлено:</b> {current_time}
"""
await callback.message.edit_text(
text,
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
[types.InlineKeyboardButton(text="🔄 Обновить", callback_data="admin_stats_summary")],
[types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_statistics")]
])
)
await callback.answer()
keyboard = types.InlineKeyboardMarkup(inline_keyboard=[
[types.InlineKeyboardButton(text="🔄 Обновить", callback_data="admin_stats_summary")],
[types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_statistics")]
])
try:
await callback.message.edit_text(text, reply_markup=keyboard)
await callback.answer("✅ Статистика обновлена")
except Exception as e:
if "message is not modified" in str(e):
await callback.answer("📊 Данные актуальны", show_alert=False)
else:
logger.error(f"Ошибка обновления общей статистики: {e}")
await callback.answer("❌ Ошибка обновления данных", show_alert=True)
@admin_required
@error_handler
@@ -347,4 +393,4 @@ def register_handlers(dp: Dispatcher):
dp.callback_query.register(
show_revenue_by_period,
F.data == f"period_{period}"
)
)
+5 -5
View File
@@ -260,11 +260,11 @@ async def show_subscriptions_stats(
"""
keyboard = [
[
types.InlineKeyboardButton(text="📊 Экспорт данных", callback_data="admin_subs_export"),
types.InlineKeyboardButton(text="📈 Графики", callback_data="admin_subs_charts")
],
[types.InlineKeyboardButton(text="🔄 Обновить", callback_data="admin_subs_stats")],
# [
# types.InlineKeyboardButton(text="📊 Экспорт данных", callback_data="admin_subs_export"),
# types.InlineKeyboardButton(text="📈 Графики", callback_data="admin_subs_charts")
# ],
# [types.InlineKeyboardButton(text="🔄 Обновить", callback_data="admin_subs_stats")],
[types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_subscriptions")]
]
+784 -3
View File
@@ -16,6 +16,8 @@ from app.localization.texts import get_texts
from app.services.user_service import UserService
from app.utils.decorators import admin_required, error_handler
from app.utils.formatters import format_datetime, format_time_ago
from app.services.remnawave_service import RemnaWaveService
from app.database.crud.server_squad import get_all_server_squads, get_server_squad_by_uuid, get_server_squad_by_id
logger = logging.getLogger(__name__)
@@ -825,6 +827,8 @@ async def show_user_statistics(
user = profile["user"]
subscription = profile["subscription"]
referral_stats = await get_detailed_referral_stats(db, user.id)
text = f"📊 <b>Статистика пользователя</b>\n\n"
text += f"👤 {user.full_name} (ID: <code>{user.telegram_id}</code>)\n\n"
@@ -837,7 +841,7 @@ async def show_user_statistics(
text += f"<b>Подписка:</b>\n"
if subscription:
sub_status = "✅ Активна" if subscription.is_active else "❌ Неактивна"
sub_type = " (триал)" if subscription.is_trial else " (платная)"
sub_type = " (пробная)" if subscription.is_trial else " (платная)"
text += f"• Статус: {sub_status}{sub_type}\n"
text += f"• Трафик: {subscription.traffic_used_gb:.1f}/{subscription.traffic_limit_gb} ГБ\n"
text += f"• Устройства: {subscription.device_limit}\n"
@@ -846,11 +850,39 @@ async def show_user_statistics(
text += f"• Отсутствует\n"
text += f"\n<b>Реферальная программа:</b>\n"
if user.referred_by_id:
text += f"• Пришел по рефералке\n"
referrer = await get_user_by_id(db, user.referred_by_id)
if referrer:
text += f"• Пришел по реферальной ссылке от <b>{referrer.full_name}</b>\n"
else:
text += f"• Пришел по реферальной ссылке (реферер не найден)\n"
else:
text += f"• Прямая регистрация\n"
text += f"• Реферальный код: <code>{user.referral_code}</code>\n"
text += f"• Реферальный код: <code>{user.referral_code}</code>\n\n"
if referral_stats['invited_count'] > 0:
text += f"<b>Доходы от рефералов:</b>\n"
text += f"• Всего приглашено: {referral_stats['invited_count']}\n"
text += f"• Активных рефералов: {referral_stats['active_referrals']}\n"
text += f"• Общий доход: {settings.format_price(referral_stats['total_earned_kopeks'])}\n"
text += f"• Доход за месяц: {settings.format_price(referral_stats['month_earned_kopeks'])}\n"
if referral_stats['referrals_detail']:
text += f"\n<b>Детали по рефералам:</b>\n"
for detail in referral_stats['referrals_detail'][:5]:
referral_name = detail['referral_name']
earned = settings.format_price(detail['total_earned_kopeks'])
status = "🟢" if detail['is_active'] else "🔴"
text += f"{status} {referral_name}: {earned}\n"
if len(referral_stats['referrals_detail']) > 5:
text += f"• ... и еще {len(referral_stats['referrals_detail']) - 5} рефералов\n"
else:
text += f"<b>Реферальная программа:</b>\n"
text += f"• Рефералов нет\n"
text += f"• Доходов нет\n"
await callback.message.edit_text(
text,
@@ -860,6 +892,64 @@ async def show_user_statistics(
)
await callback.answer()
async def get_detailed_referral_stats(db: AsyncSession, user_id: int) -> dict:
from app.database.crud.referral import get_user_referral_stats, get_referral_earnings_by_user
from sqlalchemy import select, func
from sqlalchemy.orm import selectinload
base_stats = await get_user_referral_stats(db, user_id)
referrals_query = select(User).options(
selectinload(User.subscription)
).where(User.referred_by_id == user_id)
referrals_result = await db.execute(referrals_query)
referrals = referrals_result.scalars().all()
earnings_by_referral = {}
all_earnings = await get_referral_earnings_by_user(db, user_id)
for earning in all_earnings:
referral_id = earning.referral_id
if referral_id not in earnings_by_referral:
earnings_by_referral[referral_id] = 0
earnings_by_referral[referral_id] += earning.amount_kopeks
referrals_detail = []
current_time = datetime.utcnow()
for referral in referrals:
earned = earnings_by_referral.get(referral.id, 0)
is_active = False
if referral.subscription:
from app.database.models import SubscriptionStatus
is_active = (
referral.subscription.status == SubscriptionStatus.ACTIVE.value and
referral.subscription.end_date > current_time
)
referrals_detail.append({
'referral_id': referral.id,
'referral_name': referral.full_name,
'referral_telegram_id': referral.telegram_id,
'total_earned_kopeks': earned,
'is_active': is_active,
'registration_date': referral.created_at,
'has_subscription': bool(referral.subscription)
})
referrals_detail.sort(key=lambda x: x['total_earned_kopeks'], reverse=True)
return {
'invited_count': base_stats['invited_count'],
'active_referrals': base_stats['active_referrals'],
'total_earned_kopeks': base_stats['total_earned_kopeks'],
'month_earned_kopeks': base_stats['month_earned_kopeks'],
'referrals_detail': referrals_detail
}
@admin_required
@error_handler
async def extend_user_subscription(
@@ -1300,6 +1390,642 @@ async def process_subscription_grant_text(
await state.clear()
@admin_required
@error_handler
async def show_user_servers_management(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession
):
user_id = int(callback.data.split('_')[-1])
user_service = UserService()
profile = await user_service.get_user_profile(db, user_id)
if not profile:
await callback.answer("❌ Пользователь не найден", show_alert=True)
return
user = profile["user"]
subscription = profile["subscription"]
text = f"🌍 <b>Управление серверами пользователя</b>\n\n"
text += f"👤 {user.full_name} (ID: <code>{user.telegram_id}</code>)\n\n"
if subscription:
current_squads = subscription.connected_squads or []
if current_squads:
text += f"<b>Текущие серверы ({len(current_squads)}):</b>\n"
for squad_uuid in current_squads:
try:
server = await get_server_squad_by_uuid(db, squad_uuid)
if server:
text += f"{server.display_name}\n"
else:
text += f"{squad_uuid[:8]}... (неизвестный)\n"
except Exception as e:
logger.error(f"Ошибка получения сервера {squad_uuid}: {e}")
text += f"{squad_uuid[:8]}... (ошибка загрузки)\n"
else:
text += "<b>Серверы:</b> Не подключены\n"
text += f"\n<b>Устройства:</b> {subscription.device_limit}\n"
traffic_display = f"{subscription.traffic_used_gb:.1f}/"
if subscription.traffic_limit_gb == 0:
traffic_display += "∞ ГБ"
else:
traffic_display += f"{subscription.traffic_limit_gb} ГБ"
text += f"<b>Трафик:</b> {traffic_display}\n"
else:
text += "❌ <b>Подписка отсутствует</b>"
keyboard = [
[
types.InlineKeyboardButton(text="🌍 Сменить сервер", callback_data=f"admin_user_change_server_{user_id}"),
types.InlineKeyboardButton(text="📱 Устройства", callback_data=f"admin_user_devices_{user_id}")
],
[
types.InlineKeyboardButton(text="📊 Трафик", callback_data=f"admin_user_traffic_{user_id}"),
types.InlineKeyboardButton(text="🔄 Сбросить устройства", callback_data=f"admin_user_reset_devices_{user_id}")
],
[
types.InlineKeyboardButton(text="⬅️ К пользователю", callback_data=f"admin_user_manage_{user_id}")
]
]
await callback.message.edit_text(
text,
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard)
)
await callback.answer()
@admin_required
@error_handler
async def show_server_selection(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession
):
user_id = int(callback.data.split('_')[-1])
await _show_servers_for_user(callback, user_id, db)
await callback.answer()
async def _show_servers_for_user(
callback: types.CallbackQuery,
user_id: int,
db: AsyncSession
):
try:
user = await get_user_by_id(db, user_id)
current_squads = []
if user and user.subscription:
current_squads = user.subscription.connected_squads or []
all_servers, _ = await get_all_server_squads(db, available_only=False)
servers_to_show = []
for server in all_servers:
if server.is_available or server.squad_uuid in current_squads:
servers_to_show.append(server)
if not servers_to_show:
await callback.message.edit_text(
"❌ Доступные серверы не найдены",
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
[types.InlineKeyboardButton(text="⬅️ Назад", callback_data=f"admin_user_servers_{user_id}")]
])
)
return
text = f"🌍 <b>Управление серверами</b>\n\n"
text += f"Нажмите на сервер чтобы добавить/убрать:\n"
text += f"✅ - выбранный сервер\n"
text += f"⚪ - доступный сервер\n"
text += f"🔒 - неактивный (только для уже назначенных)\n\n"
keyboard = []
selected_servers = [s for s in servers_to_show if s.squad_uuid in current_squads]
available_servers = [s for s in servers_to_show if s.squad_uuid not in current_squads and s.is_available]
inactive_servers = [s for s in servers_to_show if s.squad_uuid not in current_squads and not s.is_available]
sorted_servers = selected_servers + available_servers + inactive_servers
for server in sorted_servers[:20]:
is_selected = server.squad_uuid in current_squads
if is_selected:
emoji = ""
elif server.is_available:
emoji = ""
else:
emoji = "🔒"
display_name = server.display_name
if not server.is_available and not is_selected:
display_name += " (неактивный)"
keyboard.append([
types.InlineKeyboardButton(
text=f"{emoji} {display_name}",
callback_data=f"admin_user_toggle_server_{user_id}_{server.id}"
)
])
if len(servers_to_show) > 20:
text += f"\n📝 Показано первых 20 из {len(servers_to_show)} серверов"
keyboard.append([
types.InlineKeyboardButton(text="✅ Готово", callback_data=f"admin_user_servers_{user_id}"),
types.InlineKeyboardButton(text="⬅️ Назад", callback_data=f"admin_user_servers_{user_id}")
])
await callback.message.edit_text(
text,
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard)
)
except Exception as e:
logger.error(f"Ошибка показа серверов: {e}")
@admin_required
@error_handler
async def toggle_user_server(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession
):
parts = callback.data.split('_')
user_id = int(parts[4])
server_id = int(parts[5])
try:
user = await get_user_by_id(db, user_id)
if not user or not user.subscription:
await callback.answer("❌ Пользователь или подписка не найдены", show_alert=True)
return
server = await get_server_squad_by_id(db, server_id)
if not server:
await callback.answer("❌ Сервер не найден", show_alert=True)
return
subscription = user.subscription
current_squads = list(subscription.connected_squads or [])
if server.squad_uuid in current_squads:
current_squads.remove(server.squad_uuid)
action_text = "удален"
else:
current_squads.append(server.squad_uuid)
action_text = "добавлен"
subscription.connected_squads = current_squads
subscription.updated_at = datetime.utcnow()
await db.commit()
await db.refresh(subscription)
if user.remnawave_uuid:
try:
remnawave_service = RemnaWaveService()
async with remnawave_service.api as api:
await api.update_user(
uuid=user.remnawave_uuid,
active_internal_squads=current_squads
)
logger.info(f"✅ Обновлены серверы в RemnaWave для пользователя {user.telegram_id}")
except Exception as rw_error:
logger.error(f"❌ Ошибка обновления RemnaWave: {rw_error}")
logger.info(f"Админ {db_user.id}: сервер {server.display_name} {action_text} для пользователя {user_id}")
await refresh_server_selection_screen(callback, user_id, db_user, db)
except Exception as e:
logger.error(f"Ошибка переключения сервера: {e}")
await callback.answer("❌ Ошибка изменения сервера", show_alert=True)
async def refresh_server_selection_screen(
callback: types.CallbackQuery,
user_id: int,
db_user: User,
db: AsyncSession
):
try:
user = await get_user_by_id(db, user_id)
current_squads = []
if user and user.subscription:
current_squads = user.subscription.connected_squads or []
servers, _ = await get_all_server_squads(db, available_only=True)
if not servers:
await callback.message.edit_text(
"❌ Доступные серверы не найдены",
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
[types.InlineKeyboardButton(text="⬅️ Назад", callback_data=f"admin_user_servers_{user_id}")]
])
)
return
text = f"🌍 <b>Управление серверами</b>\n\n"
text += f"Нажмите на сервер чтобы добавить/убрать:\n\n"
keyboard = []
for server in servers[:15]:
is_selected = server.squad_uuid in current_squads
emoji = "" if is_selected else ""
keyboard.append([
types.InlineKeyboardButton(
text=f"{emoji} {server.display_name}",
callback_data=f"admin_user_toggle_server_{user_id}_{server.id}"
)
])
if len(servers) > 15:
text += f"\n📝 Показано первых 15 из {len(servers)} серверов"
keyboard.append([
types.InlineKeyboardButton(text="✅ Готово", callback_data=f"admin_user_servers_{user_id}"),
types.InlineKeyboardButton(text="⬅️ Назад", callback_data=f"admin_user_servers_{user_id}")
])
await callback.message.edit_text(
text,
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard)
)
except Exception as e:
logger.error(f"Ошибка обновления экрана серверов: {e}")
@admin_required
@error_handler
async def start_devices_edit(
callback: types.CallbackQuery,
db_user: User,
state: FSMContext
):
user_id = int(callback.data.split('_')[-1])
await state.update_data(editing_devices_user_id=user_id)
await callback.message.edit_text(
"📱 <b>Изменение количества устройств</b>\n\n"
"Введите новое количество устройств (от 1 до 10):\n"
"• Текущее значение будет заменено\n"
"• Примеры: 1, 2, 5, 10\n\n"
"Или нажмите /cancel для отмены",
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
[
types.InlineKeyboardButton(text="1", callback_data=f"admin_user_devices_set_{user_id}_1"),
types.InlineKeyboardButton(text="2", callback_data=f"admin_user_devices_set_{user_id}_2"),
types.InlineKeyboardButton(text="3", callback_data=f"admin_user_devices_set_{user_id}_3")
],
[
types.InlineKeyboardButton(text="5", callback_data=f"admin_user_devices_set_{user_id}_5"),
types.InlineKeyboardButton(text="10", callback_data=f"admin_user_devices_set_{user_id}_10")
],
[
types.InlineKeyboardButton(text="❌ Отмена", callback_data=f"admin_user_servers_{user_id}")
]
])
)
await state.set_state(AdminStates.editing_user_devices)
await callback.answer()
@admin_required
@error_handler
async def set_user_devices_button(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession
):
parts = callback.data.split('_')
user_id = int(parts[-2])
devices = int(parts[-1])
success = await _update_user_devices(db, user_id, devices, db_user.id)
if success:
await callback.message.edit_text(
f"✅ Количество устройств изменено на: {devices}",
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
[types.InlineKeyboardButton(text="🌍 Управление серверами", callback_data=f"admin_user_servers_{user_id}")]
])
)
else:
await callback.message.edit_text(
"❌ Ошибка изменения количества устройств",
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
[types.InlineKeyboardButton(text="🌍 Управление серверами", callback_data=f"admin_user_servers_{user_id}")]
])
)
await callback.answer()
@admin_required
@error_handler
async def process_devices_edit_text(
message: types.Message,
db_user: User,
state: FSMContext,
db: AsyncSession
):
data = await state.get_data()
user_id = data.get("editing_devices_user_id")
if not user_id:
await message.answer("❌ Ошибка: пользователь не найден")
await state.clear()
return
try:
devices = int(message.text.strip())
if devices <= 0 or devices > 10:
await message.answer("❌ Количество устройств должно быть от 1 до 10")
return
success = await _update_user_devices(db, user_id, devices, db_user.id)
if success:
await message.answer(
f"✅ Количество устройств изменено на: {devices}",
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
[types.InlineKeyboardButton(text="🌍 Управление серверами", callback_data=f"admin_user_servers_{user_id}")]
])
)
else:
await message.answer("❌ Ошибка изменения количества устройств")
except ValueError:
await message.answer("❌ Введите корректное число устройств")
return
await state.clear()
@admin_required
@error_handler
async def start_traffic_edit(
callback: types.CallbackQuery,
db_user: User,
state: FSMContext
):
user_id = int(callback.data.split('_')[-1])
await state.update_data(editing_traffic_user_id=user_id)
await callback.message.edit_text(
"📊 <b>Изменение лимита трафика</b>\n\n"
"Введите новый лимит трафика в ГБ:\n"
"• 0 - безлимитный трафик\n"
"• Примеры: 50, 100, 500, 1000\n"
"• Максимум: 10000 ГБ\n\n"
"Или нажмите /cancel для отмены",
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
[
types.InlineKeyboardButton(text="50 ГБ", callback_data=f"admin_user_traffic_set_{user_id}_50"),
types.InlineKeyboardButton(text="100 ГБ", callback_data=f"admin_user_traffic_set_{user_id}_100")
],
[
types.InlineKeyboardButton(text="500 ГБ", callback_data=f"admin_user_traffic_set_{user_id}_500"),
types.InlineKeyboardButton(text="1000 ГБ", callback_data=f"admin_user_traffic_set_{user_id}_1000")
],
[
types.InlineKeyboardButton(text="♾️ Безлимит", callback_data=f"admin_user_traffic_set_{user_id}_0")
],
[
types.InlineKeyboardButton(text="❌ Отмена", callback_data=f"admin_user_servers_{user_id}")
]
])
)
await state.set_state(AdminStates.editing_user_traffic)
await callback.answer()
@admin_required
@error_handler
async def set_user_traffic_button(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession
):
parts = callback.data.split('_')
user_id = int(parts[-2])
traffic_gb = int(parts[-1])
success = await _update_user_traffic(db, user_id, traffic_gb, db_user.id)
if success:
traffic_text = "♾️ безлимитный" if traffic_gb == 0 else f"{traffic_gb} ГБ"
await callback.message.edit_text(
f"✅ Лимит трафика изменен на: {traffic_text}",
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
[types.InlineKeyboardButton(text="🌍 Управление серверами", callback_data=f"admin_user_servers_{user_id}")]
])
)
else:
await callback.message.edit_text(
"❌ Ошибка изменения лимита трафика",
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
[types.InlineKeyboardButton(text="🌍 Управление серверами", callback_data=f"admin_user_servers_{user_id}")]
])
)
await callback.answer()
@admin_required
@error_handler
async def process_traffic_edit_text(
message: types.Message,
db_user: User,
state: FSMContext,
db: AsyncSession
):
data = await state.get_data()
user_id = data.get("editing_traffic_user_id")
if not user_id:
await message.answer("❌ Ошибка: пользователь не найден")
await state.clear()
return
try:
traffic_gb = int(message.text.strip())
if traffic_gb < 0 or traffic_gb > 10000:
await message.answer("❌ Лимит трафика должен быть от 0 до 10000 ГБ (0 = безлимит)")
return
success = await _update_user_traffic(db, user_id, traffic_gb, db_user.id)
if success:
traffic_text = "♾️ безлимитный" if traffic_gb == 0 else f"{traffic_gb} ГБ"
await message.answer(
f"✅ Лимит трафика изменен на: {traffic_text}",
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
[types.InlineKeyboardButton(text="🌍 Управление серверами", callback_data=f"admin_user_servers_{user_id}")]
])
)
else:
await message.answer("❌ Ошибка изменения лимита трафика")
except ValueError:
await message.answer("❌ Введите корректное число ГБ")
return
await state.clear()
@admin_required
@error_handler
async def confirm_reset_devices(
callback: types.CallbackQuery,
db_user: User
):
user_id = int(callback.data.split('_')[-1])
await callback.message.edit_text(
"🔄 <b>Сброс устройств пользователя</b>\n\n"
"⚠️ <b>ВНИМАНИЕ!</b>\n"
"Вы уверены, что хотите сбросить все HWID устройства этого пользователя?\n\n"
"Это действие:\n"
"• Удалит все привязанные устройства\n"
"• Пользователь сможет заново подключить устройства\n"
"• Действие необратимо!\n\n"
"Продолжить?",
reply_markup=get_confirmation_keyboard(
f"admin_user_reset_devices_confirm_{user_id}",
f"admin_user_servers_{user_id}",
db_user.language
)
)
await callback.answer()
@admin_required
@error_handler
async def reset_user_devices(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession
):
user_id = int(callback.data.split('_')[-1])
try:
user = await get_user_by_id(db, user_id)
if not user or not user.remnawave_uuid:
await callback.answer("❌ Пользователь не найден или не связан с RemnaWave", show_alert=True)
return
remnawave_service = RemnaWaveService()
async with remnawave_service.api as api:
success = await api.reset_user_devices(user.remnawave_uuid)
if success:
await callback.message.edit_text(
"✅ Устройства пользователя успешно сброшены",
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
[types.InlineKeyboardButton(text="🌍 Управление серверами", callback_data=f"admin_user_servers_{user_id}")]
])
)
logger.info(f"Админ {db_user.id} сбросил устройства пользователя {user_id}")
else:
await callback.message.edit_text(
"❌ Ошибка сброса устройств",
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
[types.InlineKeyboardButton(text="🌍 Управление серверами", callback_data=f"admin_user_servers_{user_id}")]
])
)
except Exception as e:
logger.error(f"Ошибка сброса устройств: {e}")
await callback.answer("❌ Ошибка сброса устройств", show_alert=True)
async def _update_user_devices(db: AsyncSession, user_id: int, devices: int, admin_id: int) -> bool:
try:
user = await get_user_by_id(db, user_id)
if not user or not user.subscription:
logger.error(f"Пользователь {user_id} или подписка не найдены")
return False
subscription = user.subscription
old_devices = subscription.device_limit
subscription.device_limit = devices
subscription.updated_at = datetime.utcnow()
await db.commit()
if user.remnawave_uuid:
try:
remnawave_service = RemnaWaveService()
async with remnawave_service.api as api:
await api.update_user(
uuid=user.remnawave_uuid,
hwid_device_limit=devices
)
logger.info(f"✅ Обновлен лимит устройств в RemnaWave для пользователя {user.telegram_id}")
except Exception as rw_error:
logger.error(f"❌ Ошибка обновления лимита устройств в RemnaWave: {rw_error}")
logger.info(f"Админ {admin_id} изменил лимит устройств пользователя {user_id}: {old_devices} -> {devices}")
return True
except Exception as e:
logger.error(f"Ошибка обновления лимита устройств: {e}")
await db.rollback()
return False
async def _update_user_traffic(db: AsyncSession, user_id: int, traffic_gb: int, admin_id: int) -> bool:
try:
user = await get_user_by_id(db, user_id)
if not user or not user.subscription:
logger.error(f"Пользователь {user_id} или подписка не найдены")
return False
subscription = user.subscription
old_traffic = subscription.traffic_limit_gb
subscription.traffic_limit_gb = traffic_gb
subscription.updated_at = datetime.utcnow()
await db.commit()
if user.remnawave_uuid:
try:
from app.external.remnawave_api import TrafficLimitStrategy
remnawave_service = RemnaWaveService()
async with remnawave_service.api as api:
await api.update_user(
uuid=user.remnawave_uuid,
traffic_limit_bytes=traffic_gb * (1024**3) if traffic_gb > 0 else 0,
traffic_limit_strategy=TrafficLimitStrategy.MONTH
)
logger.info(f"✅ Обновлен лимит трафика в RemnaWave для пользователя {user.telegram_id}")
except Exception as rw_error:
logger.error(f"❌ Ошибка обновления лимита трафика в RemnaWave: {rw_error}")
traffic_text_old = "безлимитный" if old_traffic == 0 else f"{old_traffic} ГБ"
traffic_text_new = "безлимитный" if traffic_gb == 0 else f"{traffic_gb} ГБ"
logger.info(f"Админ {admin_id} изменил лимит трафика пользователя {user_id}: {traffic_text_old} -> {traffic_text_new}")
return True
except Exception as e:
logger.error(f"Ошибка обновления лимита трафика: {e}")
await db.rollback()
return False
async def _extend_subscription_by_days(db: AsyncSession, user_id: int, days: int, admin_id: int) -> bool:
try:
@@ -1648,3 +2374,58 @@ def register_handlers(dp: Dispatcher):
process_subscription_grant_text,
AdminStates.granting_subscription
)
dp.callback_query.register(
show_user_servers_management,
F.data.startswith("admin_user_servers_")
)
dp.callback_query.register(
show_server_selection,
F.data.startswith("admin_user_change_server_")
)
dp.callback_query.register(
toggle_user_server,
F.data.startswith("admin_user_toggle_server_") & ~F.data.endswith("_add") & ~F.data.endswith("_remove")
)
dp.callback_query.register(
start_devices_edit,
F.data.startswith("admin_user_devices_") & ~F.data.contains("set")
)
dp.callback_query.register(
set_user_devices_button,
F.data.startswith("admin_user_devices_set_")
)
dp.message.register(
process_devices_edit_text,
AdminStates.editing_user_devices
)
dp.callback_query.register(
start_traffic_edit,
F.data.startswith("admin_user_traffic_") & ~F.data.contains("set")
)
dp.callback_query.register(
set_user_traffic_button,
F.data.startswith("admin_user_traffic_set_")
)
dp.message.register(
process_traffic_edit_text,
AdminStates.editing_user_traffic
)
dp.callback_query.register(
confirm_reset_devices,
F.data.startswith("admin_user_reset_devices_") & ~F.data.contains("confirm")
)
dp.callback_query.register(
reset_user_devices,
F.data.startswith("admin_user_reset_devices_confirm_")
)
+48 -3
View File
@@ -356,10 +356,15 @@ async def process_stars_payment_amount(
texts = get_texts(db_user.language)
if not settings.TELEGRAM_STARS_ENABLED:
await message.answer(" Оплата Stars временно недоступна")
await message.answer(" Оплата Stars временно недоступна")
return
try:
from app.external.telegram_stars import TelegramStarsService
amount_rubles = amount_kopeks / 100
stars_amount = TelegramStarsService.calculate_stars_from_rubles(amount_rubles)
payment_service = PaymentService(message.bot)
invoice_link = await payment_service.create_stars_invoice(
amount_kopeks=amount_kopeks,
@@ -374,7 +379,9 @@ async def process_stars_payment_amount(
await message.answer(
f"⭐ <b>Оплата через Telegram Stars</b>\n\n"
f"Сумма: {texts.format_price(amount_kopeks)}\n\n"
f"💰 Сумма: {texts.format_price(amount_kopeks)}\n"
f"К оплате: {stars_amount} звезд\n"
f"📊 Курс: {settings.get_stars_rate():.2f}₽ за звезду\n\n"
f"Нажмите кнопку ниже для оплаты:",
reply_markup=keyboard,
parse_mode="HTML"
@@ -384,7 +391,7 @@ async def process_stars_payment_amount(
except Exception as e:
logger.error(f"Ошибка создания Stars invoice: {e}")
await message.answer(" Ошибка создания платежа")
await message.answer(" Ошибка создания платежа")
@error_handler
@@ -519,6 +526,44 @@ async def check_yookassa_payment_status(
logger.error(f"Ошибка проверки статуса платежа: {e}")
await callback.answer("❌ Ошибка проверки статуса", show_alert=True)
@error_handler
async def add_user_balance(
db: AsyncSession,
user: User,
amount_kopeks: int,
description: str = "Пополнение баланса"
) -> bool:
try:
old_balance = user.balance_kopeks
user.balance_kopeks += amount_kopeks
user.updated_at = datetime.utcnow()
from app.database.crud.transaction import create_transaction
from app.database.models import TransactionType
await create_transaction(
db=db,
user_id=user.id,
type=TransactionType.DEPOSIT,
amount_kopeks=amount_kopeks,
description=description
)
await db.commit()
await db.refresh(user)
if "пополнение" in description.lower() or "topup" in description.lower():
from app.services.referral_service import process_referral_topup
await process_referral_topup(db, user.id, amount_kopeks)
logger.info(f"💰 Баланс пользователя {user.telegram_id} изменен: {old_balance}{user.balance_kopeks} (изменение: +{amount_kopeks})")
return True
except Exception as e:
logger.error(f"Ошибка изменения баланса пользователя {user.id}: {e}")
await db.rollback()
return False
def register_handlers(dp: Dispatcher):
+3 -4
View File
@@ -19,7 +19,6 @@ async def show_main_menu(
db_user: User,
db: AsyncSession
):
texts = get_texts(db_user.language)
from datetime import datetime
@@ -34,7 +33,6 @@ async def show_main_menu(
menu_text = texts.MAIN_MENU.format(
user_name=db_user.full_name,
balance=texts.format_price(db_user.balance_kopeks),
subscription_status=_get_subscription_status(db_user, texts)
)
@@ -45,7 +43,8 @@ async def show_main_menu(
is_admin=settings.is_admin(db_user.telegram_id),
has_had_paid_subscription=db_user.has_had_paid_subscription,
has_active_subscription=has_active_subscription,
subscription_is_active=subscription_is_active
subscription_is_active=subscription_is_active,
balance_kopeks=db_user.balance_kopeks
)
)
await callback.answer()
@@ -109,7 +108,7 @@ async def handle_back_to_menu(
def _get_subscription_status(user: User, texts) -> str:
if not user.subscription:
return " Отсутствует"
return " Отсутствует"
subscription = user.subscription
current_time = datetime.utcnow()
+176 -33
View File
@@ -3,11 +3,10 @@ from aiogram import Dispatcher, types, F
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.crud.referral import get_referral_earnings_sum
from app.database.models import User
from app.keyboards.inline import get_referral_keyboard, get_back_keyboard
from app.localization.texts import get_texts
from app.utils.user_utils import get_user_referral_summary
from app.utils.user_utils import get_user_referral_summary, get_detailed_referral_list, get_referral_analytics
logger = logging.getLogger(__name__)
@@ -17,7 +16,6 @@ async def show_referral_info(
db_user: User,
db: AsyncSession
):
texts = get_texts(db_user.language)
summary = await get_user_referral_summary(db, db_user.id)
@@ -28,30 +26,58 @@ async def show_referral_info(
referral_text = f"👥 <b>Реферальная программа</b>\n\n"
referral_text += f"📊 <b>Ваша статистика:</b>\n"
referral_text += f"• Приглашено пользователей: {summary['invited_count']}\n"
referral_text += f"Купили подписку: {summary['paid_referrals_count']}\n"
referral_text += f"Заработано всего: {texts.format_price(summary['total_earned_kopeks'])}\n"
referral_text += f"За последний месяц: {texts.format_price(summary['month_earned_kopeks'])}\n\n"
referral_text += f"• Приглашено пользователей: <b>{summary['invited_count']}</b>\n"
referral_text += f"Сделали первое пополнение: <b>{summary['paid_referrals_count']}</b>\n"
referral_text += f"Активных рефералов: <b>{summary['active_referrals_count']}</b>\n"
referral_text += f"Конверсия: <b>{summary['conversion_rate']}%</b>\n"
referral_text += f"• Заработано всего: <b>{texts.format_price(summary['total_earned_kopeks'])}</b>\n"
referral_text += f"• За последний месяц: <b>{texts.format_price(summary['month_earned_kopeks'])}</b>\n\n"
referral_text += f"🎁 <b>Как работают награды:</b>\n"
referral_text += f"• Новый пользователь получает: {texts.format_price(settings.REFERRED_USER_REWARD)}\n"
referral_text += f"• Вы получаете при первой покупке реферала: {texts.format_price(settings.REFERRAL_REGISTRATION_REWARD)}\n"
referral_text += f"• Комиссия с каждой покупки реферала: {settings.REFERRAL_COMMISSION_PERCENT}%\n\n"
referral_text += f"• Новый пользователь получает: <b>{texts.format_price(settings.REFERRAL_FIRST_TOPUP_BONUS_KOPEKS)}</b> при первом пополнении от <b>{texts.format_price(settings.REFERRAL_MINIMUM_TOPUP_KOPEKS)}</b>\n"
referral_text += f"• Вы получаете при первом пополнении реферала: <b>{texts.format_price(settings.REFERRAL_INVITER_BONUS_KOPEKS)}</b>\n"
referral_text += f"• Комиссия с каждого пополнения реферала: <b>{settings.REFERRAL_COMMISSION_PERCENT}%</b>\n\n"
referral_text += f"🔗 <b>Ваша реферальная ссылка:</b>\n"
referral_text += f"<code>{referral_link}</code>\n\n"
referral_text += f"🆔 <b>Ваш код:</b> <code>{db_user.referral_code}</code>\n\n"
if summary['recent_earnings']:
referral_text += f"💰 <b>Последние начисления:</b>\n"
for earning in summary['recent_earnings'][:3]:
reason_text = {
"referral_first_purchase": "🎉 Первая покупка",
"referral_commission": "💰 Комиссия",
"referral_registration_pending": "⏳ Ожидание покупки"
}.get(earning['reason'], earning['reason'])
referral_text += f"{reason_text}: {texts.format_price(earning['amount_kopeks'])} от {earning['referral_name']}\n"
meaningful_earnings = [
earning for earning in summary['recent_earnings'][:5]
if earning['amount_kopeks'] > 0
]
if meaningful_earnings:
referral_text += f"💰 <b>Последние начисления:</b>\n"
for earning in meaningful_earnings[:3]:
reason_text = {
"referral_first_topup": "🎉 Первое пополнение",
"referral_commission_topup": "💰 Комиссия с пополнения",
"referral_commission": "💰 Комиссия с покупки"
}.get(earning['reason'], earning['reason'])
referral_text += f"{reason_text}: <b>{texts.format_price(earning['amount_kopeks'])}</b> от {earning['referral_name']}\n"
referral_text += "\n"
if summary['earnings_by_type']:
referral_text += f"📈 <b>Доходы по типам:</b>\n"
if 'referral_first_topup' in summary['earnings_by_type']:
data = summary['earnings_by_type']['referral_first_topup']
if data['total_amount_kopeks'] > 0:
referral_text += f"• Бонусы за первые пополнения: <b>{data['count']}</b> ({texts.format_price(data['total_amount_kopeks'])})\n"
if 'referral_commission_topup' in summary['earnings_by_type']:
data = summary['earnings_by_type']['referral_commission_topup']
if data['total_amount_kopeks'] > 0:
referral_text += f"• Комиссии с пополнений: <b>{data['count']}</b> ({texts.format_price(data['total_amount_kopeks'])})\n"
if 'referral_commission' in summary['earnings_by_type']:
data = summary['earnings_by_type']['referral_commission']
if data['total_amount_kopeks'] > 0:
referral_text += f"• Комиссии с покупок: <b>{data['count']}</b> ({texts.format_price(data['total_amount_kopeks'])})\n"
referral_text += "\n"
referral_text += "📢 Приглашайте друзей и зарабатывайте!"
@@ -64,36 +90,136 @@ async def show_referral_info(
await callback.answer()
async def show_detailed_referral_list(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
page: int = 1
):
texts = get_texts(db_user.language)
referrals_data = await get_detailed_referral_list(db, db_user.id, limit=10, offset=(page - 1) * 10)
if not referrals_data['referrals']:
await callback.message.edit_text(
"📋 У вас пока нет рефералов.\n\nПоделитесь своей реферальной ссылкой, чтобы начать зарабатывать!",
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
[types.InlineKeyboardButton(text=texts.BACK, callback_data="menu_referrals")]
])
)
await callback.answer()
return
text = f"👥 <b>Ваши рефералы</b> (стр. {referrals_data['current_page']}/{referrals_data['total_pages']})\n\n"
for i, referral in enumerate(referrals_data['referrals'], 1):
status_emoji = "🟢" if referral['status'] == 'active' else "🔴"
topup_emoji = "💰" if referral['has_made_first_topup'] else ""
text += f"{i}. {status_emoji} <b>{referral['full_name']}</b>\n"
text += f" {topup_emoji} Пополнений: {referral['topups_count']}\n"
text += f" 💎 Заработано с него: {texts.format_price(referral['total_earned_kopeks'])}\n"
text += f" 📅 Регистрация: {referral['days_since_registration']} дн. назад\n"
if referral['days_since_activity'] is not None:
text += f" 🕐 Активность: {referral['days_since_activity']} дн. назад\n"
else:
text += f" 🕐 Активность: давно\n"
text += "\n"
keyboard = []
nav_buttons = []
if referrals_data['has_prev']:
nav_buttons.append(types.InlineKeyboardButton(
text="⬅️ Назад",
callback_data=f"referral_list_page_{page - 1}"
))
if referrals_data['has_next']:
nav_buttons.append(types.InlineKeyboardButton(
text="Вперед ➡️",
callback_data=f"referral_list_page_{page + 1}"
))
if nav_buttons:
keyboard.append(nav_buttons)
keyboard.append([types.InlineKeyboardButton(
text=texts.BACK,
callback_data="menu_referrals"
)])
await callback.message.edit_text(
text,
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard),
parse_mode="HTML"
)
await callback.answer()
async def show_referral_analytics(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession
):
texts = get_texts(db_user.language)
analytics = await get_referral_analytics(db, db_user.id)
text = f"📊 <b>Аналитика рефералов</b>\n\n"
text += f"💰 <b>Доходы по периодам:</b>\n"
text += f"• Сегодня: {texts.format_price(analytics['earnings_by_period']['today'])}\n"
text += f"• За неделю: {texts.format_price(analytics['earnings_by_period']['week'])}\n"
text += f"• За месяц: {texts.format_price(analytics['earnings_by_period']['month'])}\n"
text += f"• За квартал: {texts.format_price(analytics['earnings_by_period']['quarter'])}\n\n"
if analytics['top_referrals']:
text += f"🏆 <b>Топ-{len(analytics['top_referrals'])} рефералов:</b>\n"
for i, ref in enumerate(analytics['top_referrals'], 1):
text += f"{i}. {ref['referral_name']}: {texts.format_price(ref['total_earned_kopeks'])} ({ref['earnings_count']} начислений)\n"
text += "\n"
text += "📈 Продолжайте развивать свою реферальную сеть!"
await callback.message.edit_text(
text,
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
[types.InlineKeyboardButton(text=texts.BACK, callback_data="menu_referrals")]
]),
parse_mode="HTML"
)
await callback.answer()
async def create_invite_message(
callback: types.CallbackQuery,
db_user: User
):
texts = get_texts(db_user.language)
bot_username = (await callback.bot.get_me()).username
referral_link = f"https://t.me/{bot_username}?start={db_user.referral_code}"
invite_text = f"🎉 Присоединяйся к VPN сервису!\n\n"
invite_text += f"💎 При регистрации по моей ссылке ты получишь {texts.format_price(settings.REFERRED_USER_REWARD)} на баланс!\n\n"
invite_text += f"💎 При первом пополнении от {texts.format_price(settings.REFERRAL_MINIMUM_TOPUP_KOPEKS)} ты получишь {texts.format_price(settings.REFERRAL_FIRST_TOPUP_BONUS_KOPEKS)} бонусом на баланс!\n\n"
invite_text += f"🚀 Быстрое подключение\n"
invite_text += f"🌍 Серверы по всему миру\n"
invite_text += f"🔒 Надежная защита\n\n"
invite_text += f"👇 Переходи по ссылке:\n{referral_link}"
keyboard = types.InlineKeyboardMarkup(inline_keyboard=[
[
types.InlineKeyboardButton(
text="📤 Поделиться",
switch_inline_query=invite_text
)
],
[
types.InlineKeyboardButton(
text=texts.BACK,
callback_data="menu_referrals"
)
]
[types.InlineKeyboardButton(
text="📤 Поделиться",
switch_inline_query=invite_text
)],
[types.InlineKeyboardButton(
text=texts.BACK,
callback_data="menu_referrals"
)]
])
await callback.message.edit_text(
@@ -117,3 +243,20 @@ def register_handlers(dp: Dispatcher):
create_invite_message,
F.data == "referral_create_invite"
)
dp.callback_query.register(
show_detailed_referral_list,
F.data == "referral_list"
)
dp.callback_query.register(
show_referral_analytics,
F.data == "referral_analytics"
)
dp.callback_query.register(
lambda callback, db_user, db: show_detailed_referral_list(
callback, db_user, db, int(callback.data.split('_')[-1])
),
F.data.startswith("referral_list_page_")
)
+132
View File
@@ -0,0 +1,132 @@
import logging
from aiogram import Dispatcher, types, F
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.models import User
from app.services.payment_service import PaymentService
from app.external.telegram_stars import TelegramStarsService
from app.database.crud.user import get_user_by_telegram_id
logger = logging.getLogger(__name__)
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}")
if not query.invoice_payload or not query.invoice_payload.startswith("balance_"):
logger.warning(f"Невалидный payload: {query.invoice_payload}")
await query.answer(
ok=False,
error_message="Ошибка валидации платежа. Попробуйте еще раз."
)
return
try:
from app.database.database import get_db
async for db in get_db():
user = await get_user_by_telegram_id(db, query.from_user.id)
if not user:
logger.warning(f"Пользователь {query.from_user.id} не найден в БД")
await query.answer(
ok=False,
error_message="Пользователь не найден. Обратитесь в поддержку."
)
return
break
except Exception as db_error:
logger.error(f"Ошибка подключения к БД в pre_checkout_query: {db_error}")
await query.answer(
ok=False,
error_message="Техническая ошибка. Попробуйте позже."
)
return
await query.answer(ok=True)
logger.info(f"✅ Pre-checkout одобрен для пользователя {query.from_user.id}")
except Exception as e:
logger.error(f"Ошибка в pre_checkout_query: {e}", exc_info=True)
await query.answer(
ok=False,
error_message="Техническая ошибка. Попробуйте позже."
)
async def handle_successful_payment(
message: types.Message,
db: AsyncSession,
**kwargs
):
try:
payment = message.successful_payment
user_id = message.from_user.id
logger.info(
f"💳 Успешный Stars платеж от {user_id}: "
f"{payment.total_amount} XTR, "
f"payload: {payment.invoice_payload}, "
f"charge_id: {payment.telegram_payment_charge_id}"
)
user = await get_user_by_telegram_id(db, user_id)
if not user:
logger.error(f"Пользователь {user_id} не найден при обработке Stars платежа")
await message.answer(
"❌ Ошибка: пользователь не найден. Обратитесь в поддержку."
)
return
payment_service = PaymentService(message.bot)
success = await payment_service.process_stars_payment(
db=db,
user_id=user.id,
stars_amount=payment.total_amount,
payload=payment.invoice_payload,
telegram_payment_charge_id=payment.telegram_payment_charge_id
)
if success:
rubles_amount = TelegramStarsService.calculate_rubles_from_stars(payment.total_amount)
await message.answer(
f"🎉 <b>Платеж успешно обработан!</b>\n\n"
f"⭐ Потрачено звезд: {payment.total_amount}\n"
f"💰 Зачислено на баланс: {rubles_amount:.2f}\n"
f"🆔 ID транзакции: {payment.telegram_payment_charge_id[:8]}...\n\n"
f"Спасибо за пополнение! 🚀",
parse_mode="HTML"
)
logger.info(
f"✅ Stars платеж успешно обработан: "
f"пользователь {user.id}, {payment.total_amount} звезд → {rubles_amount:.2f}"
)
else:
logger.error(f"Ошибка обработки Stars платежа для пользователя {user.id}")
await message.answer(
"❌ Произошла ошибка при зачислении средств. "
"Обратитесь в поддержку, платеж будет проверен вручную."
)
except Exception as e:
logger.error(f"Ошибка в successful_payment: {e}", exc_info=True)
await message.answer(
"❌ Техническая ошибка при обработке платежа. "
"Обратитесь в поддержку для решения проблемы."
)
def register_stars_handlers(dp: Dispatcher):
dp.pre_checkout_query.register(
handle_pre_checkout_query,
F.currency == "XTR"
)
dp.message.register(
handle_successful_payment,
F.successful_payment
)
logger.info("🌟 Зарегистрированы обработчики Telegram Stars платежей")
+81 -40
View File
@@ -1,6 +1,6 @@
import logging
from datetime import datetime
from aiogram import Dispatcher, types, F
from aiogram import Dispatcher, types, F, Bot
from aiogram.filters import Command, StateFilter
from aiogram.fsm.context import FSMContext
from sqlalchemy.ext.asyncio import AsyncSession
@@ -18,6 +18,7 @@ from app.localization.texts import get_texts
from app.services.referral_service import process_referral_registration
from app.utils.user_utils import generate_unique_referral_code
logger = logging.getLogger(__name__)
@@ -138,7 +139,6 @@ async def cmd_start(message: types.Message, state: FSMContext, db: AsyncSession,
await message.answer(
texts.MAIN_MENU.format(
user_name=user.full_name,
balance=texts.format_price(user.balance_kopeks),
subscription_status=_get_subscription_status(user, texts)
),
reply_markup=get_main_menu_keyboard(
@@ -146,7 +146,8 @@ async def cmd_start(message: types.Message, state: FSMContext, db: AsyncSession,
is_admin=settings.is_admin(user.telegram_id),
has_had_paid_subscription=user.has_had_paid_subscription,
has_active_subscription=has_active_subscription,
subscription_is_active=subscription_is_active
subscription_is_active=subscription_is_active,
balance_kopeks=user.balance_kopeks
)
)
await state.clear()
@@ -380,12 +381,12 @@ async def process_referral_code_skip(
await complete_registration_from_callback(callback, state, db)
async def complete_registration_from_callback(
callback: types.CallbackQuery,
state: FSMContext,
db: AsyncSession
):
logger.info(f"🏁 COMPLETE: Завершение регистрации для пользователя {callback.from_user.id}")
existing_user = await get_user_by_telegram_id(db, callback.from_user.id)
@@ -405,21 +406,24 @@ async def complete_registration_from_callback(
subscription_is_active = existing_user.subscription.is_active
user_name = existing_user.full_name
user_telegram_id = existing_user.telegram_id
user_language = existing_user.language
has_had_paid_subscription = existing_user.has_had_paid_subscription
balance_kopeks = existing_user.balance_kopeks
try:
await callback.message.answer(
texts.MAIN_MENU.format(
user_name=user_name,
balance=texts.format_price(balance_kopeks),
subscription_status=_get_subscription_status(existing_user, texts)
),
reply_markup=get_main_menu_keyboard(
language=existing_user.language,
is_admin=settings.is_admin(existing_user.telegram_id),
has_had_paid_subscription=existing_user.has_had_paid_subscription,
language=user_language,
is_admin=settings.is_admin(user_telegram_id),
has_had_paid_subscription=has_had_paid_subscription,
has_active_subscription=has_active_subscription,
subscription_is_active=subscription_is_active
subscription_is_active=subscription_is_active,
balance_kopeks=balance_kopeks
)
)
except Exception as e:
@@ -493,9 +497,8 @@ async def complete_registration_from_callback(
if referrer_id:
try:
await process_referral_registration(db, user.id, referrer_id)
bonus_message = f"🎉 Вы получили {settings.REFERRED_USER_REWARD/100}₽ за регистрацию по реферальной ссылке!"
await callback.message.answer(bonus_message)
await process_referral_registration(db, user.id, referrer_id, callback.bot)
logger.info(f"✅ Реферальная регистрация обработана для {user.id}")
except Exception as e:
logger.error(f"Ошибка при обработке реферальной регистрации: {e}")
@@ -503,7 +506,6 @@ async def complete_registration_from_callback(
has_active_subscription = False
subscription_is_active = False
user_name = user.full_name
balance_kopeks = user.balance_kopeks
user_telegram_id = user.telegram_id
@@ -514,7 +516,6 @@ async def complete_registration_from_callback(
await callback.message.answer(
texts.MAIN_MENU.format(
user_name=user_name,
balance=texts.format_price(balance_kopeks),
subscription_status=_get_subscription_status_simple(texts)
),
reply_markup=get_main_menu_keyboard(
@@ -522,7 +523,8 @@ async def complete_registration_from_callback(
is_admin=settings.is_admin(user_telegram_id),
has_had_paid_subscription=has_had_paid_subscription,
has_active_subscription=has_active_subscription,
subscription_is_active=subscription_is_active
subscription_is_active=subscription_is_active,
balance_kopeks=balance_kopeks
)
)
logger.info(f"✅ Главное меню отправлено для пользователя {user_telegram_id}")
@@ -532,34 +534,33 @@ async def complete_registration_from_callback(
balance_rubles = balance_kopeks / 100
await callback.message.answer(
f"Добро пожаловать, {user_name}!\n"
f"Баланс: {balance_rubles}\n"
f"Баланс: {balance_rubles:.2f}\n"
f"Подписка: Нет активной подписки",
reply_markup=get_main_menu_keyboard(
language=user_language,
is_admin=settings.is_admin(user_telegram_id),
has_had_paid_subscription=has_had_paid_subscription,
has_active_subscription=has_active_subscription,
subscription_is_active=subscription_is_active
subscription_is_active=subscription_is_active,
balance_kopeks=balance_kopeks
)
)
logger.info(f"✅ Fallback главное меню отправлено для пользователя {user_telegram_id}")
except Exception as fallback_error:
logger.error(f" Критическая ошибка при отправке fallback меню: {fallback_error}")
logger.error(f" Критическая ошибка при отправке fallback меню: {fallback_error}")
try:
await callback.message.answer(f"Добро пожаловать, {user_name}! Регистрация завершена.")
logger.info(f"✅ Простое приветствие отправлено для пользователя {user_telegram_id}")
except Exception as final_error:
logger.error(f" Критическая ошибка при отправке простого сообщения: {final_error}")
logger.error(f" Критическая ошибка при отправке простого сообщения: {final_error}")
logger.info(f"✅ Регистрация завершена для пользователя: {user_telegram_id}")
async def complete_registration(
message: types.Message,
state: FSMContext,
db: AsyncSession
):
logger.info(f"🏁 COMPLETE: Завершение регистрации для пользователя {message.from_user.id}")
existing_user = await get_user_by_telegram_id(db, message.from_user.id)
@@ -578,24 +579,30 @@ async def complete_registration(
if existing_user.subscription:
subscription_is_active = existing_user.subscription.is_active
user_name = existing_user.full_name
user_telegram_id = existing_user.telegram_id
user_language = existing_user.language
has_had_paid_subscription = existing_user.has_had_paid_subscription
balance_kopeks = existing_user.balance_kopeks
try:
await message.answer(
texts.MAIN_MENU.format(
user_name=existing_user.full_name,
balance=texts.format_price(existing_user.balance_kopeks),
user_name=user_name,
subscription_status=_get_subscription_status(existing_user, texts)
),
reply_markup=get_main_menu_keyboard(
language=existing_user.language,
is_admin=settings.is_admin(existing_user.telegram_id),
has_had_paid_subscription=existing_user.has_had_paid_subscription,
language=user_language,
is_admin=settings.is_admin(user_telegram_id),
has_had_paid_subscription=has_had_paid_subscription,
has_active_subscription=has_active_subscription,
subscription_is_active=subscription_is_active
subscription_is_active=subscription_is_active,
balance_kopeks=balance_kopeks
)
)
except Exception as e:
logger.error(f"Ошибка при показе главного меню существующему пользователю: {e}")
await message.answer(f"Добро пожаловать, {existing_user.full_name}!")
await message.answer(f"Добро пожаловать, {user_name}!")
await state.clear()
return
@@ -664,9 +671,8 @@ async def complete_registration(
if referrer_id:
try:
await process_referral_registration(db, user.id, referrer_id)
bonus_message = f"🎉 Вы получили {settings.REFERRED_USER_REWARD/100}₽ за регистрацию по реферальной ссылке!"
await message.answer(bonus_message)
await process_referral_registration(db, user.id, referrer_id, message.bot)
logger.info(f"✅ Реферальная регистрация обработана для {user.id}")
except Exception as e:
logger.error(f"Ошибка при обработке реферальной регистрации: {e}")
@@ -674,7 +680,6 @@ async def complete_registration(
has_active_subscription = False
subscription_is_active = False
user_name = user.full_name
balance_kopeks = user.balance_kopeks
user_telegram_id = user.telegram_id
@@ -685,7 +690,6 @@ async def complete_registration(
await message.answer(
texts.MAIN_MENU.format(
user_name=user_name,
balance=texts.format_price(balance_kopeks),
subscription_status=_get_subscription_status_simple(texts)
),
reply_markup=get_main_menu_keyboard(
@@ -693,7 +697,8 @@ async def complete_registration(
is_admin=settings.is_admin(user_telegram_id),
has_had_paid_subscription=has_had_paid_subscription,
has_active_subscription=has_active_subscription,
subscription_is_active=subscription_is_active
subscription_is_active=subscription_is_active,
balance_kopeks=balance_kopeks
)
)
logger.info(f"✅ Главное меню отправлено для пользователя {user_telegram_id}")
@@ -703,19 +708,20 @@ async def complete_registration(
balance_rubles = balance_kopeks / 100
await message.answer(
f"Добро пожаловать, {user_name}!\n"
f"Баланс: {balance_rubles}\n"
f"Баланс: {balance_rubles:.2f}\n"
f"Подписка: Нет активной подписки",
reply_markup=get_main_menu_keyboard(
language=user_language,
is_admin=settings.is_admin(user_telegram_id),
has_had_paid_subscription=has_had_paid_subscription,
has_active_subscription=has_active_subscription,
subscription_is_active=subscription_is_active
subscription_is_active=subscription_is_active,
balance_kopeks=balance_kopeks
)
)
logger.info(f"✅ Fallback главное меню отправлено для пользователя {user_telegram_id}")
except Exception as fallback_error:
logger.error(f" Критическая ошибка при отправке fallback меню: {fallback_error}")
logger.error(f" Критическая ошибка при отправке fallback меню: {fallback_error}")
try:
await message.answer(f"Добро пожаловать, {user_name}! Регистрация завершена.")
logger.info(f"✅ Простое приветствие отправлено для пользователя {user_telegram_id}")
@@ -726,9 +732,44 @@ async def complete_registration(
def _get_subscription_status(user, texts):
if user.subscription and user.subscription.is_active:
return getattr(texts, 'SUBSCRIPTION_ACTIVE', 'Активна')
return getattr(texts, 'SUBSCRIPTION_NONE', 'Нет активной подписки')
if not user or not hasattr(user, 'subscription'):
return getattr(texts, 'SUBSCRIPTION_NONE', 'Нет активной подписки')
if not user.subscription:
return getattr(texts, 'SUBSCRIPTION_NONE', 'Нет активной подписки')
subscription = user.subscription
from datetime import datetime
current_time = datetime.utcnow()
if hasattr(subscription, 'end_date') and subscription.end_date <= current_time:
return f"🔴 Истекла\n📅 {subscription.end_date.strftime('%d.%m.%Y')}"
if hasattr(subscription, 'end_date'):
days_left = (subscription.end_date - current_time).days
else:
days_left = 0
is_trial = getattr(subscription, 'is_trial', False)
if is_trial:
if days_left > 1:
return f"🎁 Тестовая подписка\n📅 до {subscription.end_date.strftime('%d.%m.%Y')} ({days_left} дн.)"
elif days_left == 1:
return "🎁 Тестовая подписка\n⚠️ истекает завтра!"
else:
return "🎁 Тестовая подписка\n⚠️ истекает сегодня!"
else:
if days_left > 7:
return f"💎 Активна\n📅 до {subscription.end_date.strftime('%d.%m.%Y')} ({days_left} дн.)"
elif days_left > 1:
return f"💎 Активна\n⚠️ истекает через {days_left} дн."
elif days_left == 1:
return "💎 Активна\n⚠️ истекает завтра!"
else:
return "💎 Активна\n⚠️ истекает сегодня!"
def _get_subscription_status_simple(texts):
File diff suppressed because it is too large Load Diff
-171
View File
@@ -1,171 +0,0 @@
import logging
from datetime import datetime, timedelta
from typing import List, Optional
from sqlalchemy.ext.asyncio import AsyncSession
from aiogram import Bot
from aiogram.exceptions import TelegramBadRequest
from app.database.models import User, Subscription
from app.database.crud.user import get_user_by_id, subtract_user_balance
from app.database.crud.subscription import get_expiring_subscriptions, extend_subscription
from app.database.crud.transaction import create_transaction
from app.database.models import TransactionType
from app.keyboards.inline import get_autopay_notification_keyboard, get_subscription_expiring_keyboard
from app.localization.texts import get_texts
from app.services.subscription_service import SubscriptionService
logger = logging.getLogger(__name__)
async def send_subscription_expiring_notification(
bot: Bot,
db: AsyncSession,
subscription: Subscription,
days_left: int
) -> bool:
try:
user = await get_user_by_id(db, subscription.user_id)
if not user:
return False
texts = get_texts(user.language)
if subscription.is_trial:
text = texts.TRIAL_ENDING_SOON.format(
price=texts.format_price(30000)
)
else:
autopay_status = texts.AUTOPAY_ENABLED_TEXT if subscription.autopay_enabled else texts.AUTOPAY_DISABLED_TEXT
if subscription.autopay_enabled:
action_text = f"💰 Убедитесь, что на балансе достаточно средств: {texts.format_price(user.balance_kopeks)}"
else:
action_text = "💡 Включите автоплатеж или продлите подписку вручную"
text = texts.SUBSCRIPTION_EXPIRING_PAID.format(
days=days_left,
end_date=subscription.end_date.strftime("%d.%m.%Y %H:%M"),
autopay_status=autopay_status,
action_text=action_text
)
keyboard = get_subscription_expiring_keyboard(subscription.id, user.language)
await bot.send_message(
chat_id=user.telegram_id,
text=text,
reply_markup=keyboard,
parse_mode="HTML"
)
logger.info(f"✅ Отправлено уведомление об истечении подписки пользователю {user.telegram_id}")
return True
except TelegramBadRequest as e:
logger.warning(f"⚠️ Не удалось отправить уведомление пользователю {user.telegram_id}: {e}")
return False
except Exception as e:
logger.error(f"❌ Ошибка отправки уведомления об истечении подписки: {e}")
return False
async def send_autopay_failed_notification(
bot: Bot,
db: AsyncSession,
subscription: Subscription,
required_amount: int
) -> bool:
try:
user = await get_user_by_id(db, subscription.user_id)
if not user:
return False
texts = get_texts(user.language)
text = texts.AUTOPAY_FAILED.format(
balance=texts.format_price(user.balance_kopeks),
required=texts.format_price(required_amount)
)
keyboard = get_autopay_notification_keyboard(subscription.id, user.language)
await bot.send_message(
chat_id=user.telegram_id,
text=text,
reply_markup=keyboard,
parse_mode="HTML"
)
logger.info(f"✅ Отправлено уведомление о неудачном автоплатеже пользователю {user.telegram_id}")
return True
except TelegramBadRequest as e:
logger.warning(f"⚠️ Не удалось отправить уведомление пользователю {user.telegram_id}: {e}")
return False
except Exception as e:
logger.error(f"❌ Ошибка отправки уведомления о неудачном автоплатеже: {e}")
return False
async def process_autopayment(
bot: Bot,
db: AsyncSession,
subscription: Subscription
) -> bool:
try:
user = await get_user_by_id(db, subscription.user_id)
if not user:
logger.error(f"Пользователь {subscription.user_id} не найден для автоплатежа")
return False
subscription_service = SubscriptionService()
renewal_cost = await subscription_service.calculate_renewal_price(
subscription, 30, db
)
if user.balance_kopeks < renewal_cost:
logger.warning(f"Недостаточно средств для автоплатежа у пользователя {user.telegram_id}")
await send_autopay_failed_notification(bot, db, subscription, renewal_cost)
return False
success = await subtract_user_balance(
db, user, renewal_cost,
f"Автопродление подписки на 30 дней"
)
if not success:
logger.error(f"Ошибка списания средств для автоплатежа у пользователя {user.telegram_id}")
await send_autopay_failed_notification(bot, db, subscription, renewal_cost)
return False
await extend_subscription(db, subscription, 30)
await subscription_service.update_remnawave_user(db, subscription)
await create_transaction(
db=db,
user_id=user.id,
type=TransactionType.SUBSCRIPTION_PAYMENT,
amount_kopeks=renewal_cost,
description="Автопродление подписки на 30 дней"
)
texts = get_texts(user.language)
success_text = texts.AUTOPAY_SUCCESS.format(
days=30,
amount=texts.format_price(renewal_cost),
new_end_date=subscription.end_date.strftime("%d.%m.%Y %H:%M")
)
await bot.send_message(
chat_id=user.telegram_id,
text=success_text,
parse_mode="HTML"
)
logger.info(f"✅ Автоплатеж успешно выполнен для пользователя {user.telegram_id}")
return True
except Exception as e:
logger.error(f"❌ Ошибка обработки автоплатежа: {e}")
return False
+5 -3
View File
@@ -76,7 +76,7 @@ def get_admin_promocodes_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
InlineKeyboardButton(text=" Создать", callback_data="admin_promo_create")
],
[
InlineKeyboardButton(text="📊 Статистика", callback_data="admin_promo_stats")
InlineKeyboardButton(text="📊 Общая статистика", callback_data="admin_promo_general_stats")
],
[
InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_panel")
@@ -180,7 +180,10 @@ def get_user_management_keyboard(user_id: int, user_status: str, language: str =
InlineKeyboardButton(text="📱 Подписка", callback_data=f"admin_user_subscription_{user_id}")
],
[
InlineKeyboardButton(text="📊 Статистика", callback_data=f"admin_user_statistics_{user_id}"),
InlineKeyboardButton(text="⚙️ Настройка", callback_data=f"admin_user_servers_{user_id}"),
InlineKeyboardButton(text="📊 Статистика", callback_data=f"admin_user_statistics_{user_id}")
],
[
InlineKeyboardButton(text="📋 Транзакции", callback_data=f"admin_user_transactions_{user_id}")
]
]
@@ -578,7 +581,6 @@ def get_admin_pagination_keyboard(
return InlineKeyboardMarkup(inline_keyboard=keyboard)
def get_maintenance_keyboard(language: str = "ru", is_active: bool = False, monitoring_active: bool = False) -> InlineKeyboardMarkup:
"""Клавиатура для управления техработами"""
if language == "en":
toggle_text = "🔴 Disable maintenance" if is_active else "🔧 Enable maintenance"
+227 -106
View File
@@ -1,10 +1,13 @@
from typing import List, Optional
from aiogram import types
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
from datetime import datetime
from app.config import settings, PERIOD_PRICES, TRAFFIC_PRICES
from app.localization.texts import get_texts
import logging
logger = logging.getLogger(__name__)
def get_rules_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
texts = get_texts(language)
@@ -21,20 +24,29 @@ def get_main_menu_keyboard(
is_admin: bool = False,
has_had_paid_subscription: bool = False,
has_active_subscription: bool = False,
subscription_is_active: bool = False
subscription_is_active: bool = False,
balance_kopeks: int = 0
) -> InlineKeyboardMarkup:
texts = get_texts(language)
if settings.DEBUG:
print(f"DEBUG KEYBOARD: language={language}, is_admin={is_admin}, has_had_paid={has_had_paid_subscription}, has_active={has_active_subscription}, sub_active={subscription_is_active}")
print(f"DEBUG KEYBOARD: language={language}, is_admin={is_admin}, has_had_paid={has_had_paid_subscription}, has_active={has_active_subscription}, sub_active={subscription_is_active}, balance={balance_kopeks}")
if hasattr(texts, 'BALANCE_BUTTON') and balance_kopeks > 0:
balance_button_text = texts.BALANCE_BUTTON.format(balance=texts.format_price(balance_kopeks))
else:
balance_button_text = f"💰 Баланс: {texts.format_price(balance_kopeks)}"
keyboard = [
[
InlineKeyboardButton(text=texts.MENU_BALANCE, callback_data="menu_balance"),
InlineKeyboardButton(text=texts.MENU_SUBSCRIPTION, callback_data="menu_subscription")
InlineKeyboardButton(text=balance_button_text, callback_data="menu_balance")
]
]
if has_active_subscription and subscription_is_active:
keyboard.append([
InlineKeyboardButton(text=texts.MENU_SUBSCRIPTION, callback_data="menu_subscription")
])
show_trial = not has_had_paid_subscription and not has_active_subscription
@@ -110,7 +122,7 @@ def get_subscription_keyboard(
if connect_mode == "miniapp_subscription":
keyboard.append([
InlineKeyboardButton(
text="🚀 Открыть в мини-приложении",
text="🔗 Подключиться",
web_app=types.WebAppInfo(url=subscription.subscription_url)
)
])
@@ -118,7 +130,7 @@ def get_subscription_keyboard(
if settings.MINIAPP_CUSTOM_URL:
keyboard.append([
InlineKeyboardButton(
text="🚀 Открыть приложение",
text="🔗 Подключиться",
web_app=types.WebAppInfo(url=settings.MINIAPP_CUSTOM_URL)
)
])
@@ -233,28 +245,57 @@ def get_subscription_period_keyboard(language: str = "ru") -> InlineKeyboardMark
def get_traffic_packages_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
import logging
logger = logging.getLogger(__name__)
from app.config import settings
if settings.is_traffic_fixed():
return get_back_keyboard(language)
logger.info(f"🔍 RAW CONFIG: '{settings.TRAFFIC_PACKAGES_CONFIG}'")
all_packages = settings.get_traffic_packages()
logger.info(f"🔍 ALL PACKAGES: {all_packages}")
enabled_packages = [pkg for pkg in all_packages if pkg['enabled']]
disabled_packages = [pkg for pkg in all_packages if not pkg['enabled']]
logger.info(f"🔍 ENABLED: {len(enabled_packages)} packages")
logger.info(f"🔍 DISABLED: {len(disabled_packages)} packages")
for pkg in disabled_packages:
logger.info(f"🔍 DISABLED PACKAGE: {pkg['gb']}GB = {pkg['price']} kopeks, enabled={pkg['enabled']}")
texts = get_texts(language)
keyboard = []
packages = [
(5, texts.TRAFFIC_5GB),
(10, texts.TRAFFIC_10GB),
(25, texts.TRAFFIC_25GB),
(50, texts.TRAFFIC_50GB),
(100, texts.TRAFFIC_100GB),
(250, texts.TRAFFIC_250GB),
(0, texts.TRAFFIC_UNLIMITED)
]
traffic_packages = settings.get_traffic_packages()
for gb, text in packages:
for package in traffic_packages:
gb = package["gb"]
price = package["price"]
enabled = package["enabled"]
if not enabled:
continue
if gb == 0:
text = f"♾️ Безлимит - {settings.format_price(package['price'])}"
else:
text = f"📊 {gb} ГБ - {settings.format_price(package['price'])}"
keyboard.append([
InlineKeyboardButton(text=text, callback_data=f"traffic_{gb}")
])
if not keyboard:
keyboard.append([
InlineKeyboardButton(
text="⚠️ Пакеты трафика не настроены",
callback_data="no_traffic_packages"
)
])
keyboard.append([
InlineKeyboardButton(text=texts.BACK, callback_data="subscription_config_back")
@@ -262,7 +303,6 @@ def get_traffic_packages_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
return InlineKeyboardMarkup(inline_keyboard=keyboard)
def get_countries_keyboard(countries: List[dict], selected: List[str], language: str = "ru") -> InlineKeyboardMarkup:
texts = get_texts(language)
keyboard = []
@@ -306,21 +346,30 @@ def get_devices_keyboard(current: int, language: str = "ru") -> InlineKeyboardMa
keyboard = []
start_devices = settings.DEFAULT_DEVICE_LIMIT
end_devices = max(start_devices + 3, 6)
max_devices = settings.MAX_DEVICES_LIMIT if settings.MAX_DEVICES_LIMIT > 0 else 50
end_devices = min(max_devices + 1, start_devices + 10)
buttons = []
for devices in range(start_devices, end_devices):
price = max(0, devices - settings.DEFAULT_DEVICE_LIMIT) * settings.PRICE_PER_DEVICE
price_text = f" (+{texts.format_price(price)})" if price > 0 else " (включено)"
price_text = f" (+{texts.format_price(price)})" if price > 0 else " (вкл.)"
emoji = "" if devices == current else ""
device_word = _get_device_declension(devices)
button_text = f"{emoji} {devices}{price_text}"
keyboard.append([
buttons.append(
InlineKeyboardButton(
text=f"{emoji} {devices} {device_word}{price_text}",
text=button_text,
callback_data=f"devices_{devices}"
)
])
)
for i in range(0, len(buttons), 2):
if i + 1 < len(buttons):
keyboard.append([buttons[i], buttons[i + 1]])
else:
keyboard.append([buttons[i]])
keyboard.extend([
[InlineKeyboardButton(text="✅ Продолжить", callback_data="devices_continue")],
@@ -476,14 +525,35 @@ def get_subscription_expiring_keyboard(subscription_id: int, language: str = "ru
def get_referral_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
texts = get_texts(language)
return InlineKeyboardMarkup(inline_keyboard=[
keyboard = [
[
InlineKeyboardButton(text=texts.CREATE_INVITE, callback_data="referral_create_invite")
InlineKeyboardButton(
text="📝 Создать приглашение",
callback_data="referral_create_invite"
)
],
[
InlineKeyboardButton(text=texts.BACK, callback_data="back_to_menu")
InlineKeyboardButton(
text="👥 Список рефералов",
callback_data="referral_list"
)
],
[
InlineKeyboardButton(
text="📊 Аналитика",
callback_data="referral_analytics"
)
],
[
InlineKeyboardButton(
text=texts.BACK,
callback_data="back_to_menu"
)
]
])
]
return InlineKeyboardMarkup(inline_keyboard=keyboard)
def get_support_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
@@ -610,60 +680,103 @@ def get_extend_subscription_keyboard(language: str = "ru") -> InlineKeyboardMark
return InlineKeyboardMarkup(inline_keyboard=keyboard)
def get_add_traffic_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
def get_add_traffic_keyboard(language: str = "ru", subscription_end_date: datetime = None) -> InlineKeyboardMarkup:
from app.utils.pricing_utils import get_remaining_months
from app.config import settings
if settings.is_traffic_fixed():
return get_back_keyboard(language)
months_multiplier = 1
period_text = ""
if subscription_end_date:
months_multiplier = get_remaining_months(subscription_end_date)
if months_multiplier > 1:
period_text = f" (за {months_multiplier} мес)"
texts = get_texts(language)
keyboard = []
packages = settings.get_traffic_packages()
enabled_packages = [pkg for pkg in packages if pkg['enabled']]
packages = [
(5, f"📊 +5 ГБ - {settings.format_price(settings.PRICE_TRAFFIC_5GB)}"),
(10, f"📊 +10 ГБ - {settings.format_price(settings.PRICE_TRAFFIC_10GB)}"),
(25, f"📊 +25 ГБ - {settings.format_price(settings.PRICE_TRAFFIC_25GB)}"),
(50, f"📊 +50 ГБ - {settings.format_price(settings.PRICE_TRAFFIC_50GB)}"),
(100, f"📊 +100 ГБ - {settings.format_price(settings.PRICE_TRAFFIC_100GB)}"),
(0, f"📊 Безлимит - {settings.format_price(settings.PRICE_TRAFFIC_UNLIMITED)}")
]
if not enabled_packages:
return InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(
text="❌ Нет доступных пакетов" if language == "ru" else "❌ No packages available",
callback_data="no_traffic_packages"
)],
[InlineKeyboardButton(
text="⬅️ Назад" if language == "ru" else "⬅️ Back",
callback_data="menu_subscription"
)]
])
for gb, text in packages:
keyboard.append([
buttons = []
for package in enabled_packages:
gb = package['gb']
price_per_month = package['price']
total_price = price_per_month * months_multiplier
if gb == 0:
if language == "ru":
text = f"♾️ Безлимитный трафик - {total_price/100:.2f}{period_text}"
else:
text = f"♾️ Unlimited traffic - {total_price/100:.2f}{period_text}"
else:
if language == "ru":
text = f"📊 +{gb} ГБ трафика - {total_price/100:.2f}{period_text}"
else:
text = f"📊 +{gb} GB traffic - {total_price/100:.2f}{period_text}"
buttons.append([
InlineKeyboardButton(text=text, callback_data=f"add_traffic_{gb}")
])
keyboard.append([
InlineKeyboardButton(text=texts.BACK, callback_data="menu_subscription")
buttons.append([
InlineKeyboardButton(
text="⬅️ Назад" if language == "ru" else "⬅️ Back",
callback_data="menu_subscription"
)
])
return InlineKeyboardMarkup(inline_keyboard=keyboard)
def get_add_devices_keyboard(current_devices: int, language: str = "ru") -> InlineKeyboardMarkup:
texts = get_texts(language)
keyboard = []
return InlineKeyboardMarkup(inline_keyboard=buttons)
max_devices = 10
def get_add_devices_keyboard(current_devices: int, language: str = "ru", subscription_end_date: datetime = None) -> InlineKeyboardMarkup:
from app.utils.pricing_utils import get_remaining_months
from app.config import settings
for add_count in range(1, min(6, max_devices - current_devices + 1)):
price = add_count * settings.PRICE_PER_DEVICE
total_devices = current_devices + add_count
months_multiplier = 1
period_text = ""
if subscription_end_date:
months_multiplier = get_remaining_months(subscription_end_date)
if months_multiplier > 1:
period_text = f" (за {months_multiplier} мес)"
device_price_per_month = settings.PRICE_PER_DEVICE
buttons = []
for count in [1, 2, 3, 4, 5]:
new_total = current_devices + count
if settings.MAX_DEVICES_LIMIT > 0 and new_total > settings.MAX_DEVICES_LIMIT:
continue
add_device_word = _get_device_declension(add_count)
price_per_month = count * device_price_per_month
total_price = price_per_month * months_multiplier
keyboard.append([
InlineKeyboardButton(
text=f"📱 +{add_count} {add_device_word} (итого: {total_devices}) - {settings.format_price(price)}",
callback_data=f"add_devices_{add_count}"
)
if language == "ru":
text = f"📱 +{count} устройство(а) (итого: {new_total}) - {total_price/100:.2f}{period_text}"
else:
text = f"📱 +{count} device(s) (total: {new_total}) - {total_price/100:.2f}{period_text}"
buttons.append([
InlineKeyboardButton(text=text, callback_data=f"add_devices_{count}")
])
keyboard.append([
InlineKeyboardButton(text=texts.BACK, callback_data="menu_subscription")
buttons.append([
InlineKeyboardButton(
text="⬅️ Назад" if language == "ru" else "⬅️ Back",
callback_data="menu_subscription"
)
])
return InlineKeyboardMarkup(inline_keyboard=keyboard)
return InlineKeyboardMarkup(inline_keyboard=buttons)
def get_reset_traffic_confirm_keyboard(price_kopeks: int, language: str = "ru") -> InlineKeyboardMarkup:
@@ -689,64 +802,72 @@ def get_manage_countries_keyboard(
countries: List[dict],
selected: List[str],
current_subscription_countries: List[str],
language: str = "ru"
language: str = "ru",
subscription_end_date: datetime = None
) -> InlineKeyboardMarkup:
texts = get_texts(language)
keyboard = []
from app.utils.pricing_utils import get_remaining_months
months_multiplier = 1
if subscription_end_date:
months_multiplier = get_remaining_months(subscription_end_date)
logger.info(f"🔍 Расчет для управления странами: осталось {months_multiplier} месяцев до {subscription_end_date}")
buttons = []
total_cost = 0
for country in countries:
if not country.get('is_available', True):
continue
uuid = country['uuid']
name = country['name']
price_per_month = country['price_kopeks']
is_currently_connected = country['uuid'] in current_subscription_countries
is_selected = country['uuid'] in selected
if is_currently_connected:
if is_selected:
emoji = ""
status = ""
if uuid in current_subscription_countries:
if uuid in selected:
icon = ""
else:
emoji = ""
status = " (отключить БЕСПЛАТНО)"
icon = ""
else:
if is_selected:
emoji = ""
price_text = f" (+{texts.format_price(country['price_kopeks'])})" if country['price_kopeks'] > 0 else " (Бесплатно)"
status = price_text
if uuid in selected:
icon = ""
total_cost += price_per_month * months_multiplier
else:
emoji = ""
price_text = f" (+{texts.format_price(country['price_kopeks'])})" if country['price_kopeks'] > 0 else " (Бесплатно)"
status = price_text
icon = ""
keyboard.append([
if uuid not in current_subscription_countries and uuid in selected:
total_price = price_per_month * months_multiplier
if months_multiplier > 1:
price_text = f" ({price_per_month/100:.2f}₽/мес × {months_multiplier} = {total_price/100:.2f}₽)"
logger.info(f"🔍 Сервер {name}: {price_per_month/100}₽/мес × {months_multiplier} мес = {total_price/100}")
else:
price_text = f" ({total_price/100:.2f}₽)"
display_name = f"{icon} {name}{price_text}"
else:
display_name = f"{icon} {name}"
buttons.append([
InlineKeyboardButton(
text=f"{emoji} {country['name']}{status}",
callback_data=f"country_manage_{country['uuid']}"
text=display_name,
callback_data=f"country_manage_{uuid}"
)
])
if not keyboard:
keyboard.append([
InlineKeyboardButton(
text="❌ Нет доступных серверов",
callback_data="no_servers"
)
])
if total_cost > 0:
apply_text = f"✅ Применить изменения ({total_cost/100:.2f} ₽)"
logger.info(f"🔍 Общая стоимость новых серверов: {total_cost/100}")
else:
apply_text = "✅ Применить изменения"
added = [c for c in selected if c not in current_subscription_countries]
removed = [c for c in current_subscription_countries if c not in selected]
apply_text = "✅ Применить изменения"
if added or removed:
changes_count = len(added) + len(removed)
apply_text += f" ({changes_count})"
keyboard.extend([
[InlineKeyboardButton(text=apply_text, callback_data="countries_apply")],
[InlineKeyboardButton(text="❌ Отмена", callback_data="menu_subscription")]
buttons.append([
InlineKeyboardButton(text=apply_text, callback_data="countries_apply")
])
return InlineKeyboardMarkup(inline_keyboard=keyboard)
buttons.append([
InlineKeyboardButton(
text="⬅️ Назад" if language == "ru" else "⬅️ Back",
callback_data="menu_subscription"
)
])
return InlineKeyboardMarkup(inline_keyboard=buttons)
def get_device_selection_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
from app.config import settings
+6 -6
View File
@@ -105,19 +105,17 @@ class RussianTexts(Texts):
REFERRAL_CODE_APPLIED = "🎁 Реферальный код применен! Вы получите бонус после первой покупки."
REFERRAL_CODE_INVALID = "❌ Неверный реферальный код"
REFERRAL_CODE_SKIP = "⏭️ Пропустить"
MAIN_MENU = """👤 <b>{user_name}</b>
💰 <b>Баланс:</b> {balance}
📱 <b>Подписка:</b> {subscription_status}
Выберите действие:
"""
MENU_BALANCE = "💰 Баланс"
MENU_SUBSCRIPTION = "📱 Подписка"
MENU_TRIAL = "🎁 Тестовая подписка"
MENU_TRIAL = "🧪 Тестовая подписка"
MENU_BUY_SUBSCRIPTION = "💎 Купить подписку"
MENU_EXTEND_SUBSCRIPTION = "⏰ Продлить подписку"
MENU_PROMOCODE = "🎫 Промокод"
@@ -126,9 +124,11 @@ class RussianTexts(Texts):
MENU_RULES = "📋 Правила сервиса"
MENU_LANGUAGE = "🌐 Язык"
MENU_ADMIN = "⚙️ Админ-панель"
BALANCE_BUTTON = "💰 Баланс: {balance}"
BALANCE_BUTTON_ZERO = "💰 Баланс: 0.00 ₽"
SUBSCRIPTION_NONE = "❌ Нет активной подписки"
SUBSCRIPTION_TRIAL = "🎁 Тестовая подписка"
SUBSCRIPTION_TRIAL = "🧪 Тестовая подписка"
SUBSCRIPTION_ACTIVE = "✅ Активна"
SUBSCRIPTION_EXPIRED = "⏰ Истекла"
+82 -46
View File
@@ -37,6 +37,7 @@ class MonitoringService:
self.payment_service = PaymentService()
self.bot = bot
self._notified_users: Set[str] = set()
self._last_cleanup = datetime.utcnow()
async def start_monitoring(self):
if self.is_running:
@@ -62,6 +63,8 @@ class MonitoringService:
async def _monitoring_cycle(self):
async for db in get_db():
try:
await self._cleanup_notification_cache()
await self._check_expired_subscriptions(db)
await self._check_expiring_subscriptions(db)
await self._check_trial_expiring_soon(db)
@@ -69,10 +72,6 @@ class MonitoringService:
await self._cleanup_inactive_users(db)
await self._sync_with_remnawave(db)
current_hour = datetime.utcnow().hour
if current_hour == 0:
self._notified_users.clear()
await self._log_monitoring_event(
db, "monitoring_cycle_completed",
"Цикл мониторинга успешно завершен",
@@ -90,6 +89,15 @@ class MonitoringService:
finally:
break
async def _cleanup_notification_cache(self):
current_time = datetime.utcnow()
if (current_time - self._last_cleanup).total_seconds() >= 3600:
old_count = len(self._notified_users)
self._notified_users.clear()
self._last_cleanup = current_time
logger.info(f"🧹 Очищен кеш уведомлений ({old_count} записей)")
async def _check_expired_subscriptions(self, db: AsyncSession):
try:
expired_subscriptions = await get_expired_subscriptions(db)
@@ -168,30 +176,52 @@ class MonitoringService:
async def _check_expiring_subscriptions(self, db: AsyncSession):
try:
warning_days = settings.get_autopay_warning_days()
all_processed_users = set()
for days in warning_days:
expiring_subscriptions = await self._get_expiring_paid_subscriptions(db, days)
sent_count = 0
for subscription in expiring_subscriptions:
user = await get_user_by_id(db, subscription.user_id)
if not user:
continue
notification_key = f"expiring_{user.telegram_id}_{days}d"
if notification_key in self._notified_users:
continue
notification_key = f"expiring_{user.telegram_id}_{days}d_{subscription.id}"
user_key = f"user_{user.telegram_id}_today"
if (notification_key in self._notified_users or
user_key in all_processed_users):
logger.debug(f"🔄 Пропускаем дублирование для пользователя {user.telegram_id} на {days} дней")
continue
should_send = True
for other_days in warning_days:
if other_days < days:
other_subs = await self._get_expiring_paid_subscriptions(db, other_days)
if any(s.user_id == user.id for s in other_subs):
should_send = False
logger.debug(f"🎯 Пропускаем уведомление на {days} дней для пользователя {user.telegram_id}, есть более срочное на {other_days} дней")
break
if not should_send:
continue
if self.bot:
await self._send_subscription_expiring_notification(user, subscription, days)
self._notified_users.add(notification_key)
logger.info(f"⚠️ Пользователю {user.telegram_id} отправлено уведомление об истечении подписки через {days} дней")
success = await self._send_subscription_expiring_notification(user, subscription, days)
if success:
self._notified_users.add(notification_key)
all_processed_users.add(user_key)
sent_count += 1
logger.info(f"✅ Пользователю {user.telegram_id} отправлено уведомление об истечении подписки через {days} дней")
else:
logger.warning(f"❌ Не удалось отправить уведомление пользователю {user.telegram_id}")
if expiring_subscriptions:
if sent_count > 0:
await self._log_monitoring_event(
db, "expiring_notifications_sent",
f"Отправлено {len(expiring_subscriptions)} уведомлений об истечении через {days} дней",
{"days": days, "count": len(expiring_subscriptions)}
f"Отправлено {sent_count} уведомлений об истечении через {days} дней",
{"days": days, "count": sent_count}
)
except Exception as e:
@@ -220,15 +250,15 @@ class MonitoringService:
if not user:
continue
notification_key = f"trial_2h_{user.telegram_id}"
notification_key = f"trial_2h_{user.telegram_id}_{subscription.id}"
if notification_key in self._notified_users:
continue
if self.bot:
await self._send_trial_ending_notification(user, subscription)
self._notified_users.add(notification_key)
logger.info(f"🎁 Пользователю {user.telegram_id} отправлено уведомление об окончании тестовой подписки через 2 часа")
success = await self._send_trial_ending_notification(user, subscription)
if success:
self._notified_users.add(notification_key)
logger.info(f"🎁 Пользователю {user.telegram_id} отправлено уведомление об окончании тестовой подписки через 2 часа")
if trial_expiring:
await self._log_monitoring_event(
@@ -257,9 +287,9 @@ class MonitoringService:
)
)
logger.info(f"🔍 Поиск платных подписок, истекающих в ближайшие {days_before} дней")
logger.info(f"📅 Текущее время: {current_time}")
logger.info(f"📅 Пороговая дата: {threshold_date}")
logger.debug(f"🔍 Поиск платных подписок, истекающих в ближайшие {days_before} дней")
logger.debug(f"📅 Текущее время: {current_time}")
logger.debug(f"📅 Пороговая дата: {threshold_date}")
subscriptions = result.scalars().all()
logger.info(f"📊 Найдено {len(subscriptions)} платных подписок для уведомлений")
@@ -340,15 +370,15 @@ class MonitoringService:
except Exception as e:
logger.error(f"Ошибка обработки автоплатежей: {e}")
async def _send_subscription_expired_notification(self, user: User):
async def _send_subscription_expired_notification(self, user: User) -> bool:
try:
message = """
<b>Подписка истекла</b>
<b>Подписка истекла</b>
Ваша подписка истекла. Для восстановления доступа продлите подписку.
Ваша подписка истекла. Для восстановления доступа продлите подписку.
🔧 Доступ к серверам заблокирован до продления.
"""
🔧 Доступ к серверам заблокирован до продления.
"""
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
@@ -363,11 +393,13 @@ class MonitoringService:
parse_mode="HTML",
reply_markup=keyboard
)
return True
except Exception as e:
logger.error(f"Ошибка отправки уведомления об истечении подписки пользователю {user.telegram_id}: {e}")
return False
async def _send_subscription_expiring_notification(self, user: User, subscription: Subscription, days: int):
async def _send_subscription_expiring_notification(self, user: User, subscription: Subscription, days: int) -> bool:
try:
from app.utils.formatters import format_days_declension
@@ -382,14 +414,14 @@ class MonitoringService:
action_text = "💡 Включите автоплатеж или продлите подписку вручную"
message = f"""
<b>Подписка истекает через {days_text}!</b>
<b>Подписка истекает через {days_text}!</b>
Ваша платная подписка истекает {subscription.end_date.strftime("%d.%m.%Y %H:%M")}.
Ваша платная подписка истекает {subscription.end_date.strftime("%d.%m.%Y %H:%M")}.
💳 <b>Автоплатеж:</b> {autopay_status}
💳 <b>Автоплатеж:</b> {autopay_status}
{action_text}
"""
{action_text}
"""
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
@@ -405,30 +437,32 @@ class MonitoringService:
parse_mode="HTML",
reply_markup=keyboard
)
return True
except Exception as e:
logger.error(f"Ошибка отправки уведомления об истечении подписки пользователю {user.telegram_id}: {e}")
return False
async def _send_trial_ending_notification(self, user: User, subscription: Subscription):
async def _send_trial_ending_notification(self, user: User, subscription: Subscription) -> bool:
try:
texts = get_texts(user.language)
message = f"""
🎁 <b>Тестовая подписка скоро закончится!</b>
🎁 <b>Тестовая подписка скоро закончится!</b>
Ваша тестовая подписка истекает через 2 часа.
Ваша тестовая подписка истекает через 2 часа.
💎 <b>Не хотите остаться без VPN?</b>
Переходите на полную подписку со скидкой!
💎 <b>Не хотите остаться без VPN?</b>
Переходите на полную подписку со скидкой!
🔥 <b>Специальное предложение:</b>
30 дней всего за {settings.format_price(settings.PRICE_30_DAYS)}
Безлимитный трафик
Все серверы доступны
Поддержка до 3 устройств
🔥 <b>Специальное предложение:</b>
30 дней всего за {settings.format_price(settings.PRICE_30_DAYS)}
Безлимитный трафик
Все серверы доступны
Поддержка до 3 устройств
Успейте оформить до окончания тестового периода!
"""
Успейте оформить до окончания тестового периода!
"""
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
@@ -443,9 +477,11 @@ class MonitoringService:
parse_mode="HTML",
reply_markup=keyboard
)
return True
except Exception as e:
logger.error(f"Ошибка отправки уведомления об окончании тестовой подписки пользователю {user.telegram_id}: {e}")
return False
async def _send_autopay_success_notification(self, user: User, amount: int, days: int):
try:
@@ -554,7 +590,7 @@ class MonitoringService:
except Exception as e:
logger.error(f"Ошибка логирования события мониторинга: {e}")
async def get_monitoring_status(self, db: AsyncSession) -> Dict[str, Any]:
try:
from sqlalchemy import select, desc
+95 -5
View File
@@ -9,6 +9,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.services.yookassa_service import YooKassaService
from app.external.telegram_stars import TelegramStarsService
from app.database.crud.yookassa import create_yookassa_payment, link_yookassa_payment_to_transaction
from app.database.crud.transaction import create_transaction
from app.database.crud.user import add_user_balance, get_user_by_id
@@ -22,6 +23,7 @@ class PaymentService:
def __init__(self, bot: Optional[Bot] = None):
self.bot = bot
self.yookassa_service = YooKassaService() if settings.is_yookassa_enabled() else None
self.stars_service = TelegramStarsService(bot) if bot else None
async def create_stars_invoice(
self,
@@ -30,28 +32,105 @@ class PaymentService:
payload: Optional[str] = None
) -> str:
if not self.bot:
if not self.bot or not self.stars_service:
raise ValueError("Bot instance required for Stars payments")
try:
stars_amount = max(1, amount_kopeks // 100)
amount_rubles = amount_kopeks / 100
stars_amount = TelegramStarsService.calculate_stars_from_rubles(amount_rubles)
invoice_link = await self.bot.create_invoice_link(
title="Пополнение баланса VPN",
description=description,
description=f"{description} (≈{stars_amount} ⭐)",
payload=payload or f"balance_topup_{amount_kopeks}",
provider_token="",
currency="XTR",
prices=[LabeledPrice(label="Пополнение", amount=stars_amount)]
)
logger.info(f"Создан Stars invoice на {stars_amount} звезд")
logger.info(f"Создан Stars invoice на {stars_amount} звезд (~{amount_rubles:.2f}₽)")
return invoice_link
except Exception as e:
logger.error(f"Ошибка создания Stars invoice: {e}")
raise
async def process_stars_payment(
self,
db: AsyncSession,
user_id: int,
stars_amount: int,
payload: str,
telegram_payment_charge_id: str
) -> bool:
try:
rubles_amount = TelegramStarsService.calculate_rubles_from_stars(stars_amount)
amount_kopeks = int(rubles_amount * 100)
transaction = await create_transaction(
db=db,
user_id=user_id,
type=TransactionType.DEPOSIT,
amount_kopeks=amount_kopeks,
description=f"Пополнение через Telegram Stars ({stars_amount} ⭐)",
payment_method=PaymentMethod.TELEGRAM_STARS,
external_id=telegram_payment_charge_id,
is_completed=True
)
user = await get_user_by_id(db, user_id)
if user:
old_balance = user.balance_kopeks
user.balance_kopeks += amount_kopeks
user.updated_at = datetime.utcnow()
await db.commit()
await db.refresh(user)
logger.info(f"💰 Баланс пользователя {user.telegram_id} изменен: {old_balance}{user.balance_kopeks} (изменение: +{amount_kopeks})")
description_for_referral = f"Пополнение Stars: {rubles_amount:.2f}₽ ({stars_amount} ⭐)"
logger.info(f"🔍 Проверка реферальной логики для описания: '{description_for_referral}'")
if any(word in description_for_referral.lower() for word in ["пополнение", "stars", "yookassa", "topup"]) and not any(word in description_for_referral.lower() for word in ["комиссия", "бонус"]):
logger.info(f"📞 Вызов process_referral_topup для пользователя {user_id}")
try:
from app.services.referral_service import process_referral_topup
await process_referral_topup(db, user_id, amount_kopeks, self.bot)
except Exception as e:
logger.error(f"Ошибка обработки реферального пополнения: {e}")
else:
logger.info(f"❌ Описание '{description_for_referral}' не подходит для реферальной логики")
if self.bot:
try:
await self.bot.send_message(
user.telegram_id,
f"✅ <b>Пополнение успешно!</b>\n\n"
f"⭐ Звезд: {stars_amount}\n"
f"💰 Сумма: {settings.format_price(amount_kopeks)}\n"
f"🏦 Способ: Telegram Stars\n"
f"🆔 Транзакция: {telegram_payment_charge_id[:8]}...\n\n"
f"Баланс пополнен автоматически!",
parse_mode="HTML"
)
logger.info(f"✅ Отправлено уведомление пользователю {user.telegram_id} о пополнении на {rubles_amount:.2f}")
except Exception as e:
logger.error(f"Ошибка отправки уведомления о пополнении Stars: {e}")
logger.info(
f"✅ Обработан Stars платеж: пользователь {user_id}, "
f"{stars_amount} звезд → {rubles_amount:.2f}"
)
return True
else:
logger.error(f"Пользователь с ID {user_id} не найден при обработке Stars платежа")
return False
except Exception as e:
logger.error(f"Ошибка обработки Stars платежа: {e}", exc_info=True)
return False
async def create_yookassa_payment(
self,
db: AsyncSession,
@@ -188,7 +267,18 @@ class PaymentService:
user = await get_user_by_id(db, updated_payment.user_id)
if user:
await add_user_balance(db, user, updated_payment.amount_kopeks, f"Пополнение YooKassa: {updated_payment.amount_kopeks/100:.2f}")
old_balance = user.balance_kopeks
user.balance_kopeks += updated_payment.amount_kopeks
user.updated_at = datetime.utcnow()
await db.commit()
await db.refresh(user)
try:
from app.services.referral_service import process_referral_topup
await process_referral_topup(db, user.id, updated_payment.amount_kopeks, self.bot)
except Exception as e:
logger.error(f"Ошибка обработки реферального пополнения YooKassa: {e}")
if self.bot:
try:
+170 -93
View File
@@ -1,20 +1,34 @@
import logging
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, delete
from aiogram import Bot
from app.config import settings
from app.database.crud.user import add_user_balance, get_user_by_id
from app.database.crud.referral import create_referral_earning
from app.database.models import TransactionType
from app.database.models import TransactionType, ReferralEarning
logger = logging.getLogger(__name__)
async def send_referral_notification(
bot: Bot,
user_id: int,
message: str
):
try:
await bot.send_message(user_id, message, parse_mode="HTML")
logger.info(f"✅ Уведомление отправлено пользователю {user_id}")
except Exception as e:
logger.error(f"❌ Ошибка отправки уведомления пользователю {user_id}: {e}")
async def process_referral_registration(
db: AsyncSession,
new_user_id: int,
referrer_id: int
referrer_id: int,
bot: Bot = None
):
try:
new_user = await get_user_by_id(db, new_user_id)
referrer = await get_user_by_id(db, referrer_id)
@@ -27,14 +41,6 @@ async def process_referral_registration(
logger.error(f"Пользователь {new_user_id} не привязан к рефереру {referrer_id}")
return False
if settings.REFERRED_USER_REWARD > 0:
await add_user_balance(
db, new_user, settings.REFERRED_USER_REWARD,
f"Бонус за регистрацию по реферальной ссылке"
)
logger.info(f"💰 Новый пользователь {new_user_id} получил бонус {settings.REFERRED_USER_REWARD/100}")
await create_referral_earning(
db=db,
user_id=referrer_id,
@@ -43,7 +49,26 @@ async def process_referral_registration(
reason="referral_registration_pending"
)
logger.info(f"✅ Обработана реферальная регистрация: {new_user_id} -> {referrer_id}")
if bot:
referral_notification = (
f"🎉 <b>Добро пожаловать!</b>\n\n"
f"Вы перешли по реферальной ссылке пользователя <b>{referrer.full_name}</b>!\n\n"
f"💰 При первом пополнении от {settings.format_price(settings.REFERRAL_MINIMUM_TOPUP_KOPEKS)} "
f"вы получите бонус {settings.format_price(settings.REFERRAL_FIRST_TOPUP_BONUS_KOPEKS)}!\n\n"
f"🎁 Ваш реферер также получит награду за ваше первое пополнение."
)
await send_referral_notification(bot, new_user.telegram_id, referral_notification)
inviter_notification = (
f"👥 <b>Новый реферал!</b>\n\n"
f"По вашей ссылке зарегистрировался пользователь <b>{new_user.full_name}</b>!\n\n"
f"💰 Когда он пополнит баланс от {settings.format_price(settings.REFERRAL_MINIMUM_TOPUP_KOPEKS)}, "
f"вы получите {settings.format_price(settings.REFERRAL_INVITER_BONUS_KOPEKS)}\n\n"
f"📈 С каждого последующего пополнения вы будете получать {settings.REFERRAL_COMMISSION_PERCENT}% комиссии."
)
await send_referral_notification(bot, referrer.telegram_id, inviter_notification)
logger.info(f"✅ Зарегистрирован реферал {new_user_id} для {referrer_id}. Бонусы будут выданы после пополнения.")
return True
except Exception as e:
@@ -51,61 +76,144 @@ async def process_referral_registration(
return False
async def process_referral_purchase(
async def process_referral_topup(
db: AsyncSession,
user_id: int,
purchase_amount_kopeks: int,
transaction_id: int = None
topup_amount_kopeks: int,
bot: Bot = None
):
try:
user = await get_user_by_id(db, user_id)
if not user or not user.referred_by_id:
logger.info(f"Пользователь {user_id} не является рефералом")
return False
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
from app.database.crud.referral import get_referral_earnings_by_referral
existing_earnings = await get_referral_earnings_by_referral(db, user_id)
purchase_earnings = [
earning for earning in existing_earnings
if earning.reason in ["referral_first_purchase", "referral_commission"]
]
is_first_purchase = len(purchase_earnings) == 0
logger.info(f"🔍 Покупка реферала {user_id}: первая = {is_first_purchase}, сумма = {purchase_amount_kopeks/100}")
if is_first_purchase and settings.REFERRAL_REGISTRATION_REWARD > 0:
reward_amount = settings.REFERRAL_REGISTRATION_REWARD
if not user.has_made_first_topup:
user.has_made_first_topup = True
await db.commit()
if reward_amount > 1000000:
logger.error(f"❌ КРИТИЧЕСКАЯ ОШИБКА: reward_amount = {reward_amount} слишком большой! Проверьте настройки REFERRAL_REGISTRATION_REWARD")
reward_amount = 10000
try:
await db.execute(
delete(ReferralEarning).where(
ReferralEarning.user_id == referrer.id,
ReferralEarning.referral_id == user_id,
ReferralEarning.reason == "referral_registration_pending"
)
)
await db.commit()
logger.info(f"🗑️ Удалена запись 'ожидание пополнения' для реферала {user_id}")
except Exception as e:
logger.error(f"Ошибка удаления записи ожидания: {e}")
await add_user_balance(
db, referrer, reward_amount,
f"Реферальная награда за первую покупку {user.full_name}"
)
if settings.REFERRAL_FIRST_TOPUP_BONUS_KOPEKS > 0:
await add_user_balance(
db, user, settings.REFERRAL_FIRST_TOPUP_BONUS_KOPEKS,
f"Бонус за первое пополнение по реферальной программе",
bot=bot
)
logger.info(f"💰 Реферал {user_id} получил бонус {settings.REFERRAL_FIRST_TOPUP_BONUS_KOPEKS/100}")
if bot:
bonus_notification = (
f"🎉 <b>Бонус получен!</b>\n\n"
f"За первое пополнение вы получили бонус "
f"{settings.format_price(settings.REFERRAL_FIRST_TOPUP_BONUS_KOPEKS)}!\n\n"
f"💎 Средства зачислены на ваш баланс."
)
await send_referral_notification(bot, user.telegram_id, bonus_notification)
await create_referral_earning(
db=db,
user_id=referrer.id,
referral_id=user_id,
amount_kopeks=reward_amount,
reason="referral_first_purchase",
referral_transaction_id=transaction_id
)
logger.info(f"🎉 Первая покупка реферала: {referrer.telegram_id} получил {reward_amount/100}")
if settings.REFERRAL_INVITER_BONUS_KOPEKS > 0:
await add_user_balance(
db, referrer, settings.REFERRAL_INVITER_BONUS_KOPEKS,
f"Бонус за первое пополнение реферала {user.full_name}",
bot=bot
)
await create_referral_earning(
db=db,
user_id=referrer.id,
referral_id=user_id,
amount_kopeks=settings.REFERRAL_INVITER_BONUS_KOPEKS,
reason="referral_first_topup"
)
logger.info(f"💰 Реферер {referrer.telegram_id} получил бонус {settings.REFERRAL_INVITER_BONUS_KOPEKS/100}")
if bot:
inviter_bonus_notification = (
f"💰 <b>Реферальная награда!</b>\n\n"
f"Ваш реферал <b>{user.full_name}</b> сделал первое пополнение!\n\n"
f"🎁 Вы получили награду: {settings.format_price(settings.REFERRAL_INVITER_BONUS_KOPEKS)}\n\n"
f"📈 Теперь с каждого его пополнения вы будете получать {settings.REFERRAL_COMMISSION_PERCENT}% комиссии."
)
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
)
await create_referral_earning(
db=db,
user_id=referrer.id,
referral_id=user_id,
amount_kopeks=commission_amount,
reason="referral_commission_topup"
)
logger.info(f"💰 Комиссия с пополнения: {referrer.telegram_id} получил {commission_amount/100}")
if bot:
commission_notification = (
f"💰 <b>Реферальная комиссия!</b>\n\n"
f"Ваш реферал <b>{user.full_name}</b> пополнил баланс на "
f"{settings.format_price(topup_amount_kopeks)}\n\n"
f"🎁 Ваша комиссия ({settings.REFERRAL_COMMISSION_PERCENT}%): "
f"{settings.format_price(commission_amount)}\n\n"
f"💎 Средства зачислены на ваш баланс."
)
await send_referral_notification(bot, referrer.telegram_id, commission_notification)
return True
except Exception as e:
logger.error(f"Ошибка обработки пополнения реферала: {e}")
return False
async def process_referral_purchase(
db: AsyncSession,
user_id: int,
purchase_amount_kopeks: int,
transaction_id: int = None,
bot: Bot = None
):
try:
user = await get_user_by_id(db, user_id)
if not user or not user.referred_by_id:
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
if not (0 <= settings.REFERRAL_COMMISSION_PERCENT <= 100):
logger.error(f"❌ КРИТИЧЕСКАЯ ОШИБКА: REFERRAL_COMMISSION_PERCENT = {settings.REFERRAL_COMMISSION_PERCENT} некорректный! Должен быть от 0 до 100")
logger.error(f"❌ КРИТИЧЕСКАЯ ОШИБКА: REFERRAL_COMMISSION_PERCENT = {settings.REFERRAL_COMMISSION_PERCENT} некорректный!")
commission_percent = 10
else:
commission_percent = settings.REFERRAL_COMMISSION_PERCENT
@@ -115,7 +223,8 @@ async def process_referral_purchase(
if commission_amount > 0:
await add_user_balance(
db, referrer, commission_amount,
f"Комиссия {commission_percent}% с покупки {user.full_name}"
f"Комиссия {commission_percent}% с покупки {user.full_name}",
bot=bot
)
await create_referral_earning(
@@ -128,6 +237,17 @@ async def process_referral_purchase(
)
logger.info(f"💰 Комиссия с покупки: {referrer.telegram_id} получил {commission_amount/100}")
if bot:
purchase_commission_notification = (
f"💰 <b>Комиссия с покупки!</b>\n\n"
f"Ваш реферал <b>{user.full_name}</b> совершил покупку на "
f"{settings.format_price(purchase_amount_kopeks)}\n\n"
f"🎁 Ваша комиссия ({commission_percent}%): "
f"{settings.format_price(commission_amount)}\n\n"
f"💎 Средства зачислены на ваш баланс."
)
await send_referral_notification(bot, referrer.telegram_id, purchase_commission_notification)
if not user.has_had_paid_subscription:
user.has_had_paid_subscription = True
@@ -141,46 +261,3 @@ async def process_referral_purchase(
import traceback
logger.error(f"Полный traceback: {traceback.format_exc()}")
return False
async def get_referral_stats_for_user(db: AsyncSession, user_id: int) -> dict:
try:
from app.database.crud.referral import get_referral_earnings_sum
from sqlalchemy import select, func
from app.database.models import User
invited_count_result = await db.execute(
select(func.count(User.id)).where(User.referred_by_id == user_id)
)
invited_count = invited_count_result.scalar() or 0
paid_referrals_result = await db.execute(
select(func.count(User.id)).where(
User.referred_by_id == user_id,
User.has_had_paid_subscription == True
)
)
paid_referrals_count = paid_referrals_result.scalar() or 0
total_earned = await get_referral_earnings_sum(db, user_id) or 0
from datetime import datetime, timedelta
month_ago = datetime.utcnow() - timedelta(days=30)
month_earned = await get_referral_earnings_sum(db, user_id, start_date=month_ago) or 0
return {
"invited_count": invited_count,
"paid_referrals_count": paid_referrals_count,
"total_earned_kopeks": total_earned,
"month_earned_kopeks": month_earned
}
except Exception as e:
logger.error(f"Ошибка получения статистики рефералов: {e}")
return {
"invited_count": 0,
"paid_referrals_count": 0,
"total_earned_kopeks": 0,
"month_earned_kopeks": 0
}
+160 -17
View File
@@ -10,6 +10,12 @@ from app.external.remnawave_api import (
TrafficLimitStrategy, RemnaWaveAPIError
)
from app.database.crud.user import get_user_by_id
from app.utils.pricing_utils import (
calculate_months_from_days,
get_remaining_months,
calculate_prorated_price,
validate_pricing_calculation
)
logger = logging.getLogger(__name__)
@@ -229,36 +235,40 @@ class SubscriptionService:
devices: int,
db: AsyncSession
) -> Tuple[int, List[int]]:
from app.config import PERIOD_PRICES, TRAFFIC_PRICES
from app.config import PERIOD_PRICES
from app.database.crud.server_squad import get_server_squad_by_id
if settings.MAX_DEVICES_LIMIT > 0 and devices > settings.MAX_DEVICES_LIMIT:
raise ValueError(f"Превышен максимальный лимит устройств: {settings.MAX_DEVICES_LIMIT}")
base_price = PERIOD_PRICES.get(period_days, 0)
traffic_price = TRAFFIC_PRICES.get(traffic_gb, 0)
traffic_price = settings.get_traffic_price(traffic_gb)
server_prices = []
total_servers_price = 0
for server_id in server_squad_ids:
server = await get_server_squad_by_id(db, server_id)
if server and server.is_available and not server.is_full:
server_prices.append(server.price_kopeks)
total_servers_price += server.price_kopeks
logger.debug(f"🏷️ Сервер {server.display_name}: {server.price_kopeks/100}")
logger.debug(f"Сервер {server.display_name}: {server.price_kopeks/100}")
else:
server_prices.append(0)
logger.warning(f"⚠️ Сервер ID {server_id} недоступен")
logger.warning(f"Сервер ID {server_id} недоступен")
devices_price = max(0, devices - settings.DEFAULT_DEVICE_LIMIT) * settings.PRICE_PER_DEVICE
total_price = base_price + traffic_price + total_servers_price + devices_price
logger.info(f"💰 Расчет стоимости новой подписки:")
logger.info(f" 📅 Период {period_days} дней: {base_price/100}")
logger.info(f" 📊 Трафик {traffic_gb} ГБ: {traffic_price/100}")
logger.info(f" 🌍 Серверы ({len(server_squad_ids)}): {total_servers_price/100}")
logger.info(f" 📱 Устройства ({devices}): {devices_price/100}")
logger.info(f" 💎 ИТОГО: {total_price/100}")
logger.info(f"Расчет стоимости новой подписки:")
logger.info(f" Период {period_days} дней: {base_price/100}")
logger.info(f" Трафик {traffic_gb} ГБ: {traffic_price/100}")
logger.info(f" Серверы ({len(server_squad_ids)}): {total_servers_price/100}")
logger.info(f" Устройства ({devices}): {devices_price/100}")
logger.info(f" ИТОГО: {total_price/100}")
return total_price, server_prices
@@ -269,7 +279,7 @@ class SubscriptionService:
db: AsyncSession
) -> int:
try:
from app.config import PERIOD_PRICES, TRAFFIC_PRICES
from app.config import PERIOD_PRICES
base_price = PERIOD_PRICES.get(period_days, 0)
@@ -279,7 +289,7 @@ class SubscriptionService:
devices_price = max(0, subscription.device_limit - settings.DEFAULT_DEVICE_LIMIT) * settings.PRICE_PER_DEVICE
traffic_price = TRAFFIC_PRICES.get(subscription.traffic_limit_gb, 0)
traffic_price = settings.get_traffic_price(subscription.traffic_limit_gb)
total_price = base_price + servers_price + devices_price + traffic_price
@@ -384,6 +394,139 @@ class SubscriptionService:
except Exception as e:
logger.error(f"Ошибка получения цен стран: {e}")
return len(country_uuids) * 1000
async def calculate_subscription_price_with_months(
self,
period_days: int,
traffic_gb: int,
server_squad_ids: List[int],
devices: int,
db: AsyncSession
) -> Tuple[int, List[int]]:
from app.config import PERIOD_PRICES
from app.database.crud.server_squad import get_server_squad_by_id
if settings.MAX_DEVICES_LIMIT > 0 and devices > settings.MAX_DEVICES_LIMIT:
raise ValueError(f"Превышен максимальный лимит устройств: {settings.MAX_DEVICES_LIMIT}")
months_in_period = calculate_months_from_days(period_days)
base_price = PERIOD_PRICES.get(period_days, 0)
traffic_price_per_month = settings.get_traffic_price(traffic_gb)
total_traffic_price = traffic_price_per_month * months_in_period
server_prices = []
total_servers_price = 0
for server_id in server_squad_ids:
server = await get_server_squad_by_id(db, server_id)
if server and server.is_available and not server.is_full:
server_price_per_month = server.price_kopeks
server_price_total = server_price_per_month * months_in_period
server_prices.append(server_price_total)
total_servers_price += server_price_total
logger.debug(f"Сервер {server.display_name}: {server_price_per_month/100}₽/мес x {months_in_period} мес = {server_price_total/100}")
else:
server_prices.append(0)
logger.warning(f"Сервер ID {server_id} недоступен")
additional_devices = max(0, devices - settings.DEFAULT_DEVICE_LIMIT)
devices_price_per_month = additional_devices * settings.PRICE_PER_DEVICE
total_devices_price = devices_price_per_month * months_in_period
total_price = base_price + total_traffic_price + total_servers_price + total_devices_price
logger.info(f"Расчет стоимости новой подписки на {period_days} дней ({months_in_period} мес):")
logger.info(f" Период {period_days} дней: {base_price/100}")
logger.info(f" Трафик {traffic_gb} ГБ: {traffic_price_per_month/100}₽/мес x {months_in_period} = {total_traffic_price/100}")
logger.info(f" Серверы ({len(server_squad_ids)}): {total_servers_price/100}")
logger.info(f" Устройства ({additional_devices}): {devices_price_per_month/100}₽/мес x {months_in_period} = {total_devices_price/100}")
logger.info(f" ИТОГО: {total_price/100}")
return total_price, server_prices
async def calculate_renewal_price_with_months(
self,
subscription: Subscription,
period_days: int,
db: AsyncSession
) -> int:
try:
from app.config import PERIOD_PRICES
months_in_period = calculate_months_from_days(period_days)
base_price = PERIOD_PRICES.get(period_days, 0)
servers_price_per_month, _ = await self.get_countries_price_by_uuids(
subscription.connected_squads, db
)
total_servers_price = servers_price_per_month * months_in_period
additional_devices = max(0, subscription.device_limit - settings.DEFAULT_DEVICE_LIMIT)
devices_price_per_month = additional_devices * settings.PRICE_PER_DEVICE
total_devices_price = devices_price_per_month * months_in_period
traffic_price_per_month = settings.get_traffic_price(subscription.traffic_limit_gb)
total_traffic_price = traffic_price_per_month * months_in_period
total_price = base_price + total_servers_price + total_devices_price + total_traffic_price
logger.info(f"💰 Расчет стоимости продления подписки {subscription.id} на {period_days} дней ({months_in_period} мес):")
logger.info(f" 📅 Период {period_days} дней: {base_price/100}")
logger.info(f" 🌍 Серверы: {servers_price_per_month/100}₽/мес x {months_in_period} = {total_servers_price/100}")
logger.info(f" 📱 Устройства: {devices_price_per_month/100}₽/мес x {months_in_period} = {total_devices_price/100}")
logger.info(f" 📊 Трафик: {traffic_price_per_month/100}₽/мес x {months_in_period} = {total_traffic_price/100}")
logger.info(f" 💎 ИТОГО: {total_price/100}")
return total_price
except Exception as e:
logger.error(f"Ошибка расчета стоимости продления: {e}")
from app.config import PERIOD_PRICES
return PERIOD_PRICES.get(period_days, 0)
async def calculate_addon_price_with_remaining_period(
self,
subscription: Subscription,
additional_traffic_gb: int = 0,
additional_devices: int = 0,
additional_server_ids: List[int] = None,
db: AsyncSession = None
) -> int:
if additional_server_ids is None:
additional_server_ids = []
current_time = datetime.utcnow()
months_to_pay = get_remaining_months(subscription.end_date)
total_price = 0
if additional_traffic_gb > 0:
traffic_price_per_month = settings.get_traffic_price(additional_traffic_gb)
total_price += traffic_price_per_month * months_to_pay
logger.info(f"Трафик +{additional_traffic_gb}ГБ: {traffic_price_per_month/100}₽/мес x {months_to_pay} = {traffic_price_per_month * months_to_pay/100}")
if additional_devices > 0:
devices_price_per_month = additional_devices * settings.PRICE_PER_DEVICE
total_price += devices_price_per_month * months_to_pay
logger.info(f"Устройства +{additional_devices}: {devices_price_per_month/100}₽/мес x {months_to_pay} = {devices_price_per_month * months_to_pay/100}")
if additional_server_ids and db:
for server_id in additional_server_ids:
from app.database.crud.server_squad import get_server_squad_by_id
server = await get_server_squad_by_id(db, server_id)
if server and server.is_available:
server_price_per_month = server.price_kopeks
server_total_price = server_price_per_month * months_to_pay
total_price += server_total_price
logger.info(f"Сервер {server.display_name}: {server_price_per_month/100}₽/мес x {months_to_pay} = {server_total_price/100}")
logger.info(f"Итого доплата за {months_to_pay} мес: {total_price/100}")
return total_price
def _gb_to_bytes(self, gb: int) -> int:
if gb == 0:
+96 -51
View File
@@ -2,7 +2,7 @@ import logging
from datetime import datetime, timedelta
from typing import Optional, List, Dict, Any
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import delete
from sqlalchemy import delete, select, update
from app.database.crud.user import (
get_user_by_id, get_user_by_telegram_id, get_users_list,
@@ -13,7 +13,7 @@ from app.database.crud.transaction import get_user_transactions_count
from app.database.crud.subscription import get_subscription_by_user_id
from app.database.models import (
User, UserStatus, Subscription, Transaction, PromoCodeUse,
ReferralEarning, SubscriptionServer
ReferralEarning, SubscriptionServer, YooKassaPayment, BroadcastHistory
)
from app.config import settings
@@ -242,9 +242,81 @@ class UserService:
from app.services.subscription_service import SubscriptionService
subscription_service = SubscriptionService()
await subscription_service.disable_remnawave_user(user.remnawave_uuid)
logger.info(f"✅ RemnaWave пользователь {user.remnawave_uuid} деактивирован")
logger.info(f" ✅ RemnaWave пользователь {user.remnawave_uuid} деактивирован")
except Exception as e:
logger.error(f" Ошибка деактивации RemnaWave пользователя: {e}")
logger.warning(f" ⚠️ Ошибка деактивации RemnaWave: {e}")
try:
from app.database.models import YooKassaPayment
from sqlalchemy import select
yookassa_result = await db.execute(
select(YooKassaPayment).where(YooKassaPayment.user_id == user_id)
)
yookassa_payments = yookassa_result.scalars().all()
if yookassa_payments:
logger.info(f" 🔄 Удаляем {len(yookassa_payments)} YooKassa платежей")
await db.execute(
delete(YooKassaPayment).where(YooKassaPayment.user_id == user_id)
)
await db.flush()
logger.info(f" ✅ YooKassa платежи удалены")
except Exception as e:
logger.error(f" ❌ Ошибка удаления YooKassa платежей: {e}")
try:
transactions_result = await db.execute(
select(Transaction).where(Transaction.user_id == user_id)
)
transactions = transactions_result.scalars().all()
if transactions:
logger.info(f" 🔄 Удаляем {len(transactions)} транзакций")
await db.execute(
delete(Transaction).where(Transaction.user_id == user_id)
)
await db.flush()
logger.info(f" ✅ Транзакции удалены")
except Exception as e:
logger.error(f" ❌ Ошибка удаления транзакций: {e}")
try:
await db.execute(
delete(PromoCodeUse).where(PromoCodeUse.user_id == user_id)
)
await db.flush()
logger.info(f" 🗑️ Удалены использования промокодов пользователя {user_id}")
except Exception as e:
logger.error(f" ❌ Ошибка удаления использований промокодов: {e}")
try:
await db.execute(
delete(ReferralEarning).where(ReferralEarning.user_id == user_id)
)
await db.flush()
logger.info(f" 🗑️ Удалены реферальные доходы пользователя {user_id}")
except Exception as e:
logger.error(f" ❌ Ошибка удаления реферальных доходов: {e}")
try:
await db.execute(
delete(ReferralEarning).where(ReferralEarning.referral_id == user_id)
)
await db.flush()
logger.info(f" 🗑️ Удалены реферальные записи о пользователе {user_id}")
except Exception as e:
logger.error(f" ❌ Ошибка удаления реферальных записей: {e}")
try:
from app.database.models import BroadcastHistory
await db.execute(
delete(BroadcastHistory).where(BroadcastHistory.admin_id == user_id)
)
await db.flush()
logger.info(f" 🗑️ Удалена история рассылок админа {user_id}")
except Exception as e:
logger.error(f" ❌ Ошибка удаления истории рассылок: {e}")
if user.subscription:
try:
@@ -253,74 +325,47 @@ class UserService:
SubscriptionServer.subscription_id == user.subscription.id
)
)
logger.info(f"🗑️ Удалены записи SubscriptionServer для подписки {user.subscription.id}")
await db.flush()
logger.info(f" 🗑️ Удалены записи SubscriptionServer для подписки {user.subscription.id}")
except Exception as e:
logger.error(f"❌ Ошибка удаления SubscriptionServer: {e}")
logger.error(f" ❌ Ошибка удаления SubscriptionServer: {e}")
if user.subscription:
try:
await db.delete(user.subscription)
logger.info(f"🗑️ Удалена подписка пользователя {user_id}")
from app.database.models import Subscription
await db.execute(
delete(Subscription).where(Subscription.user_id == user_id)
)
await db.flush()
logger.info(f" 🗑️ Удалена подписка пользователя {user_id}")
except Exception as e:
logger.error(f"❌ Ошибка удаления подписки: {e}")
try:
await db.execute(
delete(PromoCodeUse).where(PromoCodeUse.user_id == user.id)
)
logger.info(f"🗑️ Удалены использования промокодов пользователя {user_id}")
except Exception as e:
logger.error(f"❌ Ошибка удаления использований промокодов: {e}")
try:
await db.execute(
delete(ReferralEarning).where(ReferralEarning.user_id == user.id)
)
logger.info(f"🗑️ Удалены реферальные доходы пользователя {user_id}")
except Exception as e:
logger.error(f"❌ Ошибка удаления реферальных доходов: {e}")
try:
await db.execute(
delete(ReferralEarning).where(ReferralEarning.referral_id == user.id)
)
logger.info(f"🗑️ Удалены реферальные записи о пользователе {user_id}")
except Exception as e:
logger.error(f"❌ Ошибка удаления реферальных записей: {e}")
logger.error(f" ❌ Ошибка удаления подписки: {e}")
try:
from sqlalchemy import update
await db.execute(
referrals_result = await db.execute(
update(User)
.where(User.referred_by_id == user.id)
.where(User.referred_by_id == user_id)
.values(referred_by_id=None)
)
logger.info(f"🗑️ Очищены реферальные ссылки у рефералов пользователя {user_id}")
if referrals_result.rowcount > 0:
logger.info(f" 🔗 Очищены реферальные ссылки у {referrals_result.rowcount} рефералов")
await db.flush()
except Exception as e:
logger.error(f"❌ Ошибка очистки реферальных ссылок: {e}")
logger.error(f" ❌ Ошибка очистки реферальных ссылок: {e}")
try:
await db.execute(
delete(Transaction).where(Transaction.user_id == user.id)
delete(User).where(User.id == user_id)
)
logger.info(f"🗑️ Удалены транзакции пользователя {user_id}")
except Exception as e:
logger.error(f"❌ Ошибка удаления транзакций: {e}")
try:
user.status = UserStatus.DELETED.value
user.balance_kopeks = 0
user.remnawave_uuid = None
user.updated_at = datetime.utcnow()
await db.commit()
logger.info(f"✅ Пользователь {user_id} помечен как удаленный и обнулен")
logger.info(f" ✅ Пользователь {user_id} окончательно удален из базы")
except Exception as e:
logger.error(f"❌ Ошибка обновления статуса пользователя: {e}")
logger.error(f" ❌ Ошибка финального удаления пользователя: {e}")
await db.rollback()
return False
logger.info(f"✅ Пользователь {user_id} (Telegram ID: {user.telegram_id}) полностью удален админом {admin_id}")
logger.info(f"✅ Пользователь {user.telegram_id} (ID: {user_id}) полностью удален администратором {admin_id}")
return True
except Exception as e:
+2
View File
@@ -52,6 +52,8 @@ class AdminStates(StatesGroup):
editing_squad_price = State()
editing_traffic_price = State()
editing_device_price = State()
editing_user_devices = State()
editing_user_traffic = State()
editing_rules_page = State()
+13 -3
View File
@@ -1,3 +1,13 @@
"""
Утилиты
"""
from .pricing_utils import (
calculate_months_from_days,
get_remaining_months,
calculate_prorated_price,
format_period_description
)
__all__ = [
'calculate_months_from_days',
'get_remaining_months',
'calculate_prorated_price',
'format_period_description'
]
+94
View File
@@ -0,0 +1,94 @@
from datetime import datetime, timedelta
from typing import Tuple
import logging
logger = logging.getLogger(__name__)
def calculate_months_from_days(days: int) -> int:
return max(1, round(days / 30))
def get_remaining_months(end_date: datetime) -> int:
current_time = datetime.utcnow()
if end_date <= current_time:
return 1
remaining_days = (end_date - current_time).days
return max(1, round(remaining_days / 30))
def calculate_period_multiplier(period_days: int) -> Tuple[int, float]:
exact_months = period_days / 30
months_count = max(1, round(exact_months))
logger.debug(f"Период {period_days} дней = {exact_months:.2f} точных месяцев ≈ {months_count} месяцев для расчета")
return months_count, exact_months
def calculate_prorated_price(
monthly_price: int,
end_date: datetime,
min_charge_months: int = 1
) -> Tuple[int, int]:
months_remaining = get_remaining_months(end_date)
months_to_charge = max(min_charge_months, months_remaining)
total_price = monthly_price * months_to_charge
logger.debug(f"Расчет пропорциональной цены: {monthly_price/100}₽/мес × {months_to_charge} мес = {total_price/100}")
return total_price, months_to_charge
def format_period_description(days: int, language: str = "ru") -> str:
months = calculate_months_from_days(days)
if language == "ru":
if days == 30:
return "1 месяц"
elif days == 60:
return "2 месяца"
elif days == 90:
return "3 месяца"
elif days == 180:
return "6 месяцев"
elif days == 360:
return "12 месяцев"
else:
month_word = "месяц" if months == 1 else ("месяца" if 2 <= months <= 4 else "месяцев")
return f"{days} дней ({months} {month_word})"
else:
month_word = "month" if months == 1 else "months"
return f"{days} days ({months} {month_word})"
def validate_pricing_calculation(
base_price: int,
monthly_additions: int,
months: int,
total_calculated: int
) -> bool:
expected_total = base_price + (monthly_additions * months)
is_valid = expected_total == total_calculated
if not is_valid:
logger.warning(f"Несоответствие в расчете цены: ожидалось {expected_total/100}₽, получено {total_calculated/100}")
logger.warning(f"Детали: базовая цена {base_price/100}₽ + месячные дополнения {monthly_additions/100}× {months} мес")
return is_valid
STANDARD_PERIODS = {
14: {"months": 0.5, "display_ru": "2 недели", "display_en": "2 weeks"},
30: {"months": 1, "display_ru": "1 месяц", "display_en": "1 month"},
60: {"months": 2, "display_ru": "2 месяца", "display_en": "2 months"},
90: {"months": 3, "display_ru": "3 месяца", "display_en": "3 months"},
180: {"months": 6, "display_ru": "6 месяцев", "display_en": "6 months"},
360: {"months": 12, "display_ru": "1 год", "display_en": "1 year"},
}
def get_period_info(days: int) -> dict:
return STANDARD_PERIODS.get(days)
+278 -56
View File
@@ -1,76 +1,298 @@
import logging
import random
import secrets
import string
from datetime import datetime
from datetime import datetime, timedelta
from typing import Optional, Dict, List
from sqlalchemy import select, func, and_
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from app.database.models import User
from sqlalchemy.orm import selectinload
from app.database.models import User, ReferralEarning, Transaction, TransactionType
logger = logging.getLogger(__name__)
async def mark_user_as_had_paid_subscription(
db: AsyncSession,
user: User
) -> None:
if not user.has_had_paid_subscription:
user.has_had_paid_subscription = True
user.updated_at = datetime.utcnow()
await db.commit()
logger.info(f"🎯 Пользователь {user.telegram_id} отмечен как имевший платную подписку")
async def generate_unique_referral_code(db: AsyncSession, telegram_id: int) -> str:
max_attempts = 10
base_code = str(telegram_id)[-6:]
for attempt in range(10):
if attempt == 0:
referral_code = base_code
else:
suffix = ''.join(random.choices(string.ascii_lowercase + string.digits, k=2))
referral_code = base_code + suffix
for _ in range(max_attempts):
code = f"ref{''.join(secrets.choice(string.ascii_letters + string.digits) for _ in range(8))}"
result = await db.execute(
select(User.id).where(User.referral_code == referral_code)
select(User).where(User.referral_code == code)
)
if not result.scalar_one_or_none():
return code
timestamp = str(int(datetime.utcnow().timestamp()))[-6:]
return f"ref{timestamp}"
async def mark_user_as_had_paid_subscription(db: AsyncSession, user_id: int) -> bool:
try:
from app.database.crud.user import get_user_by_id
user = await get_user_by_id(db, user_id)
if not user:
logger.error(f"Пользователь {user_id} не найден")
return False
if not user.has_had_paid_subscription:
user.has_had_paid_subscription = True
user.updated_at = datetime.utcnow()
await db.commit()
logger.info(f"✅ Пользователь {user_id} отмечен как имевший платную подписку")
return True
except Exception as e:
logger.error(f"Ошибка отметки пользователя {user_id} как имевшего платную подписку: {e}")
return False
async def get_user_referral_summary(db: AsyncSession, user_id: int) -> Dict:
try:
invited_count_result = await db.execute(
select(func.count(User.id)).where(User.referred_by_id == user_id)
)
invited_count = invited_count_result.scalar() or 0
referrals_result = await db.execute(
select(User).where(User.referred_by_id == user_id)
)
referrals = referrals_result.scalars().all()
paid_referrals_count = sum(1 for ref in referrals if ref.has_made_first_topup)
total_earnings_result = await db.execute(
select(func.coalesce(func.sum(ReferralEarning.amount_kopeks), 0))
.where(ReferralEarning.user_id == user_id)
)
total_earned_kopeks = total_earnings_result.scalar() or 0
month_ago = datetime.utcnow() - timedelta(days=30)
month_earnings_result = await db.execute(
select(func.coalesce(func.sum(ReferralEarning.amount_kopeks), 0))
.where(
and_(
ReferralEarning.user_id == user_id,
ReferralEarning.created_at >= month_ago
)
)
)
month_earned_kopeks = month_earnings_result.scalar() or 0
recent_earnings_result = await db.execute(
select(ReferralEarning)
.options(selectinload(ReferralEarning.referral))
.where(ReferralEarning.user_id == user_id)
.order_by(ReferralEarning.created_at.desc())
.limit(5)
)
recent_earnings_raw = recent_earnings_result.scalars().all()
recent_earnings = []
for earning in recent_earnings_raw:
if earning.referral:
recent_earnings.append({
'amount_kopeks': earning.amount_kopeks,
'reason': earning.reason,
'referral_name': earning.referral.full_name,
'created_at': earning.created_at
})
earnings_by_type = {}
earnings_by_type_result = await db.execute(
select(
ReferralEarning.reason,
func.count(ReferralEarning.id).label('count'),
func.coalesce(func.sum(ReferralEarning.amount_kopeks), 0).label('total_amount')
)
.where(ReferralEarning.user_id == user_id)
.group_by(ReferralEarning.reason)
)
if not result.scalar():
return referral_code
import uuid
return str(uuid.uuid4())[:8]
async def get_user_referral_summary(db: AsyncSession, user_id: int) -> dict:
try:
from app.services.referral_service import get_referral_stats_for_user
from app.database.crud.referral import get_referral_earnings_by_user
for row in earnings_by_type_result:
earnings_by_type[row.reason] = {
'count': row.count,
'total_amount_kopeks': row.total_amount
}
stats = await get_referral_stats_for_user(db, user_id)
recent_earnings = await get_referral_earnings_by_user(db, user_id, limit=5)
active_referrals_count = 0
for referral in referrals:
if referral.last_activity and referral.last_activity >= month_ago:
active_referrals_count += 1
return {
**stats,
"recent_earnings": [
{
"amount_kopeks": earning.amount_kopeks,
"reason": earning.reason,
"created_at": earning.created_at,
"referral_name": earning.referral.full_name if earning.referral else "Неизвестно"
}
for earning in recent_earnings
]
'invited_count': invited_count,
'paid_referrals_count': paid_referrals_count,
'active_referrals_count': active_referrals_count,
'total_earned_kopeks': total_earned_kopeks,
'month_earned_kopeks': month_earned_kopeks,
'recent_earnings': recent_earnings,
'earnings_by_type': earnings_by_type,
'conversion_rate': round((paid_referrals_count / invited_count * 100) if invited_count > 0 else 0, 1)
}
except Exception as e:
logger.error(f"Ошибка получения сводки рефералов: {e}")
logger.error(f"Ошибка получения статистики рефералов для пользователя {user_id}: {e}")
return {
"invited_count": 0,
"paid_referrals_count": 0,
"total_earned_kopeks": 0,
"month_earned_kopeks": 0,
"recent_earnings": []
}
'invited_count': 0,
'paid_referrals_count': 0,
'active_referrals_count': 0,
'total_earned_kopeks': 0,
'month_earned_kopeks': 0,
'recent_earnings': [],
'earnings_by_type': {},
'conversion_rate': 0.0
}
async def get_detailed_referral_list(db: AsyncSession, user_id: int, limit: int = 20, offset: int = 0) -> Dict:
try:
referrals_result = await db.execute(
select(User)
.where(User.referred_by_id == user_id)
.order_by(User.created_at.desc())
.offset(offset)
.limit(limit)
)
referrals = referrals_result.scalars().all()
total_count_result = await db.execute(
select(func.count(User.id)).where(User.referred_by_id == user_id)
)
total_count = total_count_result.scalar() or 0
detailed_referrals = []
for referral in referrals:
earnings_result = await db.execute(
select(func.coalesce(func.sum(ReferralEarning.amount_kopeks), 0))
.where(
and_(
ReferralEarning.user_id == user_id,
ReferralEarning.referral_id == referral.id
)
)
)
total_earned_from_referral = earnings_result.scalar() or 0
topups_result = await db.execute(
select(func.count(Transaction.id))
.where(
and_(
Transaction.user_id == referral.id,
Transaction.type == TransactionType.DEPOSIT.value,
Transaction.is_completed == True
)
)
)
topups_count = topups_result.scalar() or 0
days_since_registration = (datetime.utcnow() - referral.created_at).days
days_since_activity = None
if referral.last_activity:
days_since_activity = (datetime.utcnow() - referral.last_activity).days
detailed_referrals.append({
'id': referral.id,
'telegram_id': referral.telegram_id,
'full_name': referral.full_name,
'username': referral.username,
'created_at': referral.created_at,
'last_activity': referral.last_activity,
'has_made_first_topup': referral.has_made_first_topup,
'balance_kopeks': referral.balance_kopeks,
'total_earned_kopeks': total_earned_from_referral,
'topups_count': topups_count,
'days_since_registration': days_since_registration,
'days_since_activity': days_since_activity,
'status': 'active' if days_since_activity is not None and days_since_activity <= 30 else 'inactive'
})
return {
'referrals': detailed_referrals,
'total_count': total_count,
'has_next': offset + limit < total_count,
'has_prev': offset > 0,
'current_page': (offset // limit) + 1,
'total_pages': (total_count + limit - 1) // limit
}
except Exception as e:
logger.error(f"Ошибка получения списка рефералов для пользователя {user_id}: {e}")
return {
'referrals': [],
'total_count': 0,
'has_next': False,
'has_prev': False,
'current_page': 1,
'total_pages': 1
}
async def get_referral_analytics(db: AsyncSession, user_id: int) -> Dict:
try:
now = datetime.utcnow()
periods = {
'today': now.replace(hour=0, minute=0, second=0, microsecond=0),
'week': now - timedelta(days=7),
'month': now - timedelta(days=30),
'quarter': now - timedelta(days=90)
}
earnings_by_period = {}
for period_name, start_date in periods.items():
result = await db.execute(
select(func.coalesce(func.sum(ReferralEarning.amount_kopeks), 0))
.where(
and_(
ReferralEarning.user_id == user_id,
ReferralEarning.created_at >= start_date
)
)
)
earnings_by_period[period_name] = result.scalar() or 0
top_referrals_result = await db.execute(
select(
ReferralEarning.referral_id,
func.coalesce(func.sum(ReferralEarning.amount_kopeks), 0).label('total_earned'),
func.count(ReferralEarning.id).label('earnings_count')
)
.where(ReferralEarning.user_id == user_id)
.group_by(ReferralEarning.referral_id)
.order_by(func.sum(ReferralEarning.amount_kopeks).desc())
.limit(5)
)
top_referrals = []
for row in top_referrals_result:
referral_result = await db.execute(
select(User).where(User.id == row.referral_id)
)
referral = referral_result.scalar_one_or_none()
if referral:
top_referrals.append({
'referral_name': referral.full_name,
'total_earned_kopeks': row.total_earned,
'earnings_count': row.earnings_count
})
return {
'earnings_by_period': earnings_by_period,
'top_referrals': top_referrals
}
except Exception as e:
logger.error(f"Ошибка получения аналитики рефералов для пользователя {user_id}: {e}")
return {
'earnings_by_period': {
'today': 0,
'week': 0,
'month': 0,
'quarter': 0
},
'top_referrals': []
}