diff --git a/backend/bot/app/web/admin_api_impl/common.py b/backend/bot/app/web/admin_api_impl/common.py index b43ac82..f469da7 100644 --- a/backend/bot/app/web/admin_api_impl/common.py +++ b/backend/bot/app/web/admin_api_impl/common.py @@ -94,6 +94,9 @@ def _serialize_subscription(sub: Subscription) -> Dict[str, Any]: regular_unlimited_override = bool(getattr(sub, "regular_unlimited_override", False)) premium_unlimited_override = bool(getattr(sub, "premium_unlimited_override", False)) premium_limit_bytes = _premium_limit_bytes_from_subscription(sub) + provider = sub.provider + is_trial = str(provider or "").strip().lower() == "trial" + display_label = "Trial" if is_trial else sub.tariff_key return { "subscription_id": int(sub.subscription_id), "panel_user_uuid": sub.panel_user_uuid, @@ -118,8 +121,10 @@ def _serialize_subscription(sub: Subscription) -> Dict[str, Any]: "premium_unlimited_override": premium_unlimited_override, "premium_is_limited": bool(sub.premium_is_limited), "tariff_key": sub.tariff_key, + "display_label": display_label, + "is_trial": is_trial, "auto_renew_enabled": bool(sub.auto_renew_enabled), - "provider": sub.provider, + "provider": provider, "is_throttled": bool(sub.is_throttled), } diff --git a/backend/bot/app/web/admin_api_impl/users.py b/backend/bot/app/web/admin_api_impl/users.py index 31587c4..ecb8e4d 100644 --- a/backend/bot/app/web/admin_api_impl/users.py +++ b/backend/bot/app/web/admin_api_impl/users.py @@ -745,6 +745,24 @@ def _user_search_condition(query: str): return or_(*conditions) +def _serialize_trial_summary(user: User, trial_subs: List[Subscription]) -> Dict[str, Any]: + first_trial_sub = trial_subs[0] if trial_subs else None + latest_trial_sub = trial_subs[-1] if trial_subs else None + first_start = getattr(first_trial_sub, "start_date", None) + latest_start = getattr(latest_trial_sub, "start_date", None) + latest_end = getattr(latest_trial_sub, "end_date", None) + reset_at = getattr(user, "trial_eligibility_reset_at", None) + return { + "used": bool(trial_subs), + "count": len(trial_subs), + "first_activated_at": first_start.isoformat() if first_start else None, + "latest_activated_at": latest_start.isoformat() if latest_start else None, + "latest_end_date": latest_end.isoformat() if latest_end else None, + "active": bool(latest_trial_sub and getattr(latest_trial_sub, "is_active", False)), + "last_reset_at": reset_at.isoformat() if reset_at else None, + } + + async def admin_user_detail_route(request: web.Request) -> web.Response: _require_admin_user_id(request) target_id = int(request.match_info["user_id"]) @@ -764,6 +782,15 @@ async def admin_user_detail_route(request: web.Request) -> web.Response: .limit(20) ) latest_subs = (await session.execute(latest_subs_stmt)).scalars().all() + trial_subs_stmt = ( + select(Subscription) + .where( + Subscription.user_id == target_id, + sa_func.lower(sa_func.coalesce(Subscription.provider, "")) == "trial", + ) + .order_by(Subscription.start_date.asc().nullslast(), Subscription.end_date.asc()) + ) + trial_subs = (await session.execute(trial_subs_stmt)).scalars().all() total_paid = await payment_dal.get_user_total_paid(session, target_id) recent_payments_stmt = ( select(Payment) @@ -830,12 +857,14 @@ async def admin_user_detail_route(request: web.Request) -> web.Response: serialized_inviter = ( _serialize_admin_user_with_avatar(inviter, avatar_keys) if inviter is not None else None ) + trial_payload = _serialize_trial_summary(user, trial_subs) return _ok( { "user": serialized_user, "active_subscription": _serialize_subscription(active_sub) if active_sub else None, "subscriptions": [_serialize_subscription(s) for s in (latest_subs or [])], + "trial": trial_payload, "total_paid": float(total_paid), "recent_payments": [_serialize_payment(p) for p in recent_payments], "log_count": int(log_count or 0), diff --git a/backend/bot/app/web/webapp/billing.py b/backend/bot/app/web/webapp/billing.py index 56189bc..6a3cf16 100644 --- a/backend/bot/app/web/webapp/billing.py +++ b/backend/bot/app/web/webapp/billing.py @@ -2,6 +2,7 @@ from ._runtime import * # noqa: F403,F405 from bot.app.web.webapp.cache_helpers import invalidate_webapp_user_caches +from db.dal import message_log_dal def _billing_iso_datetime(value: Optional[Any]) -> Optional[str]: @@ -395,6 +396,28 @@ async def activate_trial_route(request: web.Request) -> web.Response: except Exception: logger.exception("Failed to send WebApp trial activation notification") + try: + await message_log_dal.create_message_log_no_commit( + session, + { + "user_id": user_id, + "telegram_username": getattr(db_user, "username", None), + "telegram_first_name": getattr(db_user, "first_name", None), + "event_type": "webapp_trial_activate", + "content": ( + f"Trial activated via WebApp for user_id={user_id}; " + f"email={getattr(db_user, 'email', None) or 'N/A'}" + ), + "is_admin_event": False, + "target_user_id": user_id, + "timestamp": datetime.now(timezone.utc), + }, + ) + except Exception: + logger.exception("Failed to add WebApp trial activation audit log") + + await session.commit() + try: from db.dal import ad_dal as _ad_dal diff --git a/frontend/src/admin/sections/UserDetailModal.svelte b/frontend/src/admin/sections/UserDetailModal.svelte index f0ecc1d..35a36f2 100644 --- a/frontend/src/admin/sections/UserDetailModal.svelte +++ b/frontend/src/admin/sections/UserDetailModal.svelte @@ -53,6 +53,26 @@ return String(val ?? "—"); } + function isTrialSubscription(sub) { + return Boolean(sub?.is_trial || String(sub?.provider || "").toLowerCase() === "trial"); + } + + function subscriptionDisplayLabel(sub) { + if (!sub) return "—"; + if (isTrialSubscription(sub)) return at("user_subscription_trial", {}, "Триал"); + if (sub.display_label) return sub.display_label; + return sub.tariff_name || sub.tariff_key || at("user_history_no_tariff", {}, "Без тарифа"); + } + + function trialSummaryText(trial) { + if (!trial?.used) return at("user_trial_not_used", {}, "Не брал"); + const date = trial.latest_activated_at || trial.first_activated_at; + const base = date + ? at("user_trial_used_at", { date: fmtDate(date) }, `Брал ${fmtDate(date)}`) + : at("user_trial_used", {}, "Брал"); + return trial.active ? `${base} · ${at("user_trial_active", {}, "активен")}` : base; + } + const usersStore = getContext("usersStore"); $: ({ @@ -404,7 +424,7 @@
  • {at("user_label_tariff", {}, "Тариф")}{openedUserDetail.active_subscription.tariff_key || "—"}{subscriptionDisplayLabel(openedUserDetail.active_subscription)}
  • @@ -507,6 +527,37 @@

    {/if} + {#if openedUserDetail?.trial} + + {/if} + {#if (openedUserDetail.subscriptions || []).length}
    @@ -521,8 +572,7 @@
    {sub.tariff_key || - at("user_history_no_tariff", {}, "Без тарифа")}{subscriptionDisplayLabel(sub)} {at( diff --git a/tests/test_admin_user_reset_trial.py b/tests/test_admin_user_reset_trial.py index 8954bc2..c24a7e2 100644 --- a/tests/test_admin_user_reset_trial.py +++ b/tests/test_admin_user_reset_trial.py @@ -1,8 +1,10 @@ import json import unittest +from datetime import datetime, timezone from types import SimpleNamespace from unittest.mock import AsyncMock, patch +from bot.app.web.admin_api_impl import common as admin_common from bot.app.web.admin_api_impl import users as admin_users @@ -73,5 +75,68 @@ class AdminUserResetTrialRouteTests(unittest.IsolatedAsyncioTestCase): self.assertFalse(session.rolled_back) +class AdminUserTrialPresentationTests(unittest.TestCase): + def test_trial_subscription_serializes_display_label(self): + start_at = datetime(2026, 1, 2, 3, 4, tzinfo=timezone.utc) + end_at = datetime(2026, 1, 9, 3, 4, tzinfo=timezone.utc) + sub = SimpleNamespace( + subscription_id=7, + panel_user_uuid="panel-user", + panel_subscription_uuid=None, + start_date=start_at, + end_date=end_at, + duration_months=None, + is_active=False, + status_from_panel="EXPIRED", + traffic_limit_bytes=10, + traffic_used_bytes=2, + tier_baseline_bytes=0, + topup_balance_bytes=0, + premium_used_bytes=0, + premium_baseline_bytes=0, + premium_topup_balance_bytes=0, + premium_topup_used_bytes=0, + premium_bonus_bytes=0, + regular_bonus_bytes=0, + regular_unlimited_override=False, + premium_unlimited_override=False, + premium_is_limited=False, + tariff_key=None, + auto_renew_enabled=False, + provider="trial", + is_throttled=False, + ) + + payload = admin_common._serialize_subscription(sub) + + self.assertTrue(payload["is_trial"]) + self.assertEqual(payload["display_label"], "Trial") + self.assertIsNone(payload["tariff_key"]) + + def test_trial_summary_includes_usage_dates_and_reset_marker(self): + first_at = datetime(2026, 1, 2, 3, 4, tzinfo=timezone.utc) + latest_at = datetime(2026, 2, 3, 4, 5, tzinfo=timezone.utc) + latest_end = datetime(2026, 2, 10, 4, 5, tzinfo=timezone.utc) + reset_at = datetime(2026, 3, 1, tzinfo=timezone.utc) + user = SimpleNamespace(trial_eligibility_reset_at=reset_at) + trial_subs = [ + SimpleNamespace( + start_date=first_at, + end_date=datetime(2026, 1, 9, tzinfo=timezone.utc), + ), + SimpleNamespace(start_date=latest_at, end_date=latest_end, is_active=True), + ] + + payload = admin_users._serialize_trial_summary(user, trial_subs) + + self.assertTrue(payload["used"]) + self.assertTrue(payload["active"]) + self.assertEqual(payload["count"], 2) + self.assertEqual(payload["first_activated_at"], first_at.isoformat()) + self.assertEqual(payload["latest_activated_at"], latest_at.isoformat()) + self.assertEqual(payload["latest_end_date"], latest_end.isoformat()) + self.assertEqual(payload["last_reset_at"], reset_at.isoformat()) + + if __name__ == "__main__": unittest.main() diff --git a/tests/test_webapp_trial_activation.py b/tests/test_webapp_trial_activation.py new file mode 100644 index 0000000..f073a5c --- /dev/null +++ b/tests/test_webapp_trial_activation.py @@ -0,0 +1,117 @@ +import json +from datetime import datetime, timezone +from types import SimpleNamespace +from unittest import IsolatedAsyncioTestCase +from unittest.mock import AsyncMock, patch + +import bot.app.web.subscription_webapp # noqa: F401 +from bot.app.web.webapp import billing as billing_module + + +class _Session: + def __init__(self): + self.commit_count = 0 + self.rollback_count = 0 + + async def __aenter__(self): + return self + + async def __aexit__(self, exc_type, exc, tb): + return False + + async def commit(self): + self.commit_count += 1 + + async def rollback(self): + self.rollback_count += 1 + + +class _SessionFactory: + def __init__(self, session): + self.session = session + + def __call__(self): + return self.session + + +class WebAppTrialActivationTests(IsolatedAsyncioTestCase): + async def test_email_only_trial_activation_is_written_to_admin_logs(self): + session = _Session() + end_date = datetime(2026, 1, 9, 3, 4, tzinfo=timezone.utc) + settings = SimpleNamespace( + TRIAL_ENABLED=True, + TRIAL_DURATION_DAYS=7, + TRIAL_TRAFFIC_LIMIT_GB=10, + LOG_TRIAL_ACTIVATIONS=False, + ) + db_user = SimpleNamespace( + user_id=42, + is_banned=False, + username=None, + first_name=None, + email="email-only@example.com", + ) + subscription_service = SimpleNamespace( + activate_trial_subscription=AsyncMock( + return_value={ + "activated": True, + "days": 7, + "end_date": end_date, + "traffic_gb": 10, + "subscription_url": "https://panel.example/sub", + } + ) + ) + request = SimpleNamespace( + app={ + "settings": settings, + "async_session_factory": _SessionFactory(session), + "subscription_service": subscription_service, + } + ) + + with ( + patch.object(billing_module, "_require_user_id", return_value=42), + patch.object( + billing_module, + "_enforce_webapp_rate_limit", + AsyncMock(return_value=None), + ), + patch.object( + billing_module.user_dal, + "get_user_by_id", + AsyncMock(return_value=db_user), + ), + patch.object( + billing_module, + "prepare_config_links", + AsyncMock(return_value=("https://panel.example/sub", "https://connect.example")), + ), + patch.object( + billing_module.message_log_dal, + "create_message_log_no_commit", + AsyncMock(), + ) as create_log, + patch.object( + billing_module, + "invalidate_webapp_user_caches", + AsyncMock(), + ), + patch("db.dal.ad_dal.mark_trial_activated", AsyncMock()) as mark_trial_activated, + ): + response = await billing_module.activate_trial_route(request) + + payload = json.loads(response.text) + self.assertEqual(response.status, 200) + self.assertTrue(payload["activated"]) + subscription_service.activate_trial_subscription.assert_awaited_once_with(session, 42) + create_log.assert_awaited_once() + log_payload = create_log.await_args.args[1] + self.assertEqual(log_payload["user_id"], 42) + self.assertEqual(log_payload["target_user_id"], 42) + self.assertEqual(log_payload["event_type"], "webapp_trial_activate") + self.assertFalse(log_payload["is_admin_event"]) + self.assertIn("email-only@example.com", log_payload["content"]) + mark_trial_activated.assert_awaited_once_with(session, 42) + self.assertEqual(session.commit_count, 2) + self.assertEqual(session.rollback_count, 0)