Compare commits

...

112 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
30 changed files with 3830 additions and 1206 deletions
+3 -2
View File
@@ -80,8 +80,9 @@ TRAFFIC_PACKAGES_CONFIG="5:2000:false,10:3500:false,25:7000:false,50:11000:true,
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
# ===== АВТОПРОДЛЕНИЕ =====
+520 -291
View File
File diff suppressed because it is too large Load Diff
+21 -3
View File
@@ -62,9 +62,14 @@ class Settings(BaseSettings):
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"
@@ -296,6 +301,19 @@ class Settings(BaseSettings):
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
+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
+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_")
)
+38
View File
@@ -526,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):
+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_")
)
+6 -10
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__)
@@ -386,7 +387,6 @@ async def complete_registration_from_callback(
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)
@@ -497,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}")
@@ -557,13 +556,11 @@ async def complete_registration_from_callback(
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)
@@ -674,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}")
+341 -217
View File
@@ -39,7 +39,12 @@ from app.keyboards.inline import (
from app.localization.texts import get_texts
from app.services.remnawave_service import RemnaWaveService
from app.services.subscription_service import SubscriptionService
from app.services.referral_service import process_referral_purchase
from app.utils.pricing_utils import (
calculate_months_from_days,
get_remaining_months,
calculate_prorated_price,
validate_pricing_calculation
)
logger = logging.getLogger(__name__)
@@ -368,21 +373,46 @@ async def activate_trial(
if remnawave_user and hasattr(subscription, 'subscription_url') and subscription.subscription_url:
trial_success_text = f"{texts.TRIAL_ACTIVATED}\n\n"
trial_success_text += f"🔗 <b>Ваша ссылка для подключения:</b>\n"
trial_success_text += f"🔗 <b>Ваша ссылка для импорта в VPN приложениe:</b>\n"
trial_success_text += f"<code>{subscription.subscription_url}</code>\n\n"
trial_success_text += f"📱 Нажмите кнопку ниже, чтобы получить инструкцию по настройке VPN на вашем устройстве"
connect_mode = settings.CONNECT_BUTTON_MODE
if connect_mode == "miniapp_subscription":
connect_keyboard = InlineKeyboardMarkup(inline_keyboard=[
[
InlineKeyboardButton(
text="🔗 Подключиться",
web_app=types.WebAppInfo(url=subscription.subscription_url),
)
],
[InlineKeyboardButton(text="📱 Моя подписка", callback_data="menu_subscription")],
[InlineKeyboardButton(text="⬅️ В главное меню", callback_data="back_to_menu")],
])
elif connect_mode == "miniapp_custom":
if not settings.MINIAPP_CUSTOM_URL:
await callback.answer("⚠ Кастомная ссылка для мини-приложения не настроена", show_alert=True)
return
connect_keyboard = InlineKeyboardMarkup(inline_keyboard=[
[
InlineKeyboardButton(
text="🔗 Подключиться",
web_app=types.WebAppInfo(url=settings.MINIAPP_CUSTOM_URL),
)
],
[InlineKeyboardButton(text="📱 Моя подписка", callback_data="menu_subscription")],
[InlineKeyboardButton(text="⬅️ В главное меню", callback_data="back_to_menu")],
])
else:
connect_keyboard = InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(text="🔗 Подключиться", callback_data="subscription_connect")],
[InlineKeyboardButton(text="📱 Моя подписка", callback_data="menu_subscription")],
[InlineKeyboardButton(text="⬅️ В главное меню", callback_data="back_to_menu")],
])
connect_keyboard = InlineKeyboardMarkup(inline_keyboard=[
[
InlineKeyboardButton(text="🔗 Подключиться", callback_data="subscription_connect")
],
[
InlineKeyboardButton(text="📱 Моя подписка", callback_data="menu_subscription")
],
[
InlineKeyboardButton(text="⬅️ В главное меню", callback_data="back_to_menu")
]
])
await callback.message.edit_text(
trial_success_text,
@@ -451,7 +481,7 @@ async def handle_add_countries(
subscription = db_user.subscription
if not subscription or subscription.is_trial:
await callback.answer(" Эта функция доступна только для платных подписок", show_alert=True)
await callback.answer(" Эта функция доступна только для платных подписок", show_alert=True)
return
countries = await _get_available_countries()
@@ -484,7 +514,8 @@ async def handle_add_countries(
countries,
current_countries.copy(),
current_countries,
db_user.language
db_user.language,
subscription.end_date
),
parse_mode="HTML"
)
@@ -533,7 +564,7 @@ async def handle_manage_country(
subscription = db_user.subscription
if not subscription or subscription.is_trial:
await callback.answer(" Только для платных подписок", show_alert=True)
await callback.answer(" Только для платных подписок", show_alert=True)
return
data = await state.get_data()
@@ -558,13 +589,14 @@ async def handle_manage_country(
countries,
current_selected,
subscription.connected_squads,
db_user.language
db_user.language,
subscription.end_date
)
)
logger.info(f"✅ Клавиатура обновлена")
except Exception as e:
logger.error(f" Ошибка обновления клавиатуры: {e}")
logger.error(f" Ошибка обновления клавиатуры: {e}")
await callback.answer()
@@ -574,6 +606,8 @@ async def apply_countries_changes(
db: AsyncSession,
state: FSMContext
):
from app.utils.pricing_utils import get_remaining_months, calculate_prorated_price
logger.info(f"🔍 Применение изменений стран")
data = await state.get_data()
@@ -593,44 +627,56 @@ async def apply_countries_changes(
logger.info(f"🔍 Добавлено: {added}, Удалено: {removed}")
countries = await _get_available_countries()
cost = 0
months_to_pay = get_remaining_months(subscription.end_date)
cost_per_month = 0
added_names = []
removed_names = []
added_server_prices = []
added_server_ids = []
for country in countries:
if country['uuid'] in added:
cost += country['price_kopeks']
server_price_per_month = country['price_kopeks']
cost_per_month += server_price_per_month
added_names.append(country['name'])
added_server_prices.append(country['price_kopeks'])
if country['uuid'] in removed:
removed_names.append(country['name'])
if cost > 0 and db_user.balance_kopeks < cost:
total_cost, charged_months = calculate_prorated_price(cost_per_month, subscription.end_date)
for country in countries:
if country['uuid'] in added:
server_price_per_month = country['price_kopeks']
server_total_price = server_price_per_month * charged_months
added_server_prices.append(server_total_price)
logger.info(f"Стоимость новых серверов: {cost_per_month/100}₽/мес × {charged_months} мес = {total_cost/100}")
if total_cost > 0 and db_user.balance_kopeks < total_cost:
await callback.answer(
f" Недостаточно средств!\nТребуется: {texts.format_price(cost)}\nУ вас: {texts.format_price(db_user.balance_kopeks)}",
f" Недостаточно средств!\nТребуется: {texts.format_price(total_cost)} (за {charged_months} мес)\nУ вас: {texts.format_price(db_user.balance_kopeks)}",
show_alert=True
)
return
try:
if added and cost > 0:
if added and total_cost > 0:
success = await subtract_user_balance(
db, db_user, cost,
f"Добавление стран: {', '.join(added_names)}"
db, db_user, total_cost,
f"Добавление стран: {', '.join(added_names)} на {charged_months} мес"
)
if not success:
await callback.answer(" Ошибка списания средств", show_alert=True)
await callback.answer(" Ошибка списания средств", show_alert=True)
return
await create_transaction(
db=db,
user_id=db_user.id,
type=TransactionType.SUBSCRIPTION_PAYMENT,
amount_kopeks=cost,
description=f"Добавление стран к подписке: {', '.join(added_names)}"
amount_kopeks=total_cost,
description=f"Добавление стран к подписке: {', '.join(added_names)} на {charged_months} мес"
)
if added:
@@ -643,7 +689,7 @@ async def apply_countries_changes(
await add_subscription_servers(db, subscription, added_server_ids, added_server_prices)
await add_user_to_servers(db, added_server_ids)
logger.info(f"📊 Добавлены серверы с ценами: {list(zip(added_server_ids, added_server_prices))}")
logger.info(f"📊 Добавлены серверы с ценами за {charged_months} мес: {list(zip(added_server_ids, added_server_prices))}")
subscription.connected_squads = selected_countries
subscription.updated_at = datetime.utcnow()
@@ -652,17 +698,6 @@ async def apply_countries_changes(
subscription_service = SubscriptionService()
await subscription_service.update_remnawave_user(db, subscription)
if cost > 0:
try:
await process_referral_purchase(
db=db,
user_id=db_user.id,
purchase_amount_kopeks=cost,
transaction_id=None
)
except Exception as e:
logger.error(f"Ошибка обработки реферальной покупки: {e}")
await db.refresh(subscription)
success_text = "✅ <b>Страны успешно обновлены!</b>\n\n"
@@ -670,8 +705,8 @@ async def apply_countries_changes(
if added_names:
success_text += f"➕ <b>Добавлены страны:</b>\n"
success_text += "\n".join(f"{name}" for name in added_names)
if cost > 0:
success_text += f"\n💰 Списано: {texts.format_price(cost)}"
if total_cost > 0:
success_text += f"\n💰 Списано: {texts.format_price(total_cost)} (за {charged_months} мес)"
success_text += "\n"
if removed_names:
@@ -688,10 +723,10 @@ async def apply_countries_changes(
)
await state.clear()
logger.info(f"✅ Пользователь {db_user.telegram_id} обновил страны. Добавлено: {len(added)}, удалено: {len(removed)}")
logger.info(f"✅ Пользователь {db_user.telegram_id} обновил страны. Добавлено: {len(added)}, удалено: {len(removed)}, заплатил: {total_cost/100}")
except Exception as e:
logger.error(f" Ошибка применения изменений: {e}")
logger.error(f" Ошибка применения изменений: {e}")
await callback.message.edit_text(
texts.ERROR,
reply_markup=get_back_keyboard(db_user.language)
@@ -715,11 +750,11 @@ async def handle_add_traffic(
subscription = db_user.subscription
if not subscription or subscription.is_trial:
await callback.answer(" Эта функция доступна только для платных подписок", show_alert=True)
await callback.answer(" Эта функция доступна только для платных подписок", show_alert=True)
return
if subscription.traffic_limit_gb == 0:
await callback.answer(" У вас уже безлимитный трафик", show_alert=True)
await callback.answer(" У вас уже безлимитный трафик", show_alert=True)
return
current_traffic = subscription.traffic_limit_gb
@@ -728,23 +763,23 @@ async def handle_add_traffic(
f"📈 <b>Добавить трафик к подписке</b>\n\n"
f"Текущий лимит: {texts.format_traffic(current_traffic)}\n"
f"Выберите дополнительный трафик:",
reply_markup=get_add_traffic_keyboard(db_user.language)
reply_markup=get_add_traffic_keyboard(db_user.language, subscription.end_date),
parse_mode="HTML"
)
await callback.answer()
async def handle_add_devices(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession
):
texts = get_texts(db_user.language)
subscription = db_user.subscription
if not subscription or subscription.is_trial:
await callback.answer(" Эта функция доступна только для платных подписок", show_alert=True)
await callback.answer(" Эта функция доступна только для платных подписок", show_alert=True)
return
current_devices = subscription.device_limit
@@ -753,7 +788,8 @@ async def handle_add_devices(
f"📱 <b>Добавить устройства к подписке</b>\n\n"
f"Текущий лимит: {current_devices} устройств\n"
f"Выберите количество дополнительных устройств:",
reply_markup=get_add_devices_keyboard(current_devices, db_user.language)
reply_markup=get_add_devices_keyboard(current_devices, db_user.language, subscription.end_date),
parse_mode="HTML"
)
await callback.answer()
@@ -764,15 +800,17 @@ async def handle_extend_subscription(
db_user: User,
db: AsyncSession
):
from app.utils.pricing_utils import calculate_months_from_days, format_period_description
texts = get_texts(db_user.language)
subscription = db_user.subscription
if not subscription or subscription.is_trial:
await callback.answer(" Продление доступно только для платных подписок", show_alert=True)
await callback.answer(" Продление доступно только для платных подписок", show_alert=True)
return
if subscription.days_left > 3:
await callback.answer(" Продление доступно за 3 дня до окончания подписки", show_alert=True)
await callback.answer(" Продление доступно за 3 дня до окончания подписки", show_alert=True)
return
subscription_service = SubscriptionService()
@@ -782,29 +820,40 @@ async def handle_extend_subscription(
for days in available_periods:
try:
price = await subscription_service.calculate_renewal_price(subscription, days, db)
months_in_period = calculate_months_from_days(days)
from app.config import PERIOD_PRICES
base_price = PERIOD_PRICES.get(days, 0)
servers_price_per_month, _ = await subscription_service.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
price = base_price + total_servers_price + total_devices_price + total_traffic_price
renewal_prices[days] = price
except Exception as e:
logger.error(f"Ошибка расчета цены для периода {days}: {e}")
continue
if not renewal_prices:
await callback.answer(" Нет доступных периодов для продления", show_alert=True)
await callback.answer(" Нет доступных периодов для продления", show_alert=True)
return
prices_text = ""
period_display = {
14: "14 дней",
30: "30 дней",
60: "60 дней",
90: "90 дней",
180: "180 дней",
360: "360 дней"
}
for days in available_periods:
if days in renewal_prices and days in period_display:
prices_text += f"📅 {period_display[days]} - {texts.format_price(renewal_prices[days])}\n"
if days in renewal_prices:
period_display = format_period_description(days, db_user.language)
prices_text += f"📅 {period_display} - {texts.format_price(renewal_prices[days])}\n"
await callback.message.edit_text(
f"⏰ Продление подписки\n\n"
@@ -869,17 +918,18 @@ async def confirm_add_traffic(
db_user: User,
db: AsyncSession
):
from app.config import settings
if settings.is_traffic_fixed():
await callback.answer("⚠️ В текущем режиме трафик фиксированный", show_alert=True)
return
from app.utils.pricing_utils import get_remaining_months, calculate_prorated_price
traffic_gb = int(callback.data.split('_')[2])
texts = get_texts(db_user.language)
subscription = db_user.subscription
price = settings.get_traffic_price(traffic_gb)
months_to_pay = get_remaining_months(subscription.end_date)
traffic_price_per_month = settings.get_traffic_price(traffic_gb)
price, charged_months = calculate_prorated_price(traffic_price_per_month, subscription.end_date)
logger.info(f"Добавление трафика {traffic_gb}ГБ: {traffic_price_per_month/100}₽/мес × {charged_months} мес = {price/100}")
if price == 0 and traffic_gb != 0:
await callback.answer("⚠️ Цена для этого пакета не настроена", show_alert=True)
@@ -892,7 +942,7 @@ async def confirm_add_traffic(
try:
success = await subtract_user_balance(
db, db_user, price,
f"Добавление {traffic_gb} ГБ трафика"
f"Добавление {traffic_gb} ГБ трафика на {charged_months} мес"
)
if not success:
@@ -912,18 +962,9 @@ async def confirm_add_traffic(
user_id=db_user.id,
type=TransactionType.SUBSCRIPTION_PAYMENT,
amount_kopeks=price,
description=f"Добавление {traffic_gb} ГБ трафика"
description=f"Добавление {traffic_gb} ГБ трафика на {charged_months} мес"
)
try:
await process_referral_purchase(
db=db,
user_id=db_user.id,
purchase_amount_kopeks=price,
transaction_id=None
)
except Exception as e:
logger.error(f"Ошибка обработки реферальной покупки: {e}")
await db.refresh(db_user)
await db.refresh(subscription)
@@ -935,12 +976,14 @@ async def confirm_add_traffic(
success_text += f"📈 Добавлено: {traffic_gb} ГБ\n"
success_text += f"Новый лимит: {texts.format_traffic(subscription.traffic_limit_gb)}"
success_text += f"\n💰 Списано: {texts.format_price(price)} (за {charged_months} мес)"
await callback.message.edit_text(
success_text,
reply_markup=get_back_keyboard(db_user.language)
)
logger.info(f"✅ Пользователь {db_user.telegram_id} добавил {traffic_gb} ГБ трафика")
logger.info(f"✅ Пользователь {db_user.telegram_id} добавил {traffic_gb} ГБ трафика за {price/100}")
except Exception as e:
logger.error(f"Ошибка добавления трафика: {e}")
@@ -962,6 +1005,7 @@ async def confirm_add_devices(
db_user: User,
db: AsyncSession
):
from app.utils.pricing_utils import get_remaining_months, calculate_prorated_price
devices_count = int(callback.data.split('_')[2])
texts = get_texts(db_user.language)
@@ -977,7 +1021,10 @@ async def confirm_add_devices(
)
return
price = devices_count * settings.PRICE_PER_DEVICE
devices_price_per_month = devices_count * settings.PRICE_PER_DEVICE
price, charged_months = calculate_prorated_price(devices_price_per_month, subscription.end_date)
logger.info(f"Добавление {devices_count} устройств: {devices_price_per_month/100}₽/мес × {charged_months} мес = {price/100}")
if db_user.balance_kopeks < price:
await callback.answer("⚠️ Недостаточно средств на балансе", show_alert=True)
@@ -986,7 +1033,7 @@ async def confirm_add_devices(
try:
success = await subtract_user_balance(
db, db_user, price,
f"Добавление {devices_count} устройств"
f"Добавление {devices_count} устройств на {charged_months} мес"
)
if not success:
@@ -1003,18 +1050,9 @@ async def confirm_add_devices(
user_id=db_user.id,
type=TransactionType.SUBSCRIPTION_PAYMENT,
amount_kopeks=price,
description=f"Добавление {devices_count} устройств"
description=f"Добавление {devices_count} устройств на {charged_months} мес"
)
try:
await process_referral_purchase(
db=db,
user_id=db_user.id,
purchase_amount_kopeks=price,
transaction_id=None
)
except Exception as e:
logger.error(f"Ошибка обработки реферальной покупки: {e}")
await db.refresh(db_user)
await db.refresh(subscription)
@@ -1022,11 +1060,12 @@ async def confirm_add_devices(
await callback.message.edit_text(
f"✅ Устройства успешно добавлены!\n\n"
f"📱 Добавлено: {devices_count} устройств\n"
f"Новый лимит: {subscription.device_limit} устройств",
f"Новый лимит: {subscription.device_limit} устройств\n"
f"💰 Списано: {texts.format_price(price)} (за {charged_months} мес)",
reply_markup=get_back_keyboard(db_user.language)
)
logger.info(f"✅ Пользователь {db_user.telegram_id} добавил {devices_count} устройств")
logger.info(f"✅ Пользователь {db_user.telegram_id} добавил {devices_count} устройств за {price/100}")
except Exception as e:
logger.error(f"Ошибка добавления устройств: {e}")
@@ -1043,37 +1082,70 @@ async def confirm_extend_subscription(
db_user: User,
db: AsyncSession
):
from app.utils.pricing_utils import calculate_months_from_days, validate_pricing_calculation
days = int(callback.data.split('_')[2])
texts = get_texts(db_user.language)
subscription = db_user.subscription
if not subscription:
await callback.answer(" У вас нет активной подписки", show_alert=True)
await callback.answer(" У вас нет активной подписки", show_alert=True)
return
subscription_service = SubscriptionService()
months_in_period = calculate_months_from_days(days)
try:
price = await subscription_service.calculate_renewal_price(subscription, days, db)
from app.config import PERIOD_PRICES
base_price = PERIOD_PRICES.get(days, 0)
subscription_service = SubscriptionService()
servers_price_per_month, _ = await subscription_service.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
price = base_price + total_servers_price + total_devices_price + total_traffic_price
monthly_additions = servers_price_per_month + devices_price_per_month + traffic_price_per_month
is_valid = validate_pricing_calculation(base_price, monthly_additions, months_in_period, price)
if not is_valid:
logger.error(f"Ошибка в расчете цены продления для пользователя {db_user.telegram_id}")
await callback.answer("Ошибка расчета цены. Обратитесь в поддержку.", show_alert=True)
return
logger.info(f"💰 Расчет продления подписки {subscription.id} на {days} дней ({months_in_period} мес):")
logger.info(f" 📅 Период {days} дней: {base_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" 📊 Трафик: {traffic_price_per_month/100}₽/мес × {months_in_period} = {total_traffic_price/100}")
logger.info(f" 💎 ИТОГО: {price/100}")
except Exception as e:
logger.error(f" ОШИБКА РАСЧЕТА ЦЕНЫ: {e}")
await callback.answer(" Ошибка расчета стоимости", show_alert=True)
logger.error(f" ОШИБКА РАСЧЕТА ЦЕНЫ: {e}")
await callback.answer(" Ошибка расчета стоимости", show_alert=True)
return
if db_user.balance_kopeks < price:
await callback.answer(" Недостаточно средств на балансе", show_alert=True)
await callback.answer(" Недостаточно средств на балансе", show_alert=True)
return
try:
logger.info(f"🔄 Начинаем продление подписки {subscription.id} на {days} дней за {price/100}")
success = await subtract_user_balance(
db, db_user, price,
f"Продление подписки на {days} дней"
)
if not success:
await callback.answer(" Ошибка списания средств", show_alert=True)
await callback.answer(" Ошибка списания средств", show_alert=True)
return
current_time = datetime.utcnow()
@@ -1090,37 +1162,31 @@ async def confirm_extend_subscription(
await db.refresh(subscription)
await db.refresh(db_user)
from app.database.crud.server_squad import get_server_ids_by_uuids
from app.database.crud.subscription import add_subscription_servers
server_ids = await get_server_ids_by_uuids(db, subscription.connected_squads)
if server_ids:
server_prices_for_period = [total_servers_price // len(server_ids)] * len(server_ids)
await add_subscription_servers(db, subscription, server_ids, server_prices_for_period)
try:
remnawave_result = await subscription_service.update_remnawave_user(db, subscription)
if remnawave_result:
logger.info(f"✅ RemnaWave обновлен успешно")
else:
logger.error(f" ОШИБКА ОБНОВЛЕНИЯ REMNAWAVE")
logger.error(f" ОШИБКА ОБНОВЛЕНИЯ REMNAWAVE")
except Exception as e:
logger.error(f" ИСКЛЮЧЕНИЕ ПРИ ОБНОВЛЕНИИ REMNAWAVE: {e}")
logger.error(f" ИСКЛЮЧЕНИЕ ПРИ ОБНОВЛЕНИИ REMNAWAVE: {e}")
try:
transaction = await create_transaction(
db=db,
user_id=db_user.id,
type=TransactionType.SUBSCRIPTION_PAYMENT,
amount_kopeks=price,
description=f"Продление подписки на {days} дней"
)
logger.info(f"✅ Транзакция создана: ID {transaction.id}")
except Exception as e:
logger.error(f"❌ ОШИБКА СОЗДАНИЯ ТРАНЗАКЦИИ: {e}")
await create_transaction(
db=db,
user_id=db_user.id,
type=TransactionType.SUBSCRIPTION_PAYMENT,
amount_kopeks=price,
description=f"Продление подписки на {days} дней ({months_in_period} мес)"
)
try:
await process_referral_purchase(
db=db,
user_id=db_user.id,
purchase_amount_kopeks=price,
transaction_id=None
)
logger.info(f"✅ Рефералы обработаны")
except Exception as e:
logger.error(f"❌ ОШИБКА ОБРАБОТКИ РЕФЕРАЛОВ: {e}")
await callback.message.edit_text(
f"✅ Подписка успешно продлена!\n\n"
@@ -1133,12 +1199,12 @@ async def confirm_extend_subscription(
logger.info(f"✅ Пользователь {db_user.telegram_id} продлил подписку на {days} дней за {price/100}")
except Exception as e:
logger.error(f" КРИТИЧЕСКАЯ ОШИБКА ПРОДЛЕНИЯ: {e}")
logger.error(f" КРИТИЧЕСКАЯ ОШИБКА ПРОДЛЕНИЯ: {e}")
import traceback
logger.error(f"TRACEBACK: {traceback.format_exc()}")
await callback.message.edit_text(
" Произошла ошибка при продлении подписки. Обратитесь в поддержку.",
" Произошла ошибка при продлении подписки. Обратитесь в поддержку.",
reply_markup=get_back_keyboard(db_user.language)
)
@@ -1397,8 +1463,7 @@ async def get_subscription_info_text(subscription, texts, db_user, db: AsyncSess
info_text += f"\n💰 <b>Стоимость подписки в месяц:</b> {texts.format_price(subscription_cost)}"
if subscription_url and subscription_url != "Генерируется...":
info_text += f"\n\n🔗 <b>Ссылка для подключения:</b>\n<code>{subscription_url}</code>"
info_text += f"\n\n📱 Скопируйте ссылку и добавьте в ваше VPN приложение"
info_text += f"\n\n🔗 <b>Ваша ссылка для импорта в VPN приложениe:</b>\n<code>{subscription_url}</code>"
return info_text
@@ -1558,6 +1623,8 @@ async def devices_continue(
db_user: User,
db: AsyncSession
):
from app.utils.pricing_utils import calculate_months_from_days, format_period_description, validate_pricing_calculation
if not callback.data == "devices_continue":
await callback.answer("⚠️ Некорректный запрос", show_alert=True)
return
@@ -1568,30 +1635,48 @@ async def devices_continue(
countries = await _get_available_countries()
selected_countries_names = []
try:
subscription_service = SubscriptionService()
countries_price, _ = await subscription_service.get_countries_price_by_uuids(data['countries'], db)
except AttributeError:
logger.warning("Используем fallback функцию для расчета цен стран")
countries_price, _ = await get_countries_price_by_uuids_fallback(data['countries'], db)
for country in countries:
if country['uuid'] in data['countries']:
selected_countries_names.append(country['name'])
months_in_period = calculate_months_from_days(data['period_days'])
period_display = format_period_description(data['period_days'], db_user.language)
base_price = PERIOD_PRICES[data['period_days']]
if settings.is_traffic_fixed():
traffic_price = settings.get_traffic_price(settings.get_fixed_traffic_limit())
traffic_price_per_month = settings.get_traffic_price(settings.get_fixed_traffic_limit())
final_traffic_gb = settings.get_fixed_traffic_limit()
else:
traffic_price = settings.get_traffic_price(data['traffic_gb'])
traffic_price_per_month = settings.get_traffic_price(data['traffic_gb'])
final_traffic_gb = data['traffic_gb']
devices_price = max(0, data['devices'] - settings.DEFAULT_DEVICE_LIMIT) * settings.PRICE_PER_DEVICE
total_price = base_price + traffic_price + countries_price + devices_price
total_traffic_price = traffic_price_per_month * months_in_period
countries_price_per_month = 0
selected_server_prices = []
for country in countries:
if country['uuid'] in data['countries']:
server_price_per_month = country['price_kopeks']
countries_price_per_month += server_price_per_month
selected_countries_names.append(country['name'])
selected_server_prices.append(server_price_per_month * months_in_period)
total_countries_price = countries_price_per_month * months_in_period
additional_devices = max(0, data['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_countries_price + total_devices_price
monthly_additions = countries_price_per_month + devices_price_per_month + traffic_price_per_month
is_valid = validate_pricing_calculation(base_price, monthly_additions, months_in_period, total_price)
if not is_valid:
logger.error(f"Ошибка в расчете цены подписки для пользователя {db_user.telegram_id}")
await callback.answer("Ошибка расчета цены. Обратитесь в поддержку.", show_alert=True)
return
data['total_price'] = total_price
data['server_prices_for_period'] = selected_server_prices
await state.set_data(data)
if settings.is_traffic_fixed():
@@ -1608,12 +1693,18 @@ async def devices_continue(
summary_text = f"""
📋 <b>Сводка заказа</b>
📅 <b>Период:</b> {data['period_days']} дней
📅 <b>Период:</b> {period_display}
📊 <b>Трафик:</b> {traffic_display}
🌍 <b>Страны:</b> {", ".join(selected_countries_names)}
📱 <b>Устройства:</b> {data['devices']}
💰 <b>Общая стоимость:</b> {texts.format_price(total_price)}
💰 <b>Детализация стоимости:</b>
- Базовый период: {texts.format_price(base_price)}
- Трафик: {texts.format_price(traffic_price_per_month)}/мес × {months_in_period} = {texts.format_price(total_traffic_price)}
- Серверы: {texts.format_price(countries_price_per_month)}/мес × {months_in_period} = {texts.format_price(total_countries_price)}
- Доп. устройства: {texts.format_price(devices_price_per_month)}/мес × {months_in_period} = {texts.format_price(total_devices_price)}
💎 <b>Общая стоимость:</b> {texts.format_price(total_price)}
Подтверждаете покупку?
"""
@@ -1634,30 +1725,57 @@ async def confirm_purchase(
db_user: User,
db: AsyncSession
):
from app.utils.pricing_utils import calculate_months_from_days, validate_pricing_calculation
data = await state.get_data()
texts = get_texts(db_user.language)
countries = await _get_available_countries()
months_in_period = calculate_months_from_days(data['period_days'])
base_price = PERIOD_PRICES[data['period_days']]
countries_price = 0
countries_price_per_month = 0
server_prices = []
for country in countries:
if country['uuid'] in data['countries']:
countries_price += country['price_kopeks']
server_prices.append(country['price_kopeks'])
server_price_per_month = country['price_kopeks']
server_price_total = server_price_per_month * months_in_period
countries_price_per_month += server_price_per_month
server_prices.append(server_price_total)
devices_price = max(0, data['devices'] - settings.DEFAULT_DEVICE_LIMIT) * settings.PRICE_PER_DEVICE
total_countries_price = countries_price_per_month * months_in_period
additional_devices = max(0, data['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
if settings.is_traffic_fixed():
traffic_price = settings.get_traffic_price(settings.get_fixed_traffic_limit())
traffic_price_per_month = settings.get_traffic_price(settings.get_fixed_traffic_limit())
final_traffic_gb = settings.get_fixed_traffic_limit()
else:
traffic_price = settings.get_traffic_price(data['traffic_gb'])
traffic_price_per_month = settings.get_traffic_price(data['traffic_gb'])
final_traffic_gb = data['traffic_gb']
final_price = base_price + traffic_price + countries_price + devices_price
total_traffic_price = traffic_price_per_month * months_in_period
final_price = base_price + total_traffic_price + total_countries_price + total_devices_price
monthly_additions = countries_price_per_month + devices_price_per_month + traffic_price_per_month
is_valid = validate_pricing_calculation(base_price, monthly_additions, months_in_period, final_price)
if not is_valid:
logger.error(f"Ошибка в расчете цены подписки для пользователя {db_user.telegram_id}")
await callback.answer("Ошибка расчета цены. Обратитесь в поддержку.", show_alert=True)
return
logger.info(f"Расчет покупки подписки на {data['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" Серверы: {countries_price_per_month/100}₽/мес × {months_in_period} = {total_countries_price/100}")
logger.info(f" Устройства: {devices_price_per_month/100}₽/мес × {months_in_period} = {total_devices_price/100}")
logger.info(f" ИТОГО: {final_price/100}")
if db_user.balance_kopeks < final_price:
await callback.message.edit_text(
@@ -1684,7 +1802,26 @@ async def confirm_purchase(
existing_subscription = db_user.subscription
if existing_subscription:
logger.info(f"🔄 Обновляем существующую подписку пользователя {db_user.telegram_id}")
logger.info(f"Обновляем существующую подписку пользователя {db_user.telegram_id}")
if existing_subscription.is_trial:
logger.info(f"Конверсия из триала в платную для пользователя {db_user.telegram_id}")
trial_duration = (datetime.utcnow() - existing_subscription.start_date).days
try:
from app.database.crud.subscription_conversion import create_subscription_conversion
await create_subscription_conversion(
db=db,
user_id=db_user.id,
trial_duration_days=trial_duration,
payment_method="balance",
first_payment_amount_kopeks=final_price,
first_paid_period_days=data['period_days']
)
logger.info(f"Записана конверсия: {trial_duration} дн. триал → {data['period_days']} дн. платная за {final_price/100}")
except Exception as conversion_error:
logger.error(f"Ошибка записи конверсии: {conversion_error}")
existing_subscription.is_trial = False
existing_subscription.status = SubscriptionStatus.ACTIVE.value
@@ -1702,10 +1839,8 @@ async def confirm_purchase(
await db.refresh(existing_subscription)
subscription = existing_subscription
logger.info(f"✅ Подписка обновлена. Новая дата окончания: {subscription.end_date}")
else:
logger.info(f"🆕 Создаем новую подписку для пользователя {db_user.telegram_id}")
logger.info(f"Создаем новую подписку для пользователя {db_user.telegram_id}")
subscription = await create_paid_subscription_with_traffic_mode(
db=db,
user_id=db_user.id,
@@ -1727,23 +1862,19 @@ async def confirm_purchase(
await add_subscription_servers(db, subscription, server_ids, server_prices)
await add_user_to_servers(db, server_ids)
logger.info(f"📊 Сохранены цены серверов: {server_prices}")
logger.info(f"📊 Обновлены счетчики пользователей для серверов: {server_ids}")
logger.info(f"Сохранены цены серверов за весь период: {server_prices}")
await db.refresh(db_user)
subscription_service = SubscriptionService()
if db_user.remnawave_uuid:
logger.info(f"🔄 Обновляем существующего RemnaWave пользователя {db_user.remnawave_uuid}")
remnawave_user = await subscription_service.update_remnawave_user(db, subscription)
else:
logger.info(f"🆕 Создаем нового RemnaWave пользователя для {db_user.telegram_id}")
remnawave_user = await subscription_service.create_remnawave_user(db, subscription)
if not remnawave_user:
logger.error(f"⚠️ Не удалось создать/обновить RemnaWave пользователя для {db_user.telegram_id}")
logger.info(f"🔄 Fallback: принудительное создание нового RemnaWave пользователя")
logger.error(f"Не удалось создать/обновить RemnaWave пользователя для {db_user.telegram_id}")
remnawave_user = await subscription_service.create_remnawave_user(db, subscription)
await create_transaction(
@@ -1751,39 +1882,52 @@ async def confirm_purchase(
user_id=db_user.id,
type=TransactionType.SUBSCRIPTION_PAYMENT,
amount_kopeks=final_price,
description=f"Подписка на {data['period_days']} дней"
description=f"Подписка на {data['period_days']} дней ({months_in_period} мес)"
)
try:
await process_referral_purchase(
db=db,
user_id=db_user.id,
purchase_amount_kopeks=final_price,
transaction_id=None
)
except Exception as e:
logger.error(f"Ошибка обработки реферальной покупки: {e}")
await db.refresh(db_user)
await db.refresh(subscription)
if remnawave_user and hasattr(subscription, 'subscription_url') and subscription.subscription_url:
success_text = f"{texts.SUBSCRIPTION_PURCHASED}\n\n"
success_text += f"🔗 <b>Ваша ссылка для подключения:</b>\n"
success_text += f"Ваша ссылка для импорта в VPN приложение:\n"
success_text += f"<code>{subscription.subscription_url}</code>\n\n"
success_text += f"📱 Нажмите кнопку ниже, чтобы получить инструкцию по настройке VPN на вашем устройстве"
connect_keyboard = InlineKeyboardMarkup(inline_keyboard=[
[
InlineKeyboardButton(text="🔗 Подключиться", callback_data="subscription_connect")
],
[
InlineKeyboardButton(text="📱 Моя подписка", callback_data="menu_subscription")
],
[
InlineKeyboardButton(text="⬅️ В главное меню", callback_data="back_to_menu")
]
])
success_text += f"Нажмите кнопку ниже, чтобы получить инструкцию по настройке VPN на вашем устройстве"
connect_mode = settings.CONNECT_BUTTON_MODE
if connect_mode == "miniapp_subscription":
connect_keyboard = InlineKeyboardMarkup(inline_keyboard=[
[
InlineKeyboardButton(
text="Подключиться",
web_app=types.WebAppInfo(url=subscription.subscription_url),
)
],
[InlineKeyboardButton(text="Моя подписка", callback_data="menu_subscription")],
[InlineKeyboardButton(text="В главное меню", callback_data="back_to_menu")],
])
elif connect_mode == "miniapp_custom":
if not settings.MINIAPP_CUSTOM_URL:
await callback.answer("Кастомная ссылка для мини-приложения не настроена", show_alert=True)
return
connect_keyboard = InlineKeyboardMarkup(inline_keyboard=[
[
InlineKeyboardButton(
text="Подключиться",
web_app=types.WebAppInfo(url=settings.MINIAPP_CUSTOM_URL),
)
],
[InlineKeyboardButton(text="Моя подписка", callback_data="menu_subscription")],
[InlineKeyboardButton(text="В главное меню", callback_data="back_to_menu")],
])
else:
connect_keyboard = InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(text="Подключиться", callback_data="subscription_connect")],
[InlineKeyboardButton(text="Моя подписка", callback_data="menu_subscription")],
[InlineKeyboardButton(text="В главное меню", callback_data="back_to_menu")],
])
await callback.message.edit_text(
success_text,
@@ -1792,11 +1936,11 @@ async def confirm_purchase(
)
else:
await callback.message.edit_text(
f"{texts.SUBSCRIPTION_PURCHASED}\n\n⚠️ Ссылка генерируется, перейдите в раздел 'Моя подписка' через несколько секунд.",
f"{texts.SUBSCRIPTION_PURCHASED}\n\nСсылка генерируется, перейдите в раздел 'Моя подписка' через несколько секунд.",
reply_markup=get_back_keyboard(db_user.language)
)
logger.info(f"Пользователь {db_user.telegram_id} купил подписку на {data['period_days']} дней")
logger.info(f"Пользователь {db_user.telegram_id} купил подписку на {data['period_days']} дней за {final_price/100}")
except Exception as e:
logger.error(f"Ошибка покупки подписки: {e}")
@@ -1857,15 +2001,6 @@ async def add_traffic(
description=f"Добавление {traffic_gb} ГБ трафика"
)
try:
await process_referral_purchase(
db=db,
user_id=db_user.id,
purchase_amount_kopeks=price,
transaction_id=None
)
except Exception as e:
logger.error(f"Ошибка обработки реферальной покупки: {e}")
await db.refresh(db_user)
await db.refresh(subscription)
@@ -2364,17 +2499,6 @@ async def confirm_add_countries_to_subscription(
subscription_service = SubscriptionService()
await subscription_service.update_remnawave_user(db, subscription)
if new_countries and total_price > 0:
try:
await process_referral_purchase(
db=db,
user_id=db_user.id,
purchase_amount_kopeks=total_price,
transaction_id=None
)
except Exception as e:
logger.error(f"Ошибка обработки реферальной покупки: {e}")
await db.refresh(db_user)
await db.refresh(subscription)
@@ -2520,7 +2644,7 @@ async def handle_connect_subscription(
keyboard = InlineKeyboardMarkup(inline_keyboard=[
[
InlineKeyboardButton(
text="🔗 Открыть подписку в мини-приложении",
text="🔗 Подключиться",
web_app=types.WebAppInfo(url=subscription.subscription_url)
)
],
@@ -2547,7 +2671,7 @@ async def handle_connect_subscription(
keyboard = InlineKeyboardMarkup(inline_keyboard=[
[
InlineKeyboardButton(
text="🚀 Открыть приложение",
text="🔗 Подключиться",
web_app=types.WebAppInfo(url=settings.MINIAPP_CUSTOM_URL)
)
],
-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"
+155 -102
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)
@@ -119,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)
)
])
@@ -127,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)
)
])
@@ -522,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:
@@ -656,82 +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']]
traffic_packages = settings.get_traffic_packages()
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 package in traffic_packages:
gb = package["gb"]
price = package["price"]
enabled = package["enabled"]
if not enabled:
continue
buttons = []
for package in enabled_packages:
gb = package['gb']
price_per_month = package['price']
total_price = price_per_month * months_multiplier
if gb == 0:
text = f"📊 Безлимит - {settings.format_price(package['price'])}"
if language == "ru":
text = f"♾️ Безлимитный трафик - {total_price/100:.2f}{period_text}"
else:
text = f"♾️ Unlimited traffic - {total_price/100:.2f}{period_text}"
else:
text = f"📊 +{gb} ГБ - {settings.format_price(package['price'])}"
if language == "ru":
text = f"📊 +{gb} ГБ трафика - {total_price/100:.2f}{period_text}"
else:
text = f"📊 +{gb} GB traffic - {total_price/100:.2f}{period_text}"
keyboard.append([
buttons.append([
InlineKeyboardButton(text=text, callback_data=f"add_traffic_{gb}")
])
if not keyboard:
keyboard.append([
InlineKeyboardButton(
text="⚠️ Пакеты трафика не настроены",
callback_data="no_traffic_packages"
)
])
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 = settings.MAX_DEVICES_LIMIT if settings.MAX_DEVICES_LIMIT > 0 else 100
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
max_add = min(5, max_devices - current_devices)
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} мес)"
for add_count in range(1, max_add + 1):
price = add_count * settings.PRICE_PER_DEVICE
total_devices = current_devices + add_count
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}")
])
if max_add == 0:
keyboard.append([
InlineKeyboardButton(
text="⚠️ Достигнут максимум устройств",
callback_data="max_devices_reached"
)
])
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:
@@ -757,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
+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
+34 -8
View File
@@ -80,12 +80,27 @@ class PaymentService:
user = await get_user_by_id(db, user_id)
if user:
await add_user_balance(
db,
user,
amount_kopeks,
f"Пополнение Stars: {rubles_amount:.2f}₽ ({stars_amount} ⭐)"
)
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:
@@ -252,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:
@@ -260,7 +286,7 @@ class PaymentService:
user.telegram_id,
f"✅ <b>Пополнение успешно!</b>\n\n"
f"💰 Сумма: {settings.format_price(updated_payment.amount_kopeks)}\n"
f"🦐 Способ: Банковская карта\n"
f"🏦 Способ: Банковская карта\n"
f"🆔 Транзакция: {yookassa_payment_id[:8]}...\n\n"
f"Баланс пополнен автоматически!",
parse_mode="HTML"
+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
}
+139
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__)
@@ -388,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:
+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': []
}