diff --git a/app/database/crud/campaign.py b/app/database/crud/campaign.py
index 40313434..137b6c09 100644
--- a/app/database/crud/campaign.py
+++ b/app/database/crud/campaign.py
@@ -9,6 +9,10 @@ from sqlalchemy.orm import selectinload
from app.database.models import (
AdvertisingCampaign,
AdvertisingCampaignRegistration,
+ Transaction,
+ TransactionType,
+ Subscription,
+ User,
)
logger = logging.getLogger(__name__)
@@ -197,7 +201,7 @@ async def get_campaign_statistics(
db: AsyncSession,
campaign_id: int,
) -> Dict[str, Optional[int]]:
- result = await db.execute(
+ aggregate_result = await db.execute(
select(
func.count(AdvertisingCampaignRegistration.id),
func.coalesce(
@@ -206,7 +210,7 @@ async def get_campaign_statistics(
func.max(AdvertisingCampaignRegistration.created_at),
).where(AdvertisingCampaignRegistration.campaign_id == campaign_id)
)
- count, total_balance, last_registration = result.one()
+ registrations_count, total_balance, last_registration = aggregate_result.one()
subscription_count_result = await db.execute(
select(func.count(AdvertisingCampaignRegistration.id)).where(
@@ -217,14 +221,134 @@ async def get_campaign_statistics(
)
)
+ campaign_users_subquery = (
+ select(AdvertisingCampaignRegistration.user_id)
+ .where(AdvertisingCampaignRegistration.campaign_id == campaign_id)
+ )
+
+ revenue_filter = and_(
+ Transaction.user_id.in_(campaign_users_subquery),
+ Transaction.is_completed.is_(True),
+ Transaction.amount_kopeks > 0,
+ Transaction.type.in_(
+ [
+ TransactionType.DEPOSIT.value,
+ TransactionType.SUBSCRIPTION_PAYMENT.value,
+ ]
+ ),
+ )
+
+ revenue_result = await db.execute(
+ select(func.coalesce(func.sum(Transaction.amount_kopeks), 0)).where(
+ revenue_filter
+ )
+ )
+ revenue_kopeks = revenue_result.scalar() or 0
+
+ transactions_count_result = await db.execute(
+ select(func.count(Transaction.id)).where(revenue_filter)
+ )
+ transactions_count = transactions_count_result.scalar() or 0
+
+ paying_users_result = await db.execute(
+ select(func.count(func.distinct(Transaction.user_id))).where(
+ revenue_filter
+ )
+ )
+ paying_users = paying_users_result.scalar() or 0
+
+ trial_users_result = await db.execute(
+ select(func.count(func.distinct(Subscription.user_id))).where(
+ and_(
+ Subscription.user_id.in_(campaign_users_subquery),
+ Subscription.is_trial.is_(True),
+ )
+ )
+ )
+ trial_users = trial_users_result.scalar() or 0
+
+ paid_flag_users_result = await db.execute(
+ select(func.count(func.distinct(User.id))).where(
+ and_(
+ User.id.in_(campaign_users_subquery),
+ User.has_had_paid_subscription.is_(True),
+ )
+ )
+ )
+ paid_flag_users = paid_flag_users_result.scalar() or 0
+
+ registrations = registrations_count or 0
+ conversion_base = registrations if registrations > 0 else None
+ effective_paying_users = max(paying_users, paid_flag_users or 0)
+
+ conversion_rate = (
+ round((effective_paying_users / registrations) * 100, 1)
+ if conversion_base
+ else 0.0
+ )
+
+ trial_conversion_rate = (
+ round((effective_paying_users / trial_users) * 100, 1)
+ if trial_users
+ else 0.0
+ )
+
+ avg_revenue_per_user = (
+ int(round(revenue_kopeks / registrations)) if registrations else 0
+ )
+ avg_revenue_per_paying_user = (
+ int(round(revenue_kopeks / effective_paying_users))
+ if effective_paying_users
+ else 0
+ )
+
return {
- "registrations": count or 0,
+ "registrations": registrations,
"balance_issued": total_balance or 0,
"subscription_issued": subscription_count_result.scalar() or 0,
"last_registration": last_registration,
+ "revenue_kopeks": revenue_kopeks,
+ "transactions_count": transactions_count,
+ "paying_users": effective_paying_users,
+ "trial_users": trial_users,
+ "conversion_rate": conversion_rate,
+ "trial_conversion_rate": trial_conversion_rate,
+ "avg_revenue_per_user": avg_revenue_per_user,
+ "avg_revenue_per_paying_user": avg_revenue_per_paying_user,
}
+async def get_campaign_registration_by_user(
+ db: AsyncSession,
+ user_id: int,
+) -> Optional[AdvertisingCampaignRegistration]:
+ result = await db.execute(
+ select(AdvertisingCampaignRegistration)
+ .options(selectinload(AdvertisingCampaignRegistration.campaign))
+ .where(AdvertisingCampaignRegistration.user_id == user_id)
+ .order_by(AdvertisingCampaignRegistration.created_at.desc())
+ .limit(1)
+ )
+ return result.scalar_one_or_none()
+
+
+async def get_campaign_registrations_for_users(
+ db: AsyncSession,
+ user_ids: List[int],
+) -> Dict[int, AdvertisingCampaignRegistration]:
+ if not user_ids:
+ return {}
+
+ result = await db.execute(
+ select(AdvertisingCampaignRegistration)
+ .options(selectinload(AdvertisingCampaignRegistration.campaign))
+ .where(AdvertisingCampaignRegistration.user_id.in_(user_ids))
+ )
+
+ registrations = result.scalars().all()
+ return {registration.user_id: registration for registration in registrations}
+
+
async def get_campaigns_overview(db: AsyncSession) -> Dict[str, int]:
total = await get_campaigns_count(db)
active = await get_campaigns_count(db, is_active=True)
diff --git a/app/handlers/admin/campaigns.py b/app/handlers/admin/campaigns.py
index 22ff8aac..3cc8f0c2 100644
--- a/app/handlers/admin/campaigns.py
+++ b/app/handlers/admin/campaigns.py
@@ -333,6 +333,24 @@ async def show_campaign_detail(
f"• Выдано баланса: {texts.format_price(stats['balance_issued'])}"
)
text.append(f"• Выдано подписок: {stats['subscription_issued']}")
+ text.append(f"• Транзакций: {stats['transactions_count']}")
+ text.append(
+ f"• Доход кампании: {texts.format_price(stats['revenue_kopeks'])}"
+ )
+ text.append(
+ f"• Платящих пользователей: {stats['paying_users']}"
+ )
+ text.append(f"• Взяли триал: {stats['trial_users']}")
+ text.append(f"• Конверсия в оплату: {stats['conversion_rate']}%")
+ text.append(
+ f"• Конверсия триала: {stats['trial_conversion_rate']}%"
+ )
+ text.append(
+ f"• Средний доход на регистрацию: {texts.format_price(stats['avg_revenue_per_user'])}"
+ )
+ text.append(
+ f"• Средний доход на платящего: {texts.format_price(stats['avg_revenue_per_paying_user'])}"
+ )
if stats["last_registration"]:
text.append(
f"• Последняя: {stats['last_registration'].strftime('%d.%m.%Y %H:%M')}"
@@ -1176,6 +1194,18 @@ async def show_campaign_stats(
text.append(f"Регистраций: {stats['registrations']}")
text.append(f"Выдано баланса: {texts.format_price(stats['balance_issued'])}")
text.append(f"Выдано подписок: {stats['subscription_issued']}")
+ text.append(f"Транзакций: {stats['transactions_count']}")
+ text.append(f"Доход кампании: {texts.format_price(stats['revenue_kopeks'])}")
+ text.append(f"Платящих пользователей: {stats['paying_users']}")
+ text.append(f"Взяли триал: {stats['trial_users']}")
+ text.append(f"Конверсия в оплату: {stats['conversion_rate']}%")
+ text.append(f"Конверсия триала: {stats['trial_conversion_rate']}%")
+ text.append(
+ f"Средний доход на регистрацию: {texts.format_price(stats['avg_revenue_per_user'])}"
+ )
+ text.append(
+ f"Средний доход на платящего: {texts.format_price(stats['avg_revenue_per_paying_user'])}"
+ )
if stats["last_registration"]:
text.append(
f"Последняя регистрация: {stats['last_registration'].strftime('%d.%m.%Y %H:%M')}"
diff --git a/app/handlers/admin/users.py b/app/handlers/admin/users.py
index 620e4001..8f7e640a 100644
--- a/app/handlers/admin/users.py
+++ b/app/handlers/admin/users.py
@@ -17,6 +17,7 @@ from app.keyboards.admin import (
from app.localization.texts import get_texts
from app.services.user_service import UserService
from app.database.crud.promo_group import get_promo_groups_with_counts
+from app.database.crud.campaign import get_campaign_registrations_for_users
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
@@ -427,6 +428,7 @@ async def _render_user_subscription_overview(
user = profile["user"]
subscription = profile["subscription"]
+ campaign_registration = profile.get("campaign_registration")
text = "📱 Подписка и настройки пользователя\n\n"
text += f"👤 {user.full_name} (ID: {user.telegram_id})\n\n"
@@ -1236,16 +1238,49 @@ async def show_user_statistics(
text += f"• Отсутствует\n"
text += f"\nРеферальная программа:\n"
-
+
+ registration_lines = []
+
if user.referred_by_id:
referrer = await get_user_by_id(db, user.referred_by_id)
if referrer:
- text += f"• Пришел по реферальной ссылке от {referrer.full_name}\n"
+ registration_lines.append(
+ f"• Пришел по реферальной ссылке от {referrer.full_name}"
+ )
else:
- text += f"• Пришел по реферальной ссылке (реферер не найден)\n"
- else:
- text += f"• Прямая регистрация\n"
-
+ registration_lines.append(
+ "• Пришел по реферальной ссылке (реферер не найден)"
+ )
+
+ if campaign_registration and campaign_registration.campaign:
+ campaign = campaign_registration.campaign
+ registration_lines.append(
+ f"• Пришел через рекламную кампанию {campaign.name} (не прямая регистрация)"
+ )
+ registration_lines.append(
+ f"• Участие в кампании: {format_datetime(campaign_registration.created_at)}"
+ )
+
+ if campaign_registration.bonus_type == "balance":
+ registration_lines.append(
+ f"• Бонус кампании: {settings.format_price(campaign_registration.balance_bonus_kopeks)} на баланс"
+ )
+ elif campaign_registration.bonus_type == "subscription":
+ bonus_parts = []
+ if campaign_registration.subscription_duration_days:
+ bonus_parts.append(
+ f"{campaign_registration.subscription_duration_days} дн."
+ )
+ registration_lines.append(
+ "• Бонус кампании: Подписка"
+ + (f" ({', '.join(bonus_parts)})" if bonus_parts else "")
+ )
+
+ if not registration_lines:
+ registration_lines.append("• Прямая регистрация")
+
+ text += "\n".join(registration_lines) + "\n"
+
text += f"• Реферальный код: {user.referral_code}\n\n"
if referral_stats['invited_count'] > 0:
@@ -1257,11 +1292,15 @@ async def show_user_statistics(
if referral_stats['referrals_detail']:
text += f"\nДетали по рефералам:\n"
- for detail in referral_stats['referrals_detail'][:5]:
+ 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 detail.get('campaign_name'):
+ text += (
+ f" 📣 Кампания: {detail['campaign_name']} (не прямая регистрация)\n"
+ )
if len(referral_stats['referrals_detail']) > 5:
text += f"• ... и еще {len(referral_stats['referrals_detail']) - 5} рефералов\n"
@@ -1292,6 +1331,9 @@ async def get_detailed_referral_stats(db: AsyncSession, user_id: int) -> dict:
referrals_result = await db.execute(referrals_query)
referrals = referrals_result.scalars().all()
+
+ referral_ids = [referral.id for referral in referrals]
+ campaign_registrations = await get_campaign_registrations_for_users(db, referral_ids)
earnings_by_referral = {}
all_earnings = await get_referral_earnings_by_user(db, user_id)
@@ -1316,6 +1358,11 @@ async def get_detailed_referral_stats(db: AsyncSession, user_id: int) -> dict:
referral.subscription.end_date > current_time
)
+ registration = campaign_registrations.get(referral.id)
+ campaign_name = None
+ if registration and registration.campaign:
+ campaign_name = registration.campaign.name
+
referrals_detail.append({
'referral_id': referral.id,
'referral_name': referral.full_name,
@@ -1323,7 +1370,9 @@ async def get_detailed_referral_stats(db: AsyncSession, user_id: int) -> dict:
'total_earned_kopeks': earned,
'is_active': is_active,
'registration_date': referral.created_at,
- 'has_subscription': bool(referral.subscription)
+ 'has_subscription': bool(referral.subscription),
+ 'campaign_name': campaign_name,
+ 'registration_source': 'campaign' if campaign_name else 'referral'
})
referrals_detail.sort(key=lambda x: x['total_earned_kopeks'], reverse=True)
diff --git a/app/handlers/referral.py b/app/handlers/referral.py
index fe3322a8..d1f94a06 100644
--- a/app/handlers/referral.py
+++ b/app/handlers/referral.py
@@ -297,7 +297,16 @@ async def show_detailed_referral_list(
"REFERRAL_LIST_ITEM_ACTIVITY_LONG_AGO",
" 🕐 Активность: давно",
) + "\n"
-
+
+ if (
+ referral.get('registration_source') == 'campaign'
+ and referral.get('campaign_name')
+ ):
+ text += texts.t(
+ "REFERRAL_LIST_ITEM_SOURCE_CAMPAIGN",
+ " 📣 Источник: рекламная кампания «{name}» (не прямой реферал)",
+ ).format(name=referral['campaign_name']) + "\n"
+
text += "\n"
keyboard = []
diff --git a/app/localization/locales/en.json b/app/localization/locales/en.json
index 49417771..655c0662 100644
--- a/app/localization/locales/en.json
+++ b/app/localization/locales/en.json
@@ -368,6 +368,7 @@
"REFERRAL_LIST_ITEM_REGISTERED": " 📅 Registered: {days} days ago",
"REFERRAL_LIST_ITEM_ACTIVITY": " 🕐 Activity: {days} days ago",
"REFERRAL_LIST_ITEM_ACTIVITY_LONG_AGO": " 🕐 Activity: long ago",
+ "REFERRAL_LIST_ITEM_SOURCE_CAMPAIGN": " 📣 Source: advertising campaign “{name}” (not a direct referral)",
"REFERRAL_LIST_PREV_PAGE": "⬅️ Back",
"REFERRAL_LIST_NEXT_PAGE": "Next ➡️",
"REFERRAL_ANALYTICS_TITLE": "📊 Referral analytics",
diff --git a/app/localization/locales/ru.json b/app/localization/locales/ru.json
index 1dc7de10..037ea386 100644
--- a/app/localization/locales/ru.json
+++ b/app/localization/locales/ru.json
@@ -368,6 +368,7 @@
"REFERRAL_LIST_ITEM_REGISTERED": " 📅 Регистрация: {days} дн. назад",
"REFERRAL_LIST_ITEM_ACTIVITY": " 🕐 Активность: {days} дн. назад",
"REFERRAL_LIST_ITEM_ACTIVITY_LONG_AGO": " 🕐 Активность: давно",
+ "REFERRAL_LIST_ITEM_SOURCE_CAMPAIGN": " 📣 Источник: рекламная кампания «{name}» (не прямой реферал)",
"REFERRAL_LIST_PREV_PAGE": "⬅️ Назад",
"REFERRAL_LIST_NEXT_PAGE": "Вперед ➡️",
"REFERRAL_ANALYTICS_TITLE": "📊 Аналитика рефералов",
diff --git a/app/services/user_service.py b/app/services/user_service.py
index dedd672a..e7a884d0 100644
--- a/app/services/user_service.py
+++ b/app/services/user_service.py
@@ -10,6 +10,7 @@ from app.database.crud.user import (
get_users_count, get_users_statistics, get_inactive_users,
add_user_balance, subtract_user_balance, update_user, delete_user
)
+from app.database.crud.campaign import get_campaign_registration_by_user
from app.database.crud.promo_group import get_promo_group_by_id
from app.database.crud.transaction import get_user_transactions_count
from app.database.crud.subscription import get_subscription_by_user_id
@@ -91,13 +92,15 @@ class UserService:
subscription = await get_subscription_by_user_id(db, user_id)
transactions_count = await get_user_transactions_count(db, user_id)
-
+ campaign_registration = await get_campaign_registration_by_user(db, user_id)
+
return {
"user": user,
"subscription": subscription,
"transactions_count": transactions_count,
"is_admin": settings.is_admin(user.telegram_id),
- "registration_days": (datetime.utcnow() - user.created_at).days
+ "registration_days": (datetime.utcnow() - user.created_at).days,
+ "campaign_registration": campaign_registration,
}
except Exception as e:
diff --git a/app/utils/user_utils.py b/app/utils/user_utils.py
index 8292f5e1..f663b866 100644
--- a/app/utils/user_utils.py
+++ b/app/utils/user_utils.py
@@ -8,6 +8,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.database.models import User, ReferralEarning, Transaction, TransactionType
+from app.database.crud.campaign import get_campaign_registrations_for_users
logger = logging.getLogger(__name__)
@@ -164,6 +165,9 @@ async def get_detailed_referral_list(db: AsyncSession, user_id: int, limit: int
.limit(limit)
)
referrals = referrals_result.scalars().all()
+
+ referral_ids = [referral.id for referral in referrals]
+ campaign_registrations = await get_campaign_registrations_for_users(db, referral_ids)
total_count_result = await db.execute(
select(func.count(User.id)).where(User.referred_by_id == user_id)
@@ -201,6 +205,11 @@ async def get_detailed_referral_list(db: AsyncSession, user_id: int, limit: int
if referral.last_activity:
days_since_activity = (datetime.utcnow() - referral.last_activity).days
+ registration = campaign_registrations.get(referral.id)
+ campaign_name = None
+ if registration and registration.campaign:
+ campaign_name = registration.campaign.name
+
detailed_referrals.append({
'id': referral.id,
'telegram_id': referral.telegram_id,
@@ -214,7 +223,9 @@ async def get_detailed_referral_list(db: AsyncSession, user_id: int, limit: int
'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'
+ 'status': 'active' if days_since_activity is not None and days_since_activity <= 30 else 'inactive',
+ 'campaign_name': campaign_name,
+ 'registration_source': 'campaign' if campaign_name else 'referral'
})
return {
diff --git a/locales/en.json b/locales/en.json
index e72fd5e4..0cd0cb46 100644
--- a/locales/en.json
+++ b/locales/en.json
@@ -414,6 +414,7 @@
"REFERRAL_LIST_ITEM_REGISTERED": " 📅 Registered: {days} days ago",
"REFERRAL_LIST_ITEM_ACTIVITY": " 🕐 Activity: {days} days ago",
"REFERRAL_LIST_ITEM_ACTIVITY_LONG_AGO": " 🕐 Activity: long ago",
+ "REFERRAL_LIST_ITEM_SOURCE_CAMPAIGN": " 📣 Source: advertising campaign “{name}” (not a direct referral)",
"REFERRAL_LIST_PREV_PAGE": "⬅️ Back",
"REFERRAL_LIST_NEXT_PAGE": "Next ➡️",
"REFERRAL_ANALYTICS_TITLE": "📊 Referral analytics",
diff --git a/locales/ru.json b/locales/ru.json
index 2c6c839d..f41d8cc3 100644
--- a/locales/ru.json
+++ b/locales/ru.json
@@ -414,6 +414,7 @@
"REFERRAL_LIST_ITEM_REGISTERED": " 📅 Регистрация: {days} дн. назад",
"REFERRAL_LIST_ITEM_ACTIVITY": " 🕐 Активность: {days} дн. назад",
"REFERRAL_LIST_ITEM_ACTIVITY_LONG_AGO": " 🕐 Активность: давно",
+ "REFERRAL_LIST_ITEM_SOURCE_CAMPAIGN": " 📣 Источник: рекламная кампания «{name}» (не прямой реферал)",
"REFERRAL_LIST_PREV_PAGE": "⬅️ Назад",
"REFERRAL_LIST_NEXT_PAGE": "Вперед ➡️",
"REFERRAL_ANALYTICS_TITLE": "📊 Аналитика рефералов",