From 93353db51151c05b29f4ea3ce7b3015670275ce4 Mon Sep 17 00:00:00 2001
From: 3252a8 <3252a8@proton.me>
Date: Sun, 31 May 2026 00:08:31 +0300
Subject: [PATCH] feat: split subscription stats by access type
---
backend/bot/handlers/admin/statistics.py | 8 +-
backend/bot/handlers/inline_mode.py | 4 +-
backend/db/dal/user_dal.py | 81 +++++++++++++++----
.../src/admin/sections/StatsSection.svelte | 78 +++++++++++++++++-
frontend/src/lib/webapp/mockApi.js | 12 ++-
locales/en.json | 22 +++--
locales/ru.json | 24 ++++--
tests/test_admin_panel_stats_cache.py | 4 +-
tests/test_user_dal.py | 43 ++++++++++
9 files changed, 243 insertions(+), 33 deletions(-)
diff --git a/backend/bot/handlers/admin/statistics.py b/backend/bot/handlers/admin/statistics.py
index 888970f..5e34c8c 100644
--- a/backend/bot/handlers/admin/statistics.py
+++ b/backend/bot/handlers/admin/statistics.py
@@ -71,11 +71,17 @@ async def show_statistics_handler(
f"📊 {_('admin_user_stats_total_label')}: {user_stats['total_users']}"
)
# Removed: Active today moved to panel stats
+ stats_text_parts.append(
+ f"📡 {_('admin_user_stats_active_subscription_label')}: {user_stats['active_subscriptions']}" # noqa: E501
+ )
stats_text_parts.append(
f"💳 {_('admin_user_stats_paid_subs_label')}: {user_stats['paid_subscriptions']}"
)
stats_text_parts.append(
- f"🆓 {_('admin_user_stats_trial_label')}: {user_stats['trial_users']}"
+ f"🧪 {_('admin_user_stats_trial_label')}: {user_stats['trial_users']}"
+ )
+ stats_text_parts.append(
+ f"🎁 {_('admin_user_stats_free_subscription_label')}: {user_stats['free_subscription_users']}" # noqa: E501
)
stats_text_parts.append(
f"😴 {_('admin_user_stats_inactive_label')}: {user_stats['inactive_users']}"
diff --git a/backend/bot/handlers/inline_mode.py b/backend/bot/handlers/inline_mode.py
index c0e5f7e..63421b7 100644
--- a/backend/bot/handlers/inline_mode.py
+++ b/backend/bot/handlers/inline_mode.py
@@ -166,8 +166,10 @@ async def create_user_stats_result(
"inline_user_stats_message",
total=user_stats["total_users"],
active_today=user_stats["active_today"],
+ active=user_stats["active_subscriptions"],
paid=user_stats["paid_subscriptions"],
trial=user_stats["trial_users"],
+ free=user_stats["free_subscription_users"],
inactive=user_stats["inactive_users"],
banned=user_stats["banned_users"],
referral=user_stats["referral_users"],
@@ -179,7 +181,7 @@ async def create_user_stats_result(
description=_(
"inline_user_stats_description",
total=user_stats["total_users"],
- active=user_stats["paid_subscriptions"],
+ active=user_stats["active_subscriptions"],
),
input_message_content=InputTextMessageContent(
message_text=stats_text, parse_mode="HTML"
diff --git a/backend/db/dal/user_dal.py b/backend/db/dal/user_dal.py
index 3bad5d9..8b506e4 100644
--- a/backend/db/dal/user_dal.py
+++ b/backend/db/dal/user_dal.py
@@ -666,17 +666,33 @@ async def get_enhanced_user_statistics(session: AsyncSession) -> Dict[str, Any]:
active_today = int(user_counts[2] or 0)
referral_users = int(user_counts[3] or 0)
- subscription_counts_stmt = (
+ provider_value = func.lower(func.coalesce(Subscription.provider, ""))
+ panel_status_value = func.upper(func.coalesce(Subscription.status_from_panel, ""))
+ trial_subscription_condition = or_(
+ provider_value == "trial",
+ panel_status_value == "TRIAL",
+ )
+ paid_subscription_condition = and_(
+ provider_value != "",
+ provider_value != "trial",
+ panel_status_value != "TRIAL",
+ )
+ free_subscription_condition = and_(
+ provider_value == "",
+ panel_status_value != "TRIAL",
+ )
+
+ active_subscription_flags_sq = (
select(
- func.count(
- func.distinct(
- case((Subscription.provider.is_not(None), Subscription.user_id), else_=None)
- )
+ Subscription.user_id.label("user_id"),
+ func.max(case((paid_subscription_condition, 1), else_=0)).label(
+ "has_paid_subscription"
),
- func.count(
- func.distinct(
- case((Subscription.provider.is_(None), Subscription.user_id), else_=None)
- )
+ func.max(case((trial_subscription_condition, 1), else_=0)).label(
+ "has_trial_subscription"
+ ),
+ func.max(case((free_subscription_condition, 1), else_=0)).label(
+ "has_free_subscription"
),
)
.join(User, Subscription.user_id == User.user_id)
@@ -686,27 +702,64 @@ async def get_enhanced_user_statistics(session: AsyncSession) -> Dict[str, Any]:
Subscription.end_date > now,
)
)
+ .group_by(Subscription.user_id)
+ .subquery()
+ )
+
+ subscription_counts_stmt = select(
+ func.count(active_subscription_flags_sq.c.user_id),
+ func.coalesce(func.sum(active_subscription_flags_sq.c.has_paid_subscription), 0),
+ func.coalesce(
+ func.sum(
+ case(
+ (
+ active_subscription_flags_sq.c.has_paid_subscription == 0,
+ active_subscription_flags_sq.c.has_trial_subscription,
+ ),
+ else_=0,
+ )
+ ),
+ 0,
+ ),
+ func.coalesce(
+ func.sum(
+ case(
+ (
+ and_(
+ active_subscription_flags_sq.c.has_paid_subscription == 0,
+ active_subscription_flags_sq.c.has_trial_subscription == 0,
+ ),
+ active_subscription_flags_sq.c.has_free_subscription,
+ ),
+ else_=0,
+ )
+ ),
+ 0,
+ ),
)
subscription_counts = (await session.execute(subscription_counts_stmt)).one()
- paid_subs_users = int(subscription_counts[0] or 0)
- trial_users = int(subscription_counts[1] or 0)
+ active_subscription_users = int(subscription_counts[0] or 0)
+ paid_subs_users = int(subscription_counts[1] or 0)
+ trial_users = int(subscription_counts[2] or 0)
+ free_subscription_users = int(subscription_counts[3] or 0)
- # Inactive users (no active subscription)
- inactive_users = total_users - paid_subs_users - trial_users - banned_users
+ inactive_users = total_users - active_subscription_users
return {
"total_users": total_users,
"banned_users": banned_users,
"active_today": active_today,
+ "active_subscriptions": active_subscription_users,
"paid_subscriptions": paid_subs_users,
"trial_users": trial_users,
+ "free_subscription_users": free_subscription_users,
"inactive_users": max(0, inactive_users),
"referral_users": referral_users,
}
async def get_user_ids_with_active_subscription(session: AsyncSession) -> List[int]:
- """Return non-banned user IDs who have an active subscription (paid or trial)."""
+ """Return non-banned user IDs who have any active subscription."""
from datetime import datetime, timezone
now = datetime.now(timezone.utc)
diff --git a/frontend/src/admin/sections/StatsSection.svelte b/frontend/src/admin/sections/StatsSection.svelte
index 67aa380..671d334 100644
--- a/frontend/src/admin/sections/StatsSection.svelte
+++ b/frontend/src/admin/sections/StatsSection.svelte
@@ -492,7 +492,7 @@
- {#each Array(3) as _, i (i)}
+ {#each Array(6) as _, i (i)}
@@ -690,22 +690,96 @@
+
+
+ {at("stats_label_active_subs", {}, "")}
+ {users.active_subscriptions ?? 0}
+
+ {users.total_users
+ ? Math.round(((users.active_subscriptions ?? 0) / (users.total_users || 1)) * 100)
+ : 0}%
+
+
+
+
+
{at("stats_label_paid_subs", {}, "")}
{users.paid_subscriptions ?? 0}
- {users.trial_users ?? 0}
+ {users.active_subscriptions
+ ? Math.round(
+ ((users.paid_subscriptions ?? 0) / (users.active_subscriptions || 1)) * 100
+ )
+ : 0}%
+
+
+
+
+ {at("stats_label_trial_users", {}, "")}
+ {users.trial_users ?? 0}
+
+ {users.active_subscriptions
+ ? Math.round(((users.trial_users ?? 0) / (users.active_subscriptions || 1)) * 100)
+ : 0}%
+
+
+
+
+
+ {at("stats_label_free_users", {}, "")}
+ {users.free_subscription_users ?? 0}
+
+ {users.active_subscriptions
+ ? Math.round(
+ ((users.free_subscription_users ?? 0) / (users.active_subscriptions || 1)) * 100
+ )
+ : 0}%
+
+
+
+
+
{at("stats_label_inactive", {}, "")}
diff --git a/frontend/src/lib/webapp/mockApi.js b/frontend/src/lib/webapp/mockApi.js
index 7a9a95c..670e33f 100644
--- a/frontend/src/lib/webapp/mockApi.js
+++ b/frontend/src/lib/webapp/mockApi.js
@@ -1231,7 +1231,17 @@ export async function mockApi(path, options = {}, context = {}) {
return {
ok: true,
currency_symbol: "RUB",
- users: { total_users: 248, active_subscriptions: 172, banned_users: 3 },
+ users: {
+ total_users: 248,
+ active_today: 9,
+ active_subscriptions: 172,
+ paid_subscriptions: 141,
+ trial_users: 8,
+ free_subscription_users: 23,
+ inactive_users: 76,
+ banned_users: 3,
+ referral_users: 34,
+ },
financial: {
today_revenue: 1240,
week_revenue: 15800,
diff --git a/locales/en.json b/locales/en.json
index 0f6f364..c4c340f 100644
--- a/locales/en.json
+++ b/locales/en.json
@@ -347,8 +347,8 @@
"admin_user_no_logs": "📜 User has no actions",
"admin_user_logs_error": "❌ Error loading user actions",
"admin_direct_message_signature": "\n\n---\n💬 Message from administrator",
- "inline_user_stats_message": "👥 User Statistics\n\n📊 Total: {total}\n📈 Active today: {active_today}\n💳 With paid subscription: {paid}\n🆓 On trial period: {trial}\n😴 Inactive: {inactive}\n🚫 Banned: {banned}\n🎁 Via referral program: {referral}",
- "inline_user_stats_description": "Total: {total}, Paid: {active}",
+ "inline_user_stats_message": "👥 User Statistics\n\n📊 Total: {total}\n📈 Active today: {active_today}\n📡 With active subscription: {active}\n💳 With paid subscription: {paid}\n🧪 With trial subscription: {trial}\n🎁 With free subscription: {free}\n😴 Without active subscription: {inactive}\n🚫 Banned: {banned}\n🎁 Via referral program: {referral}",
+ "inline_user_stats_description": "Total: {total}, subscribed: {active}",
"inline_admin_user_stats_title": "👥 User Statistics",
"inline_financial_stats_message": "💰 Financial Statistics\n\n📅 Today: {today:.2f} RUB\n ({today_count} payments)\n📅 Week: {week:.2f} RUB\n📅 Month: {month:.2f} RUB\n🏆 All time: {all_time:.2f} RUB",
"inline_admin_financial_stats_title": "💰 Financial Statistics",
@@ -488,9 +488,11 @@
"inline_referral_description": "Share referral link to get bonuses",
"inline_financial_description": "Today: {today} RUB",
"inline_system_description": "🟢 Online: {online}, 📊 Active: {active}",
+ "admin_user_stats_active_subscription_label": "With active subscription",
"admin_user_stats_paid_subs_label": "With paid subscription",
- "admin_user_stats_trial_label": "On trial period",
- "admin_user_stats_inactive_label": "Inactive",
+ "admin_user_stats_trial_label": "With trial subscription",
+ "admin_user_stats_free_subscription_label": "With free subscription",
+ "admin_user_stats_inactive_label": "Without active subscription",
"admin_user_stats_banned_label": "Banned",
"admin_user_stats_referral_label": "Attracted via referral program",
"admin_financial_today_label": "Today",
@@ -1098,8 +1100,13 @@
"admin_stats_error": "Failed to load statistics: {error}",
"admin_stats_label_users": "Users",
"admin_stats_trend_banned": "Banned: {count}",
- "admin_stats_label_paid_subs": "Paid Subscriptions",
+ "admin_stats_label_active_subs": "With active subscription",
+ "admin_stats_label_paid_subs": "Paid users",
+ "admin_stats_label_trial_users": "With trial subscription",
+ "admin_stats_label_free_users": "With free subscription",
+ "admin_stats_trend_paid": "Paid: {count}",
"admin_stats_trend_trials": "Trials: {count}",
+ "admin_stats_trend_free": "Free: {count}",
"admin_stats_label_today_rev": "Today's Revenue",
"admin_stats_trend_payments": "{count} payments",
"admin_stats_label_week": "This Week",
@@ -1145,7 +1152,10 @@
"admin_stats_revenue_avg_check": "Average ticket today: {value}",
"admin_stats_revenue_avg_none": "No successful payments today",
"admin_stats_revenue_avg_ticket_label": "Avg. ticket (today)",
- "admin_stats_card_paid_caption": "Trials shown in the badge",
+ "admin_stats_card_active_subs_caption": "Paid, trial, and free access combined",
+ "admin_stats_card_paid_caption": "Paid users separated from trial and free access",
+ "admin_stats_card_trial_caption": "Only real trial subscriptions",
+ "admin_stats_card_free_caption": "Manual extensions and bonus access",
"admin_stats_card_inactive_caption": "Share of all users is in the badge",
"admin_stats_revenue_last_7_calendar": "Last 7 calendar days total",
"admin_stats_revenue_prev_7_calendar": "Previous 7 days",
diff --git a/locales/ru.json b/locales/ru.json
index bb7164f..4270a39 100644
--- a/locales/ru.json
+++ b/locales/ru.json
@@ -347,8 +347,8 @@
"admin_user_no_logs": "📜 У пользователя нет действий",
"admin_user_logs_error": "❌ Ошибка загрузки действий пользователя",
"admin_direct_message_signature": "\n\n---\n💬 Сообщение от администратора",
- "inline_user_stats_message": "📊 Статистика Бота\n👥 Пользователи\n\n📊 Всего: {total}\n📈 Активных сегодня: {active_today}\n💳 С платной подпиской: {paid}\n🆓 На пробном периоде: {trial}\n😴 Неактивных: {inactive}\n🚫 Заблокированных: {banned}\n🎁 Привлечено по реферальной программе: {referral}",
- "inline_user_stats_description": "Всего: {total}, Платных: {active}",
+ "inline_user_stats_message": "📊 Статистика Бота\n👥 Пользователи\n\n📊 Всего: {total}\n📈 Активных сегодня: {active_today}\n📡 С активной подпиской: {active}\n💳 С платной подпиской: {paid}\n🧪 С пробной подпиской: {trial}\n🎁 С бесплатной подпиской: {free}\n😴 Без активной подписки: {inactive}\n🚫 Заблокированных: {banned}\n🎁 Привлечено по реферальной программе: {referral}",
+ "inline_user_stats_description": "Всего: {total}, с подпиской: {active}",
"inline_admin_user_stats_title": "👥 Статистика пользователей",
"inline_financial_stats_message": "💰 Финансовая статистика\n\n📅 За сегодня: {today:.2f} RUB\n ({today_count} платежей)\n📅 За неделю: {week:.2f} RUB\n📅 За месяц: {month:.2f} RUB\n🏆 За все время: {all_time:.2f} RUB",
"inline_admin_financial_stats_title": "💰 Финансовая статистика",
@@ -488,9 +488,11 @@
"inline_referral_description": "Поделиться реферальной ссылкой для получения бонусов",
"inline_financial_description": "Сегодня: {today} RUB",
"inline_system_description": "🟢 Онлайн: {online}, 📊 Активных: {active}",
+ "admin_user_stats_active_subscription_label": "С активной подпиской",
"admin_user_stats_paid_subs_label": "С платной подпиской",
- "admin_user_stats_trial_label": "На пробном периоде",
- "admin_user_stats_inactive_label": "Неактивных",
+ "admin_user_stats_trial_label": "С пробной подпиской",
+ "admin_user_stats_free_subscription_label": "С бесплатной подпиской",
+ "admin_user_stats_inactive_label": "Без активной подписки",
"admin_user_stats_banned_label": "Заблокированных",
"admin_user_stats_referral_label": "Привлечено по реферальной программе",
"admin_financial_today_label": "За сегодня",
@@ -1098,8 +1100,13 @@
"admin_stats_error": "Не удалось загрузить статистику: {error}",
"admin_stats_label_users": "Пользователи",
"admin_stats_trend_banned": "В бане: {count}",
- "admin_stats_label_paid_subs": "Платные подписки",
- "admin_stats_trend_trials": "Триалы: {count}",
+ "admin_stats_label_active_subs": "С активной подпиской",
+ "admin_stats_label_paid_subs": "Платные пользователи",
+ "admin_stats_label_trial_users": "С пробной подпиской",
+ "admin_stats_label_free_users": "С бесплатной подпиской",
+ "admin_stats_trend_paid": "Платные: {count}",
+ "admin_stats_trend_trials": "Пробные: {count}",
+ "admin_stats_trend_free": "Бесплатные: {count}",
"admin_stats_label_today_rev": "Доход за день",
"admin_stats_trend_payments": "{count} платежей",
"admin_stats_label_week": "За неделю",
@@ -1145,7 +1152,10 @@
"admin_stats_revenue_avg_check": "Средний чек сегодня: {value}",
"admin_stats_revenue_avg_none": "Сегодня без успешных платежей",
"admin_stats_revenue_avg_ticket_label": "Средний чек (сегодня)",
- "admin_stats_card_paid_caption": "Триалы — отдельно в бейдже",
+ "admin_stats_card_active_subs_caption": "Платные, пробные и бесплатные вместе",
+ "admin_stats_card_paid_caption": "Платные отдельно от пробных и бесплатных",
+ "admin_stats_card_trial_caption": "Только реальные пробные подписки",
+ "admin_stats_card_free_caption": "Ручные начисления и бонусный доступ",
"admin_stats_card_inactive_caption": "Доля от всех пользователей — в бейдже",
"admin_stats_revenue_last_7_calendar": "Сумма за последние 7 дн. (календарь)",
"admin_stats_revenue_prev_7_calendar": "Предыдущие 7 дн.",
diff --git a/tests/test_admin_panel_stats_cache.py b/tests/test_admin_panel_stats_cache.py
index e37abb7..74ed0ef 100644
--- a/tests/test_admin_panel_stats_cache.py
+++ b/tests/test_admin_panel_stats_cache.py
@@ -96,9 +96,11 @@ class AdminDbStatsCacheTests(unittest.IsolatedAsyncioTestCase):
"total_users": 10,
"banned_users": 1,
"active_today": 2,
+ "active_subscriptions": 8,
"paid_subscriptions": 7,
"trial_users": 1,
- "inactive_users": 1,
+ "free_subscription_users": 0,
+ "inactive_users": 2,
"referral_users": 3,
}
)
diff --git a/tests/test_user_dal.py b/tests/test_user_dal.py
index 05cb13e..a0358ec 100644
--- a/tests/test_user_dal.py
+++ b/tests/test_user_dal.py
@@ -27,6 +27,49 @@ class FakeResult:
return self._scalar_value
return [self._scalar_value]
+ def one(self):
+ return self._scalar_value
+
+
+class UserDalStatisticsTests(unittest.IsolatedAsyncioTestCase):
+ async def test_get_enhanced_user_statistics_splits_paid_trial_and_free_users(self):
+ session = SimpleNamespace(
+ execute=AsyncMock(
+ side_effect=[
+ FakeResult((10, 1, 2, 3)),
+ FakeResult((8, 4, 2, 2)),
+ ]
+ )
+ )
+
+ stats = await user_dal.get_enhanced_user_statistics(session)
+
+ self.assertEqual(
+ stats,
+ {
+ "total_users": 10,
+ "banned_users": 1,
+ "active_today": 2,
+ "active_subscriptions": 8,
+ "paid_subscriptions": 4,
+ "trial_users": 2,
+ "free_subscription_users": 2,
+ "inactive_users": 2,
+ "referral_users": 3,
+ },
+ )
+
+ stmt = session.execute.await_args_list[1].args[0]
+ sql = str(
+ stmt.compile(
+ dialect=postgresql.dialect(),
+ compile_kwargs={"literal_binds": True},
+ )
+ ).upper()
+ self.assertIn("GROUP BY SUBSCRIPTIONS.USER_ID", sql)
+ self.assertIn("SUBSCRIPTIONS.PROVIDER", sql)
+ self.assertIn("TRIAL", sql)
+
class UserDalMergeTests(unittest.IsolatedAsyncioTestCase):
async def test_get_panel_user_uuids_for_user_includes_subscription_fallbacks_once(self):