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 @@