From 475a1953d1750da9e39c5d8b1ff605633075d2c7 Mon Sep 17 00:00:00 2001 From: 3252a8 <3252a8@proton.me> Date: Tue, 12 May 2026 15:42:17 +0300 Subject: [PATCH] feat: tune visual of web app admin panel, tune limit messages and etc --- bot/app/web/admin_api.py | 283 ++++++++++- bot/app/web/frontend/src/App.svelte | 39 ++ .../web/frontend/src/admin/AdminPanel.svelte | 15 +- .../src/admin/sections/PaymentsSection.svelte | 135 +++++- .../src/admin/sections/UserDetailModal.svelte | 262 +++++++++- .../src/lib/admin/stores/usersStore.js | 117 ++++- .../frontend/src/styles/admin-controls.css | 11 + .../src/webapp/screens/HomeScreen.svelte | 29 +- bot/app/web/subscription_webapp.py | 11 +- bot/handlers/admin/user_management.py | 452 +++++++++++++++++- bot/handlers/user/payment.py | 21 +- .../user/subscription/payments_freekassa.py | 6 +- .../user/subscription/payments_platega.py | 6 +- .../user/subscription/payments_severpay.py | 6 +- .../user/subscription/payments_yookassa.py | 6 +- bot/services/crypto_pay_service.py | 10 +- bot/services/email_templates.py | 2 +- bot/services/freekassa_service.py | 7 +- bot/services/notification_service.py | 38 +- bot/services/platega_service.py | 6 +- bot/services/severpay_service.py | 6 +- bot/services/stars_service.py | 6 +- bot/services/subscription_service.py | 375 ++++++++++++++- bot/services/tariff_worker.py | 246 ++++++++-- bot/states/admin_states.py | 2 + bot/utils/mini_app_url.py | 32 ++ db/migrator.py | 101 ++++ db/models.py | 4 + locales/en.json | 94 +++- locales/ru.json | 94 +++- scripts/send_premium_traffic_warning_test.py | 116 +++++ tests/test_admin_traffic_grants.py | 200 ++++++++ tests/test_mini_app_url.py | 35 ++ tests/test_tariff_worker.py | 118 +++++ 34 files changed, 2777 insertions(+), 114 deletions(-) create mode 100644 bot/utils/mini_app_url.py create mode 100644 scripts/send_premium_traffic_warning_test.py create mode 100644 tests/test_admin_traffic_grants.py create mode 100644 tests/test_mini_app_url.py diff --git a/bot/app/web/admin_api.py b/bot/app/web/admin_api.py index 8948c02..c1ad499 100644 --- a/bot/app/web/admin_api.py +++ b/bot/app/web/admin_api.py @@ -14,7 +14,7 @@ import json import logging from datetime import datetime, timedelta, timezone from pathlib import Path -from typing import Any, Dict, List, Optional +from typing import Any, Dict, List, Optional, Tuple from aiogram import Bot from aiohttp import web @@ -163,10 +163,15 @@ def _serialize_user(user: User) -> Dict[str, Any]: def _serialize_subscription(sub: Subscription) -> Dict[str, Any]: + premium_bonus_bytes = int(getattr(sub, "premium_bonus_bytes", 0) or 0) + regular_bonus_bytes = int(getattr(sub, "regular_bonus_bytes", 0) or 0) + regular_unlimited_override = bool(getattr(sub, "regular_unlimited_override", False)) + premium_unlimited_override = bool(getattr(sub, "premium_unlimited_override", False)) premium_limit_bytes = ( int(sub.premium_baseline_bytes or 0) + int(sub.premium_topup_balance_bytes or 0) + int(getattr(sub, "premium_topup_used_bytes", 0) or 0) + + premium_bonus_bytes ) return { "subscription_id": int(sub.subscription_id), @@ -186,6 +191,10 @@ def _serialize_subscription(sub: Subscription) -> Dict[str, Any]: "premium_baseline_bytes": sub.premium_baseline_bytes, "premium_topup_balance_bytes": sub.premium_topup_balance_bytes, "premium_topup_used_bytes": getattr(sub, "premium_topup_used_bytes", 0), + "premium_bonus_bytes": premium_bonus_bytes, + "regular_bonus_bytes": regular_bonus_bytes, + "regular_unlimited_override": regular_unlimited_override, + "premium_unlimited_override": premium_unlimited_override, "premium_is_limited": bool(sub.premium_is_limited), "tariff_key": sub.tariff_key, "auto_renew_enabled": bool(sub.auto_renew_enabled), @@ -194,17 +203,47 @@ def _serialize_subscription(sub: Subscription) -> Dict[str, Any]: } +def _payment_traffic_gb_split(payment: Payment) -> Tuple[Optional[float], Optional[float]]: + """For traffic purchases: ``(regular_gb, premium_gb)``. Other payments → (None, None).""" + if payment.purchased_gb is None: + return None, None + try: + gb = float(payment.purchased_gb) + except (TypeError, ValueError): + return None, None + sm = (payment.sale_mode or "").strip() + if not sm: + return None, None + base = sm.split("@", 1)[0].split("|", 1)[0].lower() + if base == "premium_topup": + return None, gb + if base in {"traffic", "traffic_package", "topup"}: + return gb, None + return None, None + + def _serialize_payment(payment: Payment) -> Dict[str, Any]: # Avoid lazy-loading `payment.user` outside an active SQLAlchemy session. # Some admin routes serialize payments after the session scope is closed. user_label = str(payment.user_id) + telegram_id = None loaded_user = payment.__dict__.get("user") if loaded_user is not None: user_label = loaded_user.username or loaded_user.first_name or str(payment.user_id) + tid = getattr(loaded_user, "telegram_id", None) + if tid is not None: + try: + telegram_id = int(tid) + except (TypeError, ValueError): + telegram_id = None + reg_gb, prem_gb = _payment_traffic_gb_split(payment) return { "payment_id": int(payment.payment_id), "user_id": int(payment.user_id), "user_label": user_label, + "telegram_id": telegram_id, + "traffic_regular_gb": reg_gb, + "traffic_premium_gb": prem_gb, "provider": payment.provider, "provider_payment_id": payment.provider_payment_id, "amount": float(payment.amount), @@ -992,6 +1031,204 @@ async def admin_user_reset_trial_route(request: web.Request) -> web.Response: return _ok({}) +async def admin_user_premium_override_route(request: web.Request) -> web.Response: + """Premium-squad traffic overrides only (unlimited toggle + bonus GB).""" + actor_id = _require_admin_user_id(request) + target_id = int(request.match_info["user_id"]) + payload = await _read_json(request) + + unlimited = bool(payload.get("unlimited")) + bonus_bytes_raw = payload.get("bonus_bytes") + bonus_gb_raw = payload.get("bonus_gb") + if bonus_bytes_raw is None and bonus_gb_raw is None: + bonus_bytes = 0 + elif bonus_bytes_raw is not None: + try: + bonus_bytes = int(bonus_bytes_raw) + except (TypeError, ValueError): + return _error(400, "invalid_bonus", "bonus_bytes must be an integer") + else: + try: + bonus_bytes = int(round(float(bonus_gb_raw) * (1024 ** 3))) + except (TypeError, ValueError): + return _error(400, "invalid_bonus", "bonus_gb must be a number") + + if bonus_bytes < 0: + return _error(400, "invalid_bonus", "bonus must be non-negative") + + async_session_factory: sessionmaker = request.app["async_session_factory"] + async with async_session_factory() as session: + active = await subscription_dal.get_active_subscription_by_user_id(session, target_id) + if not active: + return _error(404, "no_active_subscription") + + active.premium_unlimited_override = bool(unlimited) + active.premium_bonus_bytes = int(bonus_bytes) + if active.premium_unlimited_override: + active.premium_is_limited = False + + await message_log_dal.create_message_log( + session, + { + "user_id": actor_id, + "event_type": "admin_premium_override_webapp", + "content": ( + f"unlimited={bool(unlimited)} bonus_bytes={int(bonus_bytes)}" + ), + "is_admin_event": True, + "target_user_id": target_id, + }, + ) + await session.commit() + await session.refresh(active) + + return _ok({"subscription": _serialize_subscription(active)}) + + +async def admin_user_regular_traffic_override_route(request: web.Request) -> web.Response: + """Main (regular) traffic: unlimited-style ceiling + admin bonus GB.""" + actor_id = _require_admin_user_id(request) + target_id = int(request.match_info["user_id"]) + payload = await _read_json(request) + + unlimited = bool(payload.get("unlimited")) + regular_bonus_bytes_raw = payload.get("regular_bonus_bytes") + regular_bonus_gb_raw = payload.get("regular_bonus_gb") + if regular_bonus_bytes_raw is None and regular_bonus_gb_raw is None: + regular_bonus_bytes = 0 + elif regular_bonus_bytes_raw is not None: + try: + regular_bonus_bytes = int(regular_bonus_bytes_raw) + except (TypeError, ValueError): + return _error(400, "invalid_regular_bonus", "regular_bonus_bytes must be an integer") + else: + try: + regular_bonus_bytes = int(round(float(regular_bonus_gb_raw) * (1024 ** 3))) + except (TypeError, ValueError): + return _error(400, "invalid_regular_bonus", "regular_bonus_gb must be a number") + + if regular_bonus_bytes < 0: + return _error(400, "invalid_regular_bonus", "regular bonus must be non-negative") + + subscription_service = request.app.get("subscription_service") + + async_session_factory: sessionmaker = request.app["async_session_factory"] + async with async_session_factory() as session: + active = await subscription_dal.get_active_subscription_by_user_id(session, target_id) + if not active: + return _error(404, "no_active_subscription") + + active.regular_unlimited_override = bool(unlimited) + active.regular_bonus_bytes = int(regular_bonus_bytes) + + if subscription_service is not None: + await subscription_service.sync_main_traffic_limit_to_panel(session, target_id) + + await message_log_dal.create_message_log( + session, + { + "user_id": actor_id, + "event_type": "admin_regular_traffic_override_webapp", + "content": ( + f"unlimited={bool(unlimited)} regular_bonus_bytes={int(regular_bonus_bytes)}" + ), + "is_admin_event": True, + "target_user_id": target_id, + }, + ) + await session.commit() + await session.refresh(active) + + return _ok({"subscription": _serialize_subscription(active)}) + + +async def admin_user_traffic_grant_route(request: web.Request) -> web.Response: + """Credit regular or premium traffic to a user without a payment. + + Body: ``{"kind": "regular" | "premium", "gb": float}`` (alternatively + ``"bytes": int``). Mirrors the same effect as a user-purchased top-up: + the chosen balance grows, panel limit/squads are refreshed, and an entry + is added to ``traffic_topups`` with ``kind="admin_topup"`` or + ``kind="admin_premium_topup"`` and ``payment_id=NULL``. + """ + actor_id = _require_admin_user_id(request) + target_id = int(request.match_info["user_id"]) + payload = await _read_json(request) + + kind = str(payload.get("kind") or "regular").strip().lower() + if kind not in {"regular", "premium"}: + return _error(400, "invalid_kind", "kind must be 'regular' or 'premium'") + + bytes_raw = payload.get("bytes") + gb_raw = payload.get("gb") + if bytes_raw is None and gb_raw is None: + return _error(400, "missing_amount", "either 'gb' or 'bytes' is required") + try: + if bytes_raw is not None: + grant_bytes = int(bytes_raw) + gb_value = grant_bytes / (1024 ** 3) + else: + gb_value = float(gb_raw) + grant_bytes = int(round(gb_value * (1024 ** 3))) + except (TypeError, ValueError): + return _error(400, "invalid_amount", "amount must be a positive number") + if gb_value <= 0 or grant_bytes <= 0: + return _error(400, "invalid_amount", "amount must be positive") + + subscription_service = request.app.get("subscription_service") + if subscription_service is None: + return _error(503, "subscription_service_unavailable") + + async_session_factory: sessionmaker = request.app["async_session_factory"] + async with async_session_factory() as session: + active = await subscription_dal.get_active_subscription_by_user_id(session, target_id) + if not active: + return _error(404, "no_active_subscription") + + if kind == "regular": + result = await subscription_service.admin_grant_topup( + session, target_id, gb_value + ) + else: + result = await subscription_service.admin_grant_premium_topup( + session, target_id, gb_value + ) + if not result: + await session.rollback() + return _error( + 422, + "grant_failed", + "Unable to credit traffic (missing tariff/squads or panel error)", + ) + + await message_log_dal.create_message_log( + session, + { + "user_id": actor_id, + "event_type": "admin_traffic_grant_webapp", + "content": f"kind={kind} bytes={grant_bytes}", + "is_admin_event": True, + "target_user_id": target_id, + }, + ) + await session.commit() + + refreshed = await subscription_dal.get_active_subscription_by_user_id( + session, target_id + ) + + return _ok( + { + "subscription": _serialize_subscription(refreshed) if refreshed else None, + "grant": { + "kind": kind, + "granted_bytes": grant_bytes, + "granted_gb": gb_value, + }, + } + ) + + async def admin_user_extend_route(request: web.Request) -> web.Response: actor_id = _require_admin_user_id(request) target_id = int(request.match_info["user_id"]) @@ -1003,14 +1240,22 @@ async def admin_user_extend_route(request: web.Request) -> web.Response: if days <= 0: return _error(400, "invalid_days") + subscription_service = request.app.get("subscription_service") + if subscription_service is None: + return _error(503, "subscription_service_unavailable") + async_session_factory: sessionmaker = request.app["async_session_factory"] async with async_session_factory() as session: - active = await subscription_dal.get_active_subscription_by_user_id(session, target_id) - if not active: - return _error(404, "no_active_subscription") + new_end = await subscription_service.extend_active_subscription_days( + session, + target_id, + days, + "admin_extend_subscription_webapp", + ) + if not new_end: + await session.rollback() + return _error(500, "extend_failed") - new_end = (active.end_date or datetime.now(timezone.utc)) + timedelta(days=days) - active.end_date = new_end await message_log_dal.create_message_log( session, { @@ -1022,8 +1267,18 @@ async def admin_user_extend_route(request: web.Request) -> web.Response: }, ) await session.commit() - await session.refresh(active) - return _ok({"subscription": _serialize_subscription(active)}) + + refreshed = await subscription_dal.get_active_subscription_by_user_id( + session, target_id + ) + + return _ok( + { + "subscription": _serialize_subscription(refreshed) + if refreshed + else None, + } + ) # ─── Payments ────────────────────────────────────────────────────── @@ -1589,6 +1844,18 @@ def setup_admin_routes(app: web.Application) -> None: router.add_post("/api/admin/users/{user_id:-?\\d+}/message/preview", admin_user_message_preview_route) router.add_post("/api/admin/users/{user_id:-?\\d+}/reset-trial", admin_user_reset_trial_route) router.add_post("/api/admin/users/{user_id:-?\\d+}/extend", admin_user_extend_route) + router.add_post( + "/api/admin/users/{user_id:-?\\d+}/premium-override", + admin_user_premium_override_route, + ) + router.add_post( + "/api/admin/users/{user_id:-?\\d+}/regular-traffic-override", + admin_user_regular_traffic_override_route, + ) + router.add_post( + "/api/admin/users/{user_id:-?\\d+}/traffic-grant", + admin_user_traffic_grant_route, + ) router.add_delete("/api/admin/users/{user_id:-?\\d+}", admin_user_delete_route) router.add_get("/api/admin/payments", admin_payments_list_route) diff --git a/bot/app/web/frontend/src/App.svelte b/bot/app/web/frontend/src/App.svelte index 79857ff..8f25595 100644 --- a/bot/app/web/frontend/src/App.svelte +++ b/bot/app/web/frontend/src/App.svelte @@ -454,6 +454,16 @@ }); } + function stripTopupQueryFromUrl() { + if (typeof window === "undefined") return; + const u = new URL(window.location.href); + if (!u.searchParams.has("topup")) return; + u.searchParams.delete("topup"); + const search = u.searchParams.toString(); + const qs = search ? `?${search}` : ""; + window.history.replaceState(null, "", `${u.pathname}${qs}${u.hash}`); + } + async function loadData() { const payload = await api("/me"); if (!payload.ok) throw new Error(payload.error || "load_failed"); @@ -482,6 +492,35 @@ if (topupModalOpen) await billingStore.loadTopupOptions(topupKind); if (deviceTopupModalOpen) await billingStore.loadDeviceTopupOptions(); if (changeModalOpen) await billingStore.loadTariffChangeOptions(); + + const topupDeep = new URLSearchParams(window.location.search).get("topup"); + if (topupDeep === "regular" || topupDeep === "premium") { + const plansList = payload.plans?.length ? payload.plans : []; + const tariffCatalogLocal = buildTariffCatalog(plansList); + const sub = payload.subscription || {}; + const tariffModeLocal = plansList.some((plan) => plan?.tariff_key); + const hasTariffSub = Boolean( + tariffModeLocal && + sub?.active && + sub?.tariff_key && + tariffCatalogLocal.some((t) => t.key === sub.tariff_key), + ); + const canRegular = + hasTariffSub && + (sub?.can_topup_regular_traffic ?? sub?.can_topup_traffic) && + Number(sub?.traffic_limit_bytes || 0) > 0; + const canPremium = + hasTariffSub && + (sub?.can_topup_premium_traffic ?? sub?.can_topup_traffic) && + Number(sub?.premium_limit_bytes || 0) > 0; + if (topupDeep === "regular" && canRegular) { + billingStore.openTopupModal("regular", payload.payment_methods?.[0]?.id || ""); + stripTopupQueryFromUrl(); + } else if (topupDeep === "premium" && canPremium) { + billingStore.openTopupModal("premium", payload.payment_methods?.[0]?.id || ""); + stripTopupQueryFromUrl(); + } + } } function showLogin() { diff --git a/bot/app/web/frontend/src/admin/AdminPanel.svelte b/bot/app/web/frontend/src/admin/AdminPanel.svelte index 389ce4e..f78247b 100644 --- a/bot/app/web/frontend/src/admin/AdminPanel.svelte +++ b/bot/app/web/frontend/src/admin/AdminPanel.svelte @@ -219,6 +219,19 @@ window.open("/api/admin/payments/export.csv", "_blank", "noopener"); } + function openPaymentUserCard(userId) { + const uid = Number(userId); + if (!Number.isFinite(uid) || uid <= 0) return; + const next = normalizeSection("users"); + sidebarOpen = false; + if (active !== next) { + active = next; + usersStore.closeUser(); + onSectionChange(next); + } + usersStore.openUser(uid); + } + function resolvedAvatarUrl(user) { return userAvatarUrl(user) || (!user?.telegram_id && user?.email ? gravatarCache.gravatarUrl(user.email) : ""); } @@ -470,7 +483,7 @@ {/if} {#if active === "payments"} - + {/if} {#if active === "promos"} diff --git a/bot/app/web/frontend/src/admin/sections/PaymentsSection.svelte b/bot/app/web/frontend/src/admin/sections/PaymentsSection.svelte index 1625065..3fc0a48 100644 --- a/bot/app/web/frontend/src/admin/sections/PaymentsSection.svelte +++ b/bot/app/web/frontend/src/admin/sections/PaymentsSection.svelte @@ -2,16 +2,19 @@ import { getContext, onMount } from "svelte"; import { AdminBadge, + AdminButton, AdminEmptyState, AdminPagination, AdminTable, AdminTableSkeleton, } from "$components/patterns/admin/index.js"; + import { User } from "$components/ui/icons.js"; export let at = (key) => key; export let fmtDate = (value) => value; export let fmtMoney = (value) => value; export let paymentStatusVariant = () => "muted"; + export let onOpenUserCard = () => {}; const paymentsStore = getContext("paymentsStore"); @@ -23,9 +26,60 @@ } = $paymentsStore); $: paymentsHasMore = payments.length > 0 && paymentsTotal > (paymentsPage + 1) * 25; // 25 is PAYMENTS_PAGE_SIZE + + /** @param {number|null|undefined} v */ + function formatTrafficGbCell(v) { + if (v == null || v === "") return "—"; + const n = Number(v); + if (Number.isNaN(n)) return "—"; + let s; + if (Math.abs(n - Math.round(n)) < 1e-9) { + s = String(Math.round(n)); + } else { + s = String(Math.round(n * 100) / 100); + } + return `${s} GB`; + } + + /** @param {number|null|undefined} v */ + function formatGbAmountPlain(v) { + if (v == null || v === "") return ""; + const n = Number(v); + if (Number.isNaN(n)) return ""; + if (Math.abs(n - Math.round(n)) < 1e-9) return String(Math.round(n)); + return String(Math.round(n * 100) / 100); + } + + /** @param {Record} p */ + function paymentDescriptionDisplay(p) { + const r = p.traffic_regular_gb; + const pr = p.traffic_premium_gb; + if (r != null && pr == null) { + const gb = formatGbAmountPlain(r); + return at( + "payments_desc_traffic_package_regular", + { gb }, + `Пакет трафика ${gb} ГБ (обычный)`, + ); + } + if (pr != null && r == null) { + const gb = formatGbAmountPlain(pr); + return at( + "payments_desc_traffic_package_premium", + { gb }, + `Пакет трафика ${gb} ГБ (премиум)`, + ); + } + const raw = p.description && String(p.description).trim(); + return raw || "—"; + } + $: paymentHeaders = [ at("id", {}, "ID"), at("user", {}, "Пользователь"), + at("payments_col_telegram_id", {}, "Telegram ID"), + at("payments_col_traffic_regular", {}, "Основной трафик"), + at("payments_col_traffic_premium", {}, "Премиум"), at("amount", {}, "Сумма"), at("provider", {}, "Провайдер"), at("description", {}, "Описание"), @@ -40,7 +94,11 @@
{#if paymentsLoading} - + {:else if !payments.length} {at("payments_empty", {}, "Нет платежей")} {:else} @@ -49,6 +107,9 @@ {at("id", {}, "ID")} {at("user", {}, "Пользователь")} + {at("payments_col_telegram_id", {}, "Telegram ID")} + {at("payments_col_traffic_regular", {}, "Основной трафик")} + {at("payments_col_traffic_premium", {}, "Премиум")} {at("amount", {}, "Сумма")} {at("provider", {}, "Провайдер")} {at("description", {}, "Описание")} @@ -60,10 +121,33 @@ {#each payments as p} #{p.payment_id} - {p.user_label || p.user_id} + + + onOpenUserCard(p.user_id)} + > + + + {p.user_label || p.user_id} + + + + {p.telegram_id != null ? p.telegram_id : "—"} + + + {formatTrafficGbCell(p.traffic_regular_gb)} + + + {formatTrafficGbCell(p.traffic_premium_gb)} + {fmtMoney(p.amount, p.currency)} {p.provider} - {p.description || "—"} + {paymentDescriptionDisplay(p)} {p.status} @@ -81,6 +165,47 @@ nextLabel={at("next", {}, "Далее")} prevDisabled={paymentsPage === 0} nextDisabled={!paymentsHasMore} - onPrev={() => { paymentsStore.setPage(Math.max(0, paymentsPage - 1)); }} - onNext={() => { paymentsStore.setPage(paymentsPage + 1); }} + onPrev={() => { + paymentsStore.setPage(Math.max(0, paymentsPage - 1)); + }} + onNext={() => { + paymentsStore.setPage(paymentsPage + 1); + }} /> + + diff --git a/bot/app/web/frontend/src/admin/sections/UserDetailModal.svelte b/bot/app/web/frontend/src/admin/sections/UserDetailModal.svelte index 277bcea..32ab908 100644 --- a/bot/app/web/frontend/src/admin/sections/UserDetailModal.svelte +++ b/bot/app/web/frontend/src/admin/sections/UserDetailModal.svelte @@ -1,7 +1,7 @@ @@ -177,7 +179,17 @@ warning={openedUserDetail.active_subscription.is_throttled} label={at("aria_label_main_traffic", {}, "Использование основного трафика")} /> - {#if Number(openedUserDetail.active_subscription.premium_limit_bytes || 0) > 0} + {#if openedUserDetail.active_subscription.premium_unlimited_override} + + {:else if Number(openedUserDetail.active_subscription.premium_limit_bytes || 0) > 0}
+ {#if openedUserDetail?.active_subscription} +
+ +
+ + {at("user_premium_override_bonus", {}, "Доп. премиум-трафик, GB")} + {at("user_premium_override_bonus_hint", {}, "")} + + +
+ +
+ +
+ +
+ + {at("user_regular_override_bonus", {}, "Доп. основной трафик, GB")} + {at("user_regular_override_bonus_hint", {}, "")} + + +
+ +
+ +
+ +
+ + {at("user_traffic_grant_kind", {}, "Тип трафика")} + usersStore.updateState({ grantTrafficKindDraft: v })} + ariaLabel={at("user_traffic_grant_kind", {}, "Тип трафика")} + /> + + + {at("user_traffic_grant_gb", {}, "ГБ к выдаче")} +
+ + + {at("user_traffic_grant_submit", {}, "Выдать")} + +
+
+
+
+ {/if} + {at("user_label_telegram_msg", {}, "Сообщение в Telegram")} {at("user_hint_telegram_msg", {}, "Поддерживается HTML-разметка Telegram")} @@ -339,3 +494,106 @@ + + diff --git a/bot/app/web/frontend/src/lib/admin/stores/usersStore.js b/bot/app/web/frontend/src/lib/admin/stores/usersStore.js index 8c78030..7664486 100644 --- a/bot/app/web/frontend/src/lib/admin/stores/usersStore.js +++ b/bot/app/web/frontend/src/lib/admin/stores/usersStore.js @@ -23,6 +23,12 @@ export function createUsersStore({ api, onToast, at }) { userBanConfirmOpen: false, userMessageConfirmOpen: false, userDetailTab: "profile", + premiumUnlimitedDraft: false, + premiumBonusGbDraft: "", + regularUnlimitedDraft: false, + regularBonusGbDraft: "", + grantTrafficGbDraft: "", + grantTrafficKindDraft: "regular", }); let _activeRef = "stats"; // fallback if active isn't tracked @@ -86,10 +92,19 @@ export function createUsersStore({ api, onToast, at }) { try { const res = await api(`/admin/users/${userId}`); if (res?.ok) { + const sub = res.active_subscription || null; + const bonusBytes = Number(sub?.premium_bonus_bytes || 0); + const regularBonusBytes = Number(sub?.regular_bonus_bytes || 0); state.update(s => ({ ...s, openedUserDetail: res, - openedUser: res.user ? { ...res.user, ...s.openedUser, ...res.user } : s.openedUser + openedUser: res.user ? { ...res.user, ...s.openedUser, ...res.user } : s.openedUser, + premiumUnlimitedDraft: Boolean(sub?.premium_unlimited_override), + premiumBonusGbDraft: bonusBytes > 0 ? +(bonusBytes / (1024 ** 3)).toFixed(2) : "", + regularUnlimitedDraft: Boolean(sub?.regular_unlimited_override), + regularBonusGbDraft: regularBonusBytes > 0 ? +(regularBonusBytes / (1024 ** 3)).toFixed(2) : "", + grantTrafficGbDraft: "", + grantTrafficKindDraft: "regular", })); } else { onToast(res?.error || "load_failed"); @@ -249,6 +264,103 @@ export function createUsersStore({ api, onToast, at }) { } } + async function savePremiumTrafficOverride() { + let s; + state.update(st => { s = st; return st; }); + if (!s.openedUser) return; + state.update(st => ({ ...st, userActionBusy: true })); + try { + const bonusGbRaw = s.premiumBonusGbDraft; + const bonusGb = bonusGbRaw === "" || bonusGbRaw === null || bonusGbRaw === undefined + ? 0 + : Number(bonusGbRaw); + if (Number.isNaN(bonusGb) || bonusGb < 0) { + onToast(at("premium_override_invalid_bonus", {}, "Некорректное значение GB")); + return; + } + const res = await api(`/admin/users/${s.openedUser.user_id}/premium-override`, { + method: "POST", + body: JSON.stringify({ + unlimited: Boolean(s.premiumUnlimitedDraft), + bonus_gb: bonusGb, + }), + }); + if (res?.ok) { + onToast(at("premium_override_saved", {}, "Премиум-оверрайд сохранён")); + await openUser(s.openedUser, { skipPush: true }); + } else { + onToast(res?.error || at("error", {}, "Ошибка")); + } + } finally { + state.update(st => ({ ...st, userActionBusy: false })); + } + } + + async function saveRegularTrafficOverride() { + let s; + state.update(st => { s = st; return st; }); + if (!s.openedUser) return; + state.update(st => ({ ...st, userActionBusy: true })); + try { + const regGbRaw = s.regularBonusGbDraft; + const regularGb = regGbRaw === "" || regGbRaw === null || regGbRaw === undefined + ? 0 + : Number(regGbRaw); + if (Number.isNaN(regularGb) || regularGb < 0) { + onToast(at("regular_override_invalid_bonus", {}, "Некорректное значение GB для основного трафика")); + return; + } + const res = await api(`/admin/users/${s.openedUser.user_id}/regular-traffic-override`, { + method: "POST", + body: JSON.stringify({ + unlimited: Boolean(s.regularUnlimitedDraft), + regular_bonus_gb: regularGb, + }), + }); + if (res?.ok) { + onToast(at("regular_override_saved", {}, "Оверрайд основного трафика сохранён")); + await openUser(s.openedUser, { skipPush: true }); + } else { + onToast(res?.error || at("error", {}, "Ошибка")); + } + } finally { + state.update(st => ({ ...st, userActionBusy: false })); + } + } + + async function grantTraffic() { + let s; + state.update(st => { s = st; return st; }); + if (!s.openedUser) return; + const gbRaw = s.grantTrafficGbDraft; + const gb = Number(gbRaw); + if (!gbRaw || Number.isNaN(gb) || gb <= 0) { + onToast(at("traffic_grant_invalid_gb", {}, "Введите положительное число GB")); + return; + } + const kind = s.grantTrafficKindDraft === "premium" ? "premium" : "regular"; + state.update(st => ({ ...st, userActionBusy: true })); + try { + const res = await api(`/admin/users/${s.openedUser.user_id}/traffic-grant`, { + method: "POST", + body: JSON.stringify({ kind, gb }), + }); + if (res?.ok) { + onToast( + kind === "premium" + ? at("traffic_grant_premium_done", { gb }, `+${gb} ГБ премиум-трафика`) + : at("traffic_grant_regular_done", { gb }, `+${gb} ГБ трафика`) + ); + state.update(st => ({ ...st, grantTrafficGbDraft: "" })); + await openUser(s.openedUser, { skipPush: true }); + } else { + onToast(res?.error || at("error", {}, "Ошибка")); + } + } finally { + state.update(st => ({ ...st, userActionBusy: false })); + } + } + async function deleteUser() { let s; state.update(st => { s = st; return st; }); @@ -291,5 +403,8 @@ export function createUsersStore({ api, onToast, at }) { extendUser, resetTrialUser, deleteUser, + savePremiumTrafficOverride, + saveRegularTrafficOverride, + grantTraffic, }; } diff --git a/bot/app/web/frontend/src/styles/admin-controls.css b/bot/app/web/frontend/src/styles/admin-controls.css index 926a138..7e8d957 100644 --- a/bot/app/web/frontend/src/styles/admin-controls.css +++ b/bot/app/web/frontend/src/styles/admin-controls.css @@ -267,6 +267,17 @@ flex: 0 0 auto; } +.admin-extend-control .admin-btn.admin-btn-primary { + background: var(--accent); + color: #02110a; + border-color: color-mix(in srgb, var(--accent) 70%, #000); + font-weight: 600; +} + +.admin-extend-control .admin-btn.admin-btn-primary:hover:not(:disabled) { + background: color-mix(in srgb, var(--accent) 90%, #fff); +} + .admin-input-row .input, .admin-input-row .admin-btn { height: 36px; diff --git a/bot/app/web/frontend/src/webapp/screens/HomeScreen.svelte b/bot/app/web/frontend/src/webapp/screens/HomeScreen.svelte index fbb0a0b..3b89ee1 100644 --- a/bot/app/web/frontend/src/webapp/screens/HomeScreen.svelte +++ b/bot/app/web/frontend/src/webapp/screens/HomeScreen.svelte @@ -100,7 +100,34 @@ {trafficPercent(subscription)}% - {#if Number(subscription?.premium_limit_bytes || 0) > 0} + {#if subscription?.premium_unlimited_override} + +
+ {premiumTitle(subscription)} + +
+
+ {#if premiumServerLabels(subscription).length} +
+ + {t("wa_premium_unlimited", {}, "Безлимит на premium-сервера")} + + +
+ {t("wa_premium_servers_limited", {}, "Отдельный лимит действует на")} +
+ {#each premiumServerLabels(subscription).slice(0, 8) as label} + {label} + {/each} +
+
+
+ {:else} + {t("wa_premium_unlimited", {}, "Безлимит на premium-сервера")} + {/if} +
+
+ {:else if Number(subscription?.premium_limit_bytes || 0) > 0} {#if canOpenPremiumTopupModal} diff --git a/bot/app/web/subscription_webapp.py b/bot/app/web/subscription_webapp.py index 5787571..ecb0b7a 100644 --- a/bot/app/web/subscription_webapp.py +++ b/bot/app/web/subscription_webapp.py @@ -2113,10 +2113,13 @@ async def tariff_topup_options_route(request: web.Request) -> web.Response: sale_mode="premium_topup", title_prefix=f"{tariff.premium_name(lang)} ", ) if topup_kind in {"all", "premium"} and tariff.premium_squad_uuids else [] + premium_bonus_bytes = int(getattr(sub, "premium_bonus_bytes", 0) or 0) + premium_unlimited_override = bool(getattr(sub, "premium_unlimited_override", False)) premium_limit_bytes = ( int(sub.premium_baseline_bytes or 0) + int(sub.premium_topup_balance_bytes or 0) + int(getattr(sub, "premium_topup_used_bytes", 0) or 0) + + premium_bonus_bytes ) premium_access = await request.app["subscription_service"].premium_access_for_tariff(tariff) return web.json_response( @@ -2127,7 +2130,7 @@ async def tariff_topup_options_route(request: web.Request) -> web.Response: "topup_kind": topup_kind, "premium_title": tariff.premium_name(lang), "traffic_percent": _traffic_percent(sub.traffic_used_bytes, sub.traffic_limit_bytes), - "premium_traffic_percent": _traffic_percent( + "premium_traffic_percent": 0 if premium_unlimited_override else _traffic_percent( sub.premium_used_bytes, premium_limit_bytes, ), @@ -2136,6 +2139,8 @@ async def tariff_topup_options_route(request: web.Request) -> web.Response: "premium_baseline_bytes": int(sub.premium_baseline_bytes or 0), "premium_topup_balance_bytes": int(sub.premium_topup_balance_bytes or 0), "premium_topup_used_bytes": int(getattr(sub, "premium_topup_used_bytes", 0) or 0), + "premium_bonus_bytes": premium_bonus_bytes, + "premium_unlimited_override": premium_unlimited_override, "premium_is_limited": bool(sub.premium_is_limited), "premium_squad_labels": premium_access.get("squad_labels") or [], "premium_node_labels": premium_access.get("node_labels") or [], @@ -3278,6 +3283,10 @@ def _serialize_subscription( "premium_baseline_bytes": _coerce_int_or_none(active.get("premium_baseline_bytes")), "premium_topup_balance_bytes": _coerce_int_or_none(active.get("premium_topup_balance_bytes")), "premium_topup_used_bytes": _coerce_int_or_none(active.get("premium_topup_used_bytes")), + "premium_bonus_bytes": _coerce_int_or_none(active.get("premium_bonus_bytes")) or 0, + "regular_bonus_bytes": _coerce_int_or_none(active.get("regular_bonus_bytes")) or 0, + "regular_unlimited_override": bool(active.get("regular_unlimited_override")), + "premium_unlimited_override": bool(active.get("premium_unlimited_override")), "premium_is_limited": bool(active.get("premium_is_limited")), "premium_squad_labels": list(active.get("premium_squad_labels") or []), "premium_node_labels": list(active.get("premium_node_labels") or []), diff --git a/bot/handlers/admin/user_management.py b/bot/handlers/admin/user_management.py index 5039cca..41b3222 100644 --- a/bot/handlers/admin/user_management.py +++ b/bot/handlers/admin/user_management.py @@ -192,6 +192,16 @@ def get_user_card_keyboard(user_id: int, i18n_instance, lang: str, callback_data=f"user_action:refresh:{user_id}" ) + # Row 3b: Premium override + traffic grant + builder.button( + text=_(key="admin_user_premium_override_button"), + callback_data=f"user_action:premium_override:{user_id}" + ) + builder.button( + text=_(key="admin_user_traffic_grant_button"), + callback_data=f"user_action:traffic_grant:{user_id}" + ) + # Row 4: Quick links — only for users with a real Telegram profile # (synthetic email-only users have a negative user_id with no tg profile). has_self_link = user_id > 0 @@ -225,9 +235,9 @@ def get_user_card_keyboard(user_id: int, i18n_instance, lang: str, quick_links_count = (1 if has_self_link else 0) + (1 if has_referrer_link else 0) if quick_links_count == 0: - builder.adjust(2, 2, 2, 1, 2) + builder.adjust(2, 2, 2, 2, 1, 2) else: - builder.adjust(2, 2, 2, quick_links_count, 1, 2) + builder.adjust(2, 2, 2, 2, quick_links_count, 1, 2) return builder @@ -343,6 +353,18 @@ async def format_user_card(user: User, session: AsyncSession, limit_display = _("traffic_unlimited") card_parts.append(f"{_('admin_user_traffic_label')} {hcode(f'{used_display} / {limit_display}')}") + + premium_unlimited = bool(subscription_details.get("premium_unlimited_override")) + premium_bonus_bytes = int(subscription_details.get("premium_bonus_bytes") or 0) + if premium_unlimited: + card_parts.append( + f"{_('admin_user_premium_override_label')} {hcode(_('admin_user_premium_override_unlimited'))}" + ) + elif premium_bonus_bytes > 0: + bonus_gb = premium_bonus_bytes / (1024 ** 3) + card_parts.append( + f"{_('admin_user_premium_override_label')} {hcode(_('admin_user_premium_override_bonus_value', gb=f'{bonus_gb:.2f}'))}" + ) else: card_parts.append(f"{_('admin_user_subscription_label')} {hcode(_('admin_user_subscription_none'))}") except Exception as e: @@ -541,10 +563,237 @@ async def user_action_handler(callback: types.CallbackQuery, state: FSMContext, await handle_delete_user_prompt( callback, state, user, settings, i18n, current_lang, session ) + elif action == "premium_override": + await handle_premium_override_menu( + callback, state, user, subscription_service, session, i18n, current_lang + ) + elif action == "premium_override_set_unlimited": + await handle_premium_override_apply( + callback, user, subscription_service, session, i18n, current_lang, + unlimited=True, bonus_bytes=0, + ) + elif action == "premium_override_clear": + await handle_premium_override_apply( + callback, user, subscription_service, session, i18n, current_lang, + unlimited=False, bonus_bytes=0, + ) + elif action == "premium_override_set_bonus": + await handle_premium_override_bonus_prompt( + callback, state, user, i18n, current_lang + ) + elif action == "traffic_grant": + await handle_traffic_grant_menu(callback, user, i18n, current_lang) + elif action == "traffic_grant_regular": + await handle_traffic_grant_prompt(callback, state, user, "regular", i18n, current_lang) + elif action == "traffic_grant_premium": + await handle_traffic_grant_prompt(callback, state, user, "premium", i18n, current_lang) else: await callback.answer(_("admin_unknown_action"), show_alert=True) +async def handle_premium_override_menu( + callback: types.CallbackQuery, + state: FSMContext, + user: User, + subscription_service: SubscriptionService, + session: AsyncSession, + i18n_instance, + lang: str, +) -> None: + """Show premium override sub-menu with current state and quick toggles.""" + _ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs) + + active_sub = await subscription_dal.get_active_subscription_by_user_id(session, user.user_id) + if not active_sub: + await callback.answer(_("admin_premium_override_no_subscription"), show_alert=True) + return + + unlimited = bool(getattr(active_sub, "premium_unlimited_override", False)) + bonus_bytes = int(getattr(active_sub, "premium_bonus_bytes", 0) or 0) + bonus_gb = bonus_bytes / (1024 ** 3) if bonus_bytes else 0 + + if unlimited: + current_text = _("admin_premium_override_state_unlimited") + elif bonus_bytes > 0: + current_text = _("admin_premium_override_state_bonus", gb=f"{bonus_gb:.2f}") + else: + current_text = _("admin_premium_override_state_none") + + text = "\n".join( + [ + f"{_('admin_premium_override_title')}", + "", + _("admin_premium_override_hint"), + "", + _("admin_premium_override_current", current=current_text), + ] + ) + + builder = InlineKeyboardBuilder() + builder.button( + text=_("admin_premium_override_btn_unlimited"), + callback_data=f"user_action:premium_override_set_unlimited:{user.user_id}", + ) + builder.button( + text=_("admin_premium_override_btn_bonus"), + callback_data=f"user_action:premium_override_set_bonus:{user.user_id}", + ) + builder.button( + text=_("admin_premium_override_btn_clear"), + callback_data=f"user_action:premium_override_clear:{user.user_id}", + ) + builder.button( + text=_("admin_user_back_to_card_button"), + callback_data=f"user_action:refresh:{user.user_id}", + ) + builder.adjust(1, 1, 1, 1) + + try: + await callback.message.edit_text(text, reply_markup=builder.as_markup(), parse_mode="HTML") + except Exception: + await callback.message.answer(text, reply_markup=builder.as_markup(), parse_mode="HTML") + await state.update_data(target_user_id=user.user_id) + await callback.answer() + + +async def handle_premium_override_apply( + callback: types.CallbackQuery, + user: User, + subscription_service: SubscriptionService, + session: AsyncSession, + i18n_instance, + lang: str, + *, + unlimited: bool, + bonus_bytes: int, +) -> None: + """Persist a premium override (unlimited or explicit bonus) on the active subscription.""" + _ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs) + + try: + active_sub = await subscription_dal.get_active_subscription_by_user_id(session, user.user_id) + if not active_sub: + await callback.answer(_("admin_premium_override_no_subscription"), show_alert=True) + return + + active_sub.premium_unlimited_override = bool(unlimited) + active_sub.premium_bonus_bytes = max(0, int(bonus_bytes or 0)) + if unlimited: + active_sub.premium_is_limited = False + + await message_log_dal.create_message_log_no_commit( + session, + { + "user_id": callback.from_user.id if callback.from_user else user.user_id, + "event_type": "admin:premium_override", + "content": ( + f"unlimited={bool(unlimited)} bonus_bytes={int(bonus_bytes or 0)}" + ), + "is_admin_event": True, + "target_user_id": user.user_id, + "timestamp": datetime.now(timezone.utc), + }, + ) + await session.commit() + + await callback.answer(_("admin_premium_override_saved"), show_alert=False) + await handle_refresh_user_card(callback, user, subscription_service, session, i18n_instance, lang) + except Exception as exc: + logging.error( + "Failed to apply premium override for user %s: %s", user.user_id, exc, exc_info=True + ) + await session.rollback() + await callback.answer(_("admin_premium_override_save_error"), show_alert=True) + + +async def handle_premium_override_bonus_prompt( + callback: types.CallbackQuery, + state: FSMContext, + user: User, + i18n_instance, + lang: str, +) -> None: + """Ask admin for the bonus GB to grant.""" + _ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs) + await state.update_data(target_user_id=user.user_id) + await state.set_state(AdminStates.waiting_for_premium_override_bonus_gb) + prompt = _("admin_premium_override_bonus_prompt", user_id=user.user_id) + try: + await callback.message.edit_text(prompt) + except Exception: + await callback.message.answer(prompt) + await callback.answer() + + +async def handle_traffic_grant_menu( + callback: types.CallbackQuery, + user: User, + i18n_instance, + lang: str, +) -> None: + """Show traffic-grant menu (regular vs premium).""" + _ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs) + + text = "\n".join( + [ + f"{_('admin_traffic_grant_title')}", + "", + _("admin_traffic_grant_hint"), + ] + ) + builder = InlineKeyboardBuilder() + builder.button( + text=_("admin_traffic_grant_btn_regular"), + callback_data=f"user_action:traffic_grant_regular:{user.user_id}", + ) + builder.button( + text=_("admin_traffic_grant_btn_premium"), + callback_data=f"user_action:traffic_grant_premium:{user.user_id}", + ) + builder.button( + text=_("admin_user_back_to_card_button"), + callback_data=f"user_action:refresh:{user.user_id}", + ) + builder.adjust(1, 1, 1) + + try: + await callback.message.edit_text(text, reply_markup=builder.as_markup(), parse_mode="HTML") + except Exception: + await callback.message.answer(text, reply_markup=builder.as_markup(), parse_mode="HTML") + await callback.answer() + + +async def handle_traffic_grant_prompt( + callback: types.CallbackQuery, + state: FSMContext, + user: User, + kind: str, + i18n_instance, + lang: str, +) -> None: + """Ask the admin for the amount of GB to grant (regular or premium).""" + _ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs) + kind_normalized = "premium" if kind == "premium" else "regular" + await state.update_data(target_user_id=user.user_id, traffic_grant_kind=kind_normalized) + await state.set_state(AdminStates.waiting_for_traffic_grant_gb) + prompt_key = ( + "admin_traffic_grant_prompt_premium" + if kind_normalized == "premium" + else "admin_traffic_grant_prompt_regular" + ) + prompt = _(prompt_key, user_id=user.user_id) + try: + await callback.message.edit_text(prompt) + except Exception: + await callback.message.answer(prompt) + await callback.answer() + + +# `process_traffic_grant_gb_handler` is declared near the other FSM-bound +# handlers at the bottom of the module, so the router decorator can attach to +# the same `router` instance the premium override flow uses. + + async def handle_reset_trial(callback: types.CallbackQuery, user: User, subscription_service: SubscriptionService, session: AsyncSession, i18n_instance, lang: str): @@ -1372,6 +1621,203 @@ async def process_unban_user_handler(message: types.Message, state: FSMContext, await state.clear() +@router.message(AdminStates.waiting_for_premium_override_bonus_gb, F.text) +async def process_premium_override_bonus_handler( + message: types.Message, + state: FSMContext, + settings: Settings, + i18n_data: dict, + subscription_service: SubscriptionService, + session: AsyncSession, +): + """Read bonus GB and apply premium override (non-unlimited path).""" + current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE) + i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") + if not i18n: + await message.reply("Language service error.") + return + _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) + + data = await state.get_data() + target_user_id = data.get("target_user_id") + if not target_user_id: + await message.answer(_("admin_premium_override_state_missing")) + await state.clear() + return + + raw = (message.text or "").strip().replace(",", ".") + try: + gb = float(raw) + if gb < 0 or gb > 1_000_000: + raise ValueError("out_of_range") + except (TypeError, ValueError): + await message.answer(_("admin_premium_override_invalid_gb")) + return + + bonus_bytes = int(round(gb * (1024 ** 3))) + target_user = await user_dal.get_user_by_id(session, target_user_id) + if not target_user: + await message.answer(_("admin_user_not_found_action")) + await state.clear() + return + + try: + active_sub = await subscription_dal.get_active_subscription_by_user_id( + session, target_user_id + ) + if not active_sub: + await message.answer(_("admin_premium_override_no_subscription")) + await state.clear() + return + + active_sub.premium_unlimited_override = False + active_sub.premium_bonus_bytes = max(0, bonus_bytes) + + await message_log_dal.create_message_log_no_commit( + session, + { + "user_id": message.from_user.id if message.from_user else target_user_id, + "event_type": "admin:premium_override", + "content": f"unlimited=False bonus_bytes={int(bonus_bytes)}", + "is_admin_event": True, + "target_user_id": target_user_id, + "timestamp": datetime.now(timezone.utc), + }, + ) + await session.commit() + + await message.answer( + _("admin_premium_override_bonus_set", gb=f"{gb:.2f}", user_id=target_user_id) + ) + + referral_service = ReferralService(settings, subscription_service, message.bot, i18n) + bot_username = await _resolve_bot_username(message.bot) + user_card_text = await format_user_card( + target_user, session, subscription_service, i18n, current_lang, referral_service, + settings=settings, bot_username=bot_username, + ) + keyboard = get_user_card_keyboard( + target_user.user_id, i18n, current_lang, target_user.referred_by_id + ) + await _send_with_profile_link_fallback( + message.answer, + text=user_card_text, + markup=keyboard.as_markup(), + user_id=target_user.user_id, + parse_mode="HTML", + ) + except Exception as exc: + logging.error( + "Error setting premium override bonus for user %s: %s", target_user_id, exc, exc_info=True + ) + await session.rollback() + await message.answer(_("admin_premium_override_save_error")) + finally: + await state.clear() + + +@router.message(AdminStates.waiting_for_traffic_grant_gb, F.text) +async def process_traffic_grant_gb_handler( + message: types.Message, + state: FSMContext, + settings: Settings, + i18n_data: dict, + subscription_service: SubscriptionService, + session: AsyncSession, +): + """Read GB amount and apply admin grant of regular or premium traffic.""" + current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE) + i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") + if not i18n: + await message.reply("Language service error.") + return + _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) + + data = await state.get_data() + target_user_id = data.get("target_user_id") + kind = (data.get("traffic_grant_kind") or "regular").lower() + if not target_user_id: + await message.answer(_("admin_traffic_grant_no_user")) + await state.clear() + return + + raw = (message.text or "").strip().replace(",", ".") + try: + gb_value = float(raw) + if gb_value <= 0 or gb_value > 1_000_000: + raise ValueError("out_of_range") + except (TypeError, ValueError): + await message.answer(_("admin_traffic_grant_invalid_gb")) + return + + target_user = await user_dal.get_user_by_id(session, target_user_id) + if not target_user: + await message.answer(_("admin_user_not_found_action")) + await state.clear() + return + + try: + if kind == "premium": + result = await subscription_service.admin_grant_premium_topup( + session, target_user_id, gb_value + ) + else: + result = await subscription_service.admin_grant_topup( + session, target_user_id, gb_value + ) + if not result: + await session.rollback() + await message.answer(_("admin_traffic_grant_failed")) + await state.clear() + return + await message_log_dal.create_message_log_no_commit( + session, + { + "user_id": message.from_user.id if message.from_user else target_user_id, + "event_type": "admin:traffic_grant", + "content": f"kind={kind} gb={gb_value:g}", + "is_admin_event": True, + "target_user_id": target_user_id, + "timestamp": datetime.now(timezone.utc), + }, + ) + await session.commit() + + gb_text = f"{gb_value:g}" + success_key = ( + "admin_traffic_grant_premium_done" + if kind == "premium" + else "admin_traffic_grant_regular_done" + ) + await message.answer(_(success_key, gb=gb_text, user_id=target_user_id)) + + referral_service = ReferralService(settings, subscription_service, message.bot, i18n) + bot_username = await _resolve_bot_username(message.bot) + user_card_text = await format_user_card( + target_user, session, subscription_service, i18n, current_lang, referral_service, + settings=settings, bot_username=bot_username, + ) + keyboard = get_user_card_keyboard( + target_user.user_id, i18n, current_lang, target_user.referred_by_id + ) + await _send_with_profile_link_fallback( + message.answer, + text=user_card_text, + markup=keyboard.as_markup(), + user_id=target_user.user_id, + parse_mode="HTML", + ) + except Exception as exc: + logging.error( + "Error granting traffic for user %s (kind=%s, gb=%s): %s", + target_user_id, kind, gb_value, exc, exc_info=True, + ) + await session.rollback() + await message.answer(_("admin_traffic_grant_failed")) + finally: + await state.clear() + + @router.callback_query(F.data.startswith("admin_user_card_from_list:")) async def user_card_from_list_handler(callback: types.CallbackQuery, state: FSMContext, i18n_data: dict, @@ -1413,7 +1859,7 @@ async def user_card_from_list_handler(callback: types.CallbackQuery, callback_data=f"admin_action:users_list:{page}" ) quick_links_width = 2 if user.referred_by_id else 1 - keyboard.adjust(2, 2, 2, quick_links_width, 1, 2, 1) + keyboard.adjust(2, 2, 2, 2, quick_links_width, 1, 2, 1) # Format user card try: diff --git a/bot/handlers/user/payment.py b/bot/handlers/user/payment.py index b3ad551..f77aec0 100644 --- a/bot/handlers/user/payment.py +++ b/bot/handlers/user/payment.py @@ -266,7 +266,7 @@ async def process_successful_payment(session: AsyncSession, bot: Bot, "applied_promo_bonus_days", 0) referral_bonus_info = None - if sale_mode != "traffic": + if sale_mode_base == "subscription": referral_bonus_info = await referral_service.apply_referral_bonuses_for_payment( session, user_id, @@ -292,7 +292,7 @@ async def process_successful_payment(session: AsyncSession, bot: Bot, if should_send_lknpd_receipt: receipt_item_name = payment_info_from_webhook.get("description") if not receipt_item_name: - if sale_mode == "traffic": + if sale_mode_base in {"traffic", "traffic_package", "topup", "premium_topup"}: receipt_item_name = settings.LKNPD_RECEIPT_NAME_TRAFFIC.format(gb=traffic_label) else: receipt_item_name = settings.LKNPD_RECEIPT_NAME_SUBSCRIPTION.format(months=int(subscription_months)) @@ -313,14 +313,14 @@ async def process_successful_payment(session: AsyncSession, bot: Bot, ) config_link_text = config_link_display or _("config_link_not_available") # For auto-renew charges, avoid re-sending config link; send concise message - if sale_mode != "traffic" and is_auto_renew and final_end_date_for_user: + if sale_mode_base == "subscription" and is_auto_renew and final_end_date_for_user: details_message = _( "yookassa_auto_renewal", months=int(subscription_months), end_date=final_end_date_for_user.strftime('%Y-%m-%d'), ) details_markup = None - elif sale_mode == "traffic": + elif sale_mode_base in {"traffic", "traffic_package", "topup", "premium_topup"}: details_message = _( "payment_successful_traffic_full", traffic_gb=traffic_label, @@ -403,14 +403,23 @@ async def process_successful_payment(session: AsyncSession, bot: Bot, try: notification_service = NotificationService(bot, settings, i18n) user = await user_dal.get_user_by_id(session, user_id) + tariff_for_log = None + if payment_before_update and getattr(payment_before_update, "tariff_key", None): + tariff_for_log = payment_before_update.tariff_key + elif updated_payment_record and getattr(updated_payment_record, "tariff_key", None): + tariff_for_log = updated_payment_record.tariff_key + elif payment_record and getattr(payment_record, "tariff_key", None): + tariff_for_log = payment_record.tariff_key await notification_service.notify_payment_received( user_id=user_id, amount=payment_value, currency=settings.DEFAULT_CURRENCY_SYMBOL, - months=int(subscription_months) if sale_mode != "traffic" else 0, + months=int(subscription_months) if sale_mode_base == "subscription" else 0, payment_provider="yookassa", # This is specifically for YooKassa webhook username=user.username if user else None, - traffic_gb=traffic_amount_gb if sale_mode == "traffic" else None, + traffic_gb=traffic_amount_gb if sale_mode_base in {"traffic", "traffic_package", "topup", "premium_topup"} else None, + traffic_is_premium=sale_mode_base == "premium_topup", + tariff_key=tariff_for_log, ) except Exception as e: logging.error(f"Failed to send payment notification: {e}") diff --git a/bot/handlers/user/subscription/payments_freekassa.py b/bot/handlers/user/subscription/payments_freekassa.py index 5d349b3..868bb19 100644 --- a/bot/handlers/user/subscription/payments_freekassa.py +++ b/bot/handlers/user/subscription/payments_freekassa.py @@ -75,7 +75,7 @@ async def pay_fk_callback_handler( "currency": currency_code, "status": "pending_freekassa", "description": payment_description, - "subscription_duration_months": int(months), + "subscription_duration_months": int(months) if sale_base == "subscription" else None, "provider": "freekassa", "sale_mode": sale_mode, "tariff_key": sale_mode.split("@", 1)[1] if "@" in sale_mode else None, @@ -147,7 +147,7 @@ async def pay_fk_callback_handler( try: await callback.message.edit_text( f"{order_info_text}\n\n" + get_text( - key="payment_link_message_traffic" if sale_mode == "traffic" else "payment_link_message", + key="payment_link_message_traffic" if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"} else "payment_link_message", months=int(months), traffic_gb=human_value, ), @@ -165,7 +165,7 @@ async def pay_fk_callback_handler( try: await callback.message.answer( f"{order_info_text}\n\n" + get_text( - key="payment_link_message_traffic" if sale_mode == "traffic" else "payment_link_message", + key="payment_link_message_traffic" if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"} else "payment_link_message", months=int(months), traffic_gb=human_value, ), diff --git a/bot/handlers/user/subscription/payments_platega.py b/bot/handlers/user/subscription/payments_platega.py index 3e134a3..4546cc7 100644 --- a/bot/handlers/user/subscription/payments_platega.py +++ b/bot/handlers/user/subscription/payments_platega.py @@ -102,7 +102,7 @@ async def pay_platega_callback_handler( "currency": currency_code, "status": "pending_platega", "description": payment_description, - "subscription_duration_months": int(months), + "subscription_duration_months": int(months) if sale_base == "subscription" else None, "provider": "platega", "sale_mode": sale_mode, "tariff_key": sale_mode.split("@", 1)[1] if "@" in sale_mode else None, @@ -178,7 +178,7 @@ async def pay_platega_callback_handler( try: await callback.message.edit_text( get_text( - key="payment_link_message_traffic" if sale_mode == "traffic" else "payment_link_message", + key="payment_link_message_traffic" if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"} else "payment_link_message", months=int(months), traffic_gb=human_value, ), @@ -196,7 +196,7 @@ async def pay_platega_callback_handler( try: await callback.message.answer( get_text( - key="payment_link_message_traffic" if sale_mode == "traffic" else "payment_link_message", + key="payment_link_message_traffic" if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"} else "payment_link_message", months=int(months), traffic_gb=human_value, ), diff --git a/bot/handlers/user/subscription/payments_severpay.py b/bot/handlers/user/subscription/payments_severpay.py index b924988..f4f1c15 100644 --- a/bot/handlers/user/subscription/payments_severpay.py +++ b/bot/handlers/user/subscription/payments_severpay.py @@ -74,7 +74,7 @@ async def pay_severpay_callback_handler( "currency": currency_code, "status": "pending_severpay", "description": payment_description, - "subscription_duration_months": int(months), + "subscription_duration_months": int(months) if sale_base == "subscription" else None, "provider": "severpay", "sale_mode": sale_mode, "tariff_key": sale_mode.split("@", 1)[1] if "@" in sale_mode else None, @@ -138,7 +138,7 @@ async def pay_severpay_callback_handler( try: await callback.message.edit_text( get_text( - key="payment_link_message_traffic" if sale_mode == "traffic" else "payment_link_message", + key="payment_link_message_traffic" if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"} else "payment_link_message", months=int(months), traffic_gb=human_value, ), @@ -156,7 +156,7 @@ async def pay_severpay_callback_handler( try: await callback.message.answer( get_text( - key="payment_link_message_traffic" if sale_mode == "traffic" else "payment_link_message", + key="payment_link_message_traffic" if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"} else "payment_link_message", months=int(months), traffic_gb=human_value, ), diff --git a/bot/handlers/user/subscription/payments_yookassa.py b/bot/handlers/user/subscription/payments_yookassa.py index 67f2879..9826124 100644 --- a/bot/handlers/user/subscription/payments_yookassa.py +++ b/bot/handlers/user/subscription/payments_yookassa.py @@ -93,7 +93,7 @@ async def _initiate_yk_payment( "currency": currency_code_for_yk, "status": "pending_yookassa", "description": payment_description, - "subscription_duration_months": int(months), + "subscription_duration_months": int(months) if sale_base == "subscription" else None, "sale_mode": sale_base, "tariff_key": sale_mode.split("@", 1)[1] if "@" in sale_mode else None, "purchased_gb": float(months) if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"} else None, @@ -223,7 +223,7 @@ async def _initiate_yk_payment( try: await callback.message.edit_text( get_text( - key="payment_link_message_traffic" if sale_mode == "traffic" else "payment_link_message", + key="payment_link_message_traffic" if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"} else "payment_link_message", months=int(months), traffic_gb=_format_value(months), ), @@ -243,7 +243,7 @@ async def _initiate_yk_payment( try: await callback.message.answer( get_text( - key="payment_link_message_traffic" if sale_mode == "traffic" else "payment_link_message", + key="payment_link_message_traffic" if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"} else "payment_link_message", months=int(months), traffic_gb=_format_value(months), ), diff --git a/bot/services/crypto_pay_service.py b/bot/services/crypto_pay_service.py index 243571e..29f8941 100644 --- a/bot/services/crypto_pay_service.py +++ b/bot/services/crypto_pay_service.py @@ -87,7 +87,7 @@ class CryptoPayService: "currency": self.settings.CRYPTOPAY_ASSET, "status": "pending_cryptopay", "description": description, - "subscription_duration_months": int(months), + "subscription_duration_months": int(months) if sale_base == "subscription" else None, "provider": "cryptopay", "sale_mode": sale_mode, "tariff_key": sale_mode.split("@", 1)[1] if "@" in sale_mode else None, @@ -263,6 +263,10 @@ class CryptoPayService: logging.exception("Failed to send CryptoPay success message.") # Send notification about payment + try: + payment_row = await payment_dal.get_payment_by_db_id(session, payment_db_id) + except Exception: + payment_row = None try: notification_service = NotificationService(bot, settings, i18n) user = await user_dal.get_user_by_id(session, user_id) @@ -273,7 +277,9 @@ class CryptoPayService: months=int(months) if sale_base == "subscription" else 0, traffic_gb=traffic_gb if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"} else None, payment_provider="crypto_pay", - username=user.username if user else None + username=user.username if user else None, + traffic_is_premium=sale_base == "premium_topup", + tariff_key=getattr(payment_row, "tariff_key", None) if payment_row else None, ) except Exception: logging.exception("Failed to send crypto_pay payment notification.") diff --git a/bot/services/email_templates.py b/bot/services/email_templates.py index 1a064c8..7c07927 100644 --- a/bot/services/email_templates.py +++ b/bot/services/email_templates.py @@ -347,7 +347,7 @@ def render_payment_success( lang = _normalize_lang(language_code, settings) accent = _safe_color(settings.WEBAPP_PRIMARY_COLOR) brand = _brand_title(settings) - is_traffic = sale_mode == "traffic" + is_traffic = (sale_mode or "").split("@", 1)[0].split("|", 1)[0] in {"traffic", "traffic_package", "topup", "premium_topup"} amount_text = _format_amount(amount, currency) safe_dashboard_url = (dashboard_url or "").strip() end_date = end_date_text or "—" diff --git a/bot/services/freekassa_service.py b/bot/services/freekassa_service.py index 675dd05..beb36e9 100644 --- a/bot/services/freekassa_service.py +++ b/bot/services/freekassa_service.py @@ -315,6 +315,7 @@ class FreeKassaService: final_end = activation.get("end_date") if activation else None months = payment.purchased_gb or payment.subscription_duration_months or 1 sale_mode = payment.sale_mode or ("traffic" if self.settings.traffic_sale_mode else "subscription") + sale_base = sale_mode.split("@", 1)[0].split("|", 1)[0] applied_days = 0 if referral_bonus and referral_bonus.get("referee_new_end_date"): @@ -395,10 +396,12 @@ class FreeKassaService: user_id=payment.user_id, amount=float(payment.amount), currency=self.default_currency, - months=int(months) if sale_mode != "traffic" else 0, - traffic_gb=months if sale_mode == "traffic" else None, + months=int(months) if sale_base == "subscription" else 0, + traffic_gb=float(months) if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"} else None, payment_provider="freekassa", username=db_user.username if db_user else None, + traffic_is_premium=sale_base == "premium_topup", + tariff_key=getattr(payment, "tariff_key", None), ) except Exception: logging.exception("FreeKassa notification: failed to notify admins.") diff --git a/bot/services/notification_service.py b/bot/services/notification_service.py index fdfc181..1812659 100644 --- a/bot/services/notification_service.py +++ b/bot/services/notification_service.py @@ -324,10 +324,31 @@ class NotificationService: profile_keyboard = self._build_profile_keyboard(_, telegram_id) await self._send_to_log_channel(message, reply_markup=profile_keyboard) + def _format_traffic_gb_admin(self, traffic_gb: float) -> str: + value = float(traffic_gb) + if value.is_integer(): + return str(int(value)) + return f"{value:g}" + + def _tariff_display_for_log(self, tariff_key: Optional[str]) -> str: + if not tariff_key: + return "" + cfg = getattr(self.settings, "tariffs_config", None) + if not cfg: + return str(tariff_key) + try: + tariff = cfg.require(str(tariff_key)) + return str(tariff.name(self.settings.DEFAULT_LANGUAGE)) + except Exception: + return str(tariff_key) + async def notify_payment_received(self, user_id: int, amount: float, currency: str, months: int, payment_provider: str, username: Optional[str] = None, - traffic_gb: Optional[float] = None): + traffic_gb: Optional[float] = None, + *, + traffic_is_premium: bool = False, + tariff_key: Optional[str] = None): """Send notification about successful payment""" if not self.settings.LOG_PAYMENTS: return @@ -350,14 +371,25 @@ class NotificationService: }.get(payment_provider.lower(), "💰") if traffic_gb is not None: - traffic_label = str(int(traffic_gb)) if float(traffic_gb).is_integer() else f"{traffic_gb:g}" + traffic_label = self._format_traffic_gb_admin(float(traffic_gb)) + traffic_kind = _( + "log_payment_traffic_kind_premium" if traffic_is_premium else "log_payment_traffic_kind_regular", + ) + traffic_summary = _("log_payment_traffic_purchase_line", gb=traffic_label, kind=traffic_kind) + tariff_name = self._tariff_display_for_log(tariff_key) + tariff_line = ( + _("log_payment_tariff_line", name=hd.quote(tariff_name)) + if tariff_name + else "" + ) message = _( "log_payment_received_traffic", provider_emoji=provider_emoji, user_display=user_display, amount=amount, currency=currency, - traffic_gb=traffic_label, + traffic_summary=traffic_summary, + tariff_line=tariff_line, payment_provider=payment_provider, timestamp=datetime.now().strftime("%Y-%m-%d %H:%M:%S") ) diff --git a/bot/services/platega_service.py b/bot/services/platega_service.py index 6b6ef62..6c837a0 100644 --- a/bot/services/platega_service.py +++ b/bot/services/platega_service.py @@ -318,10 +318,12 @@ class PlategaService: user_id=payment.user_id, amount=float(payment.amount), currency=currency, - months=int(payment_months) if sale_mode != "traffic" else 0, - traffic_gb=payment_months if sale_mode == "traffic" else None, + months=int(payment_months) if sale_base == "subscription" else 0, + traffic_gb=float(payment_months) if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"} else None, payment_provider="platega", username=db_user.username if db_user else None, + traffic_is_premium=sale_base == "premium_topup", + tariff_key=getattr(payment, "tariff_key", None), ) except Exception: logging.exception("Platega webhook: failed to notify admins.") diff --git a/bot/services/severpay_service.py b/bot/services/severpay_service.py index be1ea75..6ce1747 100644 --- a/bot/services/severpay_service.py +++ b/bot/services/severpay_service.py @@ -314,10 +314,12 @@ class SeverPayService: user_id=payment.user_id, amount=float(payment.amount), currency=payment.currency, - months=int(payment_months) if sale_mode != "traffic" else 0, - traffic_gb=payment_months if sale_mode == "traffic" else None, + months=int(payment_months) if sale_base == "subscription" else 0, + traffic_gb=float(payment_months) if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"} else None, payment_provider="severpay", username=db_user.username if db_user else None, + traffic_is_premium=sale_base == "premium_topup", + tariff_key=getattr(payment, "tariff_key", None), ) except Exception: logging.exception("SeverPay webhook: failed to notify admins.") diff --git a/bot/services/stars_service.py b/bot/services/stars_service.py index eff5142..0dd6cab 100644 --- a/bot/services/stars_service.py +++ b/bot/services/stars_service.py @@ -35,7 +35,7 @@ class StarsService: "currency": "XTR", "status": "pending_stars", "description": description, - "subscription_duration_months": int(months), + "subscription_duration_months": int(months) if sale_base == "subscription" else None, "provider": "telegram_stars", "sale_mode": sale_mode, "tariff_key": sale_mode.split("@", 1)[1] if "@" in sale_mode else None, @@ -201,7 +201,9 @@ class StarsService: months=int(months) if sale_base == "subscription" else 0, payment_provider="stars", username=user.username if user else None, - traffic_gb=months if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"} else None, + traffic_gb=float(months) if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"} else None, + traffic_is_premium=sale_base == "premium_topup", + tariff_key=getattr(payment_record, "tariff_key", None), ) except Exception as e: logging.error(f"Failed to send stars payment notification: {e}") diff --git a/bot/services/subscription_service.py b/bot/services/subscription_service.py index 6372e38..781aaf4 100644 --- a/bot/services/subscription_service.py +++ b/bot/services/subscription_service.py @@ -85,10 +85,25 @@ class SubscriptionService: return list(dict.fromkeys(squads)) return self.settings.parsed_user_squad_uuids - def _traffic_limit_for_period_tariff(self, tariff: Optional[Tariff], topup_balance_bytes: int = 0) -> int: + def _traffic_limit_for_period_tariff( + self, + tariff: Optional[Tariff], + topup_balance_bytes: int = 0, + regular_bonus_bytes: int = 0, + regular_unlimited_override: bool = False, + traffic_used_bytes: int = 0, + ) -> int: if tariff: - return int(tariff.monthly_bytes + max(0, topup_balance_bytes)) - return self.settings.user_traffic_limit_bytes + baseline = int(tariff.monthly_bytes or 0) + else: + baseline = int(self.settings.user_traffic_limit_bytes) + return self._compute_main_traffic_limit_bytes( + tier_baseline_bytes=baseline, + topup_balance_bytes=topup_balance_bytes, + regular_bonus_bytes=regular_bonus_bytes, + regular_unlimited_override=regular_unlimited_override, + traffic_used_bytes=traffic_used_bytes, + ) def _premium_limit_for_tariff(self, tariff: Optional[Tariff], topup_balance_bytes: int = 0) -> int: if not tariff: @@ -100,11 +115,35 @@ class SubscriptionService: premium_baseline_bytes: int, premium_topup_balance_bytes: int = 0, premium_topup_used_bytes: int = 0, + premium_bonus_bytes: int = 0, ) -> int: - return int(premium_baseline_bytes or 0) + max(0, int(premium_topup_balance_bytes or 0)) + max( - 0, int(premium_topup_used_bytes or 0) + return ( + int(premium_baseline_bytes or 0) + + max(0, int(premium_topup_balance_bytes or 0)) + + max(0, int(premium_topup_used_bytes or 0)) + + max(0, int(premium_bonus_bytes or 0)) ) + def _compute_main_traffic_limit_bytes( + self, + *, + tier_baseline_bytes: int, + topup_balance_bytes: int, + regular_bonus_bytes: int, + regular_unlimited_override: bool, + traffic_used_bytes: int, + ) -> int: + """Numeric cap sent to the panel; ``regular_unlimited_override`` uses a large practical ceiling.""" + floor = ( + int(tier_baseline_bytes or 0) + + max(0, int(topup_balance_bytes or 0)) + + max(0, int(regular_bonus_bytes or 0)) + ) + if regular_unlimited_override: + used = max(0, int(traffic_used_bytes or 0)) + return max(floor, used + 512 * (1024 ** 3), 1024 ** 5) + return floor + async def premium_access_for_tariff(self, tariff: Optional[Tariff]) -> Dict[str, Any]: if not tariff or not tariff.premium_squad_uuids: return {"squad_uuids": [], "squad_labels": [], "node_labels": []} @@ -949,7 +988,17 @@ class SubscriptionService: purchase_bytes = self.gb_to_bytes(traffic_gb) new_topup_balance = int(sub.topup_balance_bytes or 0) + purchase_bytes - new_limit = int(sub.tier_baseline_bytes or tariff.monthly_bytes) + new_topup_balance + baseline = int(sub.tier_baseline_bytes or tariff.monthly_bytes) + rb = int(getattr(sub, "regular_bonus_bytes", 0) or 0) + runl = bool(getattr(sub, "regular_unlimited_override", False)) + used_for_lim = int(getattr(sub, "traffic_used_bytes", 0) or 0) + new_limit = self._compute_main_traffic_limit_bytes( + tier_baseline_bytes=baseline, + topup_balance_bytes=new_topup_balance, + regular_bonus_bytes=rb, + regular_unlimited_override=runl, + traffic_used_bytes=used_for_lim, + ) base_hwid_limit = ( int(sub.hwid_device_limit) if sub.hwid_device_limit is not None @@ -1084,6 +1133,277 @@ class SubscriptionService: "tariff_key": tariff.key, } + async def admin_grant_topup( + self, + session: AsyncSession, + user_id: int, + traffic_gb: float, + ) -> Optional[Dict[str, Any]]: + """Credit regular traffic to a user as if they purchased a top-up. + + Mirrors :meth:`activate_topup` but skips payment context and tariff + resolution: the grant simply increases ``topup_balance_bytes`` and + recomputes ``traffic_limit_bytes`` from the subscription's current + tier baseline. The audit row in ``traffic_topups`` is stored with + ``kind="admin_topup"`` and ``payment_id=NULL`` so reports stay clean. + """ + try: + gb_value = float(traffic_gb) + except (TypeError, ValueError): + logging.error("admin_grant_topup: invalid traffic_gb=%r", traffic_gb) + return None + if gb_value <= 0: + return None + + db_user = await user_dal.get_user_by_id(session, user_id) + if not db_user or not db_user.panel_user_uuid: + return None + sub = await subscription_dal.get_active_subscription_by_user_id( + session, user_id, db_user.panel_user_uuid + ) + if not sub: + return None + + tariff = self._resolve_tariff(sub.tariff_key) if sub.tariff_key else None + purchase_bytes = self.gb_to_bytes(gb_value) + baseline_bytes = int(sub.tier_baseline_bytes or (tariff.monthly_bytes if tariff else 0) or 0) + new_topup_balance = int(sub.topup_balance_bytes or 0) + purchase_bytes + rb = int(getattr(sub, "regular_bonus_bytes", 0) or 0) + runl = bool(getattr(sub, "regular_unlimited_override", False)) + used_for_lim = int(getattr(sub, "traffic_used_bytes", 0) or 0) + new_limit = self._compute_main_traffic_limit_bytes( + tier_baseline_bytes=baseline_bytes, + topup_balance_bytes=new_topup_balance, + regular_bonus_bytes=rb, + regular_unlimited_override=runl, + traffic_used_bytes=used_for_lim, + ) + base_hwid_limit = ( + int(sub.hwid_device_limit) + if sub.hwid_device_limit is not None + else self._base_hwid_limit_for_tariff(tariff) + ) + effective_hwid_limit = self._effective_hwid_limit( + base_hwid_limit, + int(sub.extra_hwid_devices or 0), + ) + updated_sub = await subscription_dal.update_subscription( + session, + sub.subscription_id, + { + "topup_balance_bytes": new_topup_balance, + "traffic_limit_bytes": new_limit, + "is_throttled": False, + "hwid_device_limit": base_hwid_limit, + }, + ) + panel_payload = self._build_panel_update_payload( + panel_user_uuid=db_user.panel_user_uuid, + expire_at=updated_sub.end_date, + status="ACTIVE", + traffic_limit_bytes=new_limit, + hwid_device_limit=effective_hwid_limit, + ) + if tariff is not None: + panel_payload["activeInternalSquads"] = self._panel_squads_for_tariff( + tariff, + include_premium=not bool(getattr(updated_sub, "premium_is_limited", False)), + ) + panel_payload.update(self._panel_identity_payload_for_user(db_user)) + try: + await self.panel_service.update_user_details_on_panel( + db_user.panel_user_uuid, panel_payload + ) + except Exception: + logging.exception( + "admin_grant_topup: failed to push panel update for user %s", user_id + ) + await tariff_dal.create_traffic_topup( + session, + subscription_id=sub.subscription_id, + payment_id=None, + purchased_bytes=purchase_bytes, + kind="admin_topup", + ) + return { + "subscription_id": sub.subscription_id, + "traffic_limit_bytes": new_limit, + "topup_balance_bytes": new_topup_balance, + "granted_bytes": purchase_bytes, + } + + async def sync_main_traffic_limit_to_panel( + self, + session: AsyncSession, + user_id: int, + ) -> None: + """Recompute main traffic limit from tier + topups + regular_bonus_bytes and push to panel.""" + db_user = await user_dal.get_user_by_id(session, user_id) + if not db_user or not db_user.panel_user_uuid: + return + sub = await subscription_dal.get_active_subscription_by_user_id( + session, user_id, db_user.panel_user_uuid + ) + if not sub: + return + tariff = self._resolve_tariff(sub.tariff_key) if sub.tariff_key else None + baseline = int(sub.tier_baseline_bytes or (tariff.monthly_bytes if tariff else 0) or 0) + rb = int(getattr(sub, "regular_bonus_bytes", 0) or 0) + runl = bool(getattr(sub, "regular_unlimited_override", False)) + used_now = int(getattr(sub, "traffic_used_bytes", 0) or 0) + new_limit = self._compute_main_traffic_limit_bytes( + tier_baseline_bytes=baseline, + topup_balance_bytes=int(sub.topup_balance_bytes or 0), + regular_bonus_bytes=rb, + regular_unlimited_override=runl, + traffic_used_bytes=used_now, + ) + sub.traffic_limit_bytes = new_limit + if runl: + sub.is_throttled = False + base_hwid_limit = ( + int(sub.hwid_device_limit) + if sub.hwid_device_limit is not None + else self._base_hwid_limit_for_tariff(tariff) + ) + effective_hwid_limit = self._effective_hwid_limit( + base_hwid_limit, + int(sub.extra_hwid_devices or 0), + ) + panel_payload = self._build_panel_update_payload( + panel_user_uuid=db_user.panel_user_uuid, + expire_at=sub.end_date, + status="ACTIVE", + traffic_limit_bytes=new_limit, + hwid_device_limit=effective_hwid_limit, + ) + if tariff is not None: + panel_payload["activeInternalSquads"] = self._panel_squads_for_tariff( + tariff, + include_premium=not bool(getattr(sub, "premium_is_limited", False)), + ) + panel_payload.update(self._panel_identity_payload_for_user(db_user)) + try: + await self.panel_service.update_user_details_on_panel( + db_user.panel_user_uuid, panel_payload + ) + except Exception: + logging.exception( + "sync_main_traffic_limit_to_panel failed for user %s", user_id + ) + + async def admin_grant_premium_topup( + self, + session: AsyncSession, + user_id: int, + traffic_gb: float, + ) -> Optional[Dict[str, Any]]: + """Credit premium-squad traffic to a user as if they purchased a premium top-up. + + Mirrors :meth:`activate_premium_topup` but skips payment context. + Requires the user's current tariff to expose premium squads. The + balance is absorbed into ``premium_topup_balance_bytes`` (backfilling + any current overuse first), ``premium_is_limited`` is recomputed and, + if access becomes available again, the premium squads are returned to + the user on the panel. The audit row in ``traffic_topups`` is stored + with ``kind="admin_premium_topup"`` and ``payment_id=NULL``. + """ + try: + gb_value = float(traffic_gb) + except (TypeError, ValueError): + logging.error("admin_grant_premium_topup: invalid traffic_gb=%r", traffic_gb) + return None + if gb_value <= 0: + return None + + db_user = await user_dal.get_user_by_id(session, user_id) + if not db_user or not db_user.panel_user_uuid: + return None + sub = await subscription_dal.get_active_subscription_by_user_id( + session, user_id, db_user.panel_user_uuid + ) + if not sub: + return None + tariff = self._resolve_tariff(sub.tariff_key) if sub.tariff_key else None + if not tariff or not tariff.premium_squad_uuids: + logging.error( + "admin_grant_premium_topup: tariff %s has no premium squads (user %s)", + getattr(tariff, "key", None), + user_id, + ) + return None + + purchase_bytes = self.gb_to_bytes(gb_value) + now = datetime.now(timezone.utc) + premium_period_start = month_start(now) + current_period_start = getattr(sub, "premium_period_start_at", None) + same_period = bool(current_period_start and current_period_start == premium_period_start) + previous_topup_used = int(sub.premium_topup_used_bytes or 0) if same_period else 0 + premium_used = int(sub.premium_used_bytes or 0) if same_period else 0 + premium_baseline = int(tariff.premium_monthly_bytes or sub.premium_baseline_bytes or 0) + premium_bonus = max(0, int(getattr(sub, "premium_bonus_bytes", 0) or 0)) + premium_topup_balance = int(sub.premium_topup_balance_bytes or 0) + purchase_bytes + overflow_to_cover = max( + 0, premium_used - premium_baseline - previous_topup_used - premium_bonus + ) + consume_now = min(premium_topup_balance, overflow_to_cover) + premium_topup_balance -= consume_now + premium_topup_used = previous_topup_used + consume_now + premium_limit = self._premium_effective_limit_bytes( + premium_baseline, + premium_topup_balance, + premium_topup_used, + premium_bonus, + ) + premium_unlimited = bool(getattr(sub, "premium_unlimited_override", False)) + premium_is_limited = ( + not premium_unlimited and premium_limit > 0 and premium_used >= premium_limit + ) + + updated_sub = await subscription_dal.update_subscription( + session, + sub.subscription_id, + { + "premium_baseline_bytes": premium_baseline, + "premium_topup_balance_bytes": premium_topup_balance, + "premium_topup_used_bytes": premium_topup_used, + "premium_used_bytes": premium_used, + "premium_is_limited": premium_is_limited, + "premium_period_start_at": premium_period_start, + }, + ) + panel_payload = { + "uuid": db_user.panel_user_uuid, + "activeInternalSquads": self._panel_squads_for_tariff( + tariff, + include_premium=not premium_is_limited, + ), + } + try: + await self.panel_service.update_user_details_on_panel( + db_user.panel_user_uuid, panel_payload + ) + except Exception: + logging.exception( + "admin_grant_premium_topup: failed to push panel update for user %s", + user_id, + ) + await tariff_dal.create_traffic_topup( + session, + subscription_id=sub.subscription_id, + payment_id=None, + purchased_bytes=purchase_bytes, + kind="admin_premium_topup", + ) + return { + "subscription_id": sub.subscription_id, + "premium_limit_bytes": premium_limit, + "premium_topup_balance_bytes": premium_topup_balance, + "premium_topup_used_bytes": premium_topup_used, + "premium_is_limited": premium_is_limited, + "granted_bytes": purchase_bytes, + } + async def activate_hwid_device_topup( self, session: AsyncSession, @@ -1272,7 +1592,16 @@ class SubscriptionService: if target.billing_model == "period": update_data["tier_baseline_bytes"] = target.monthly_bytes - update_data["traffic_limit_bytes"] = target.monthly_bytes + int(sub.topup_balance_bytes or 0) + rb = int(getattr(sub, "regular_bonus_bytes", 0) or 0) + runl = bool(getattr(sub, "regular_unlimited_override", False)) + used_sub = int(sub.traffic_used_bytes or 0) + update_data["traffic_limit_bytes"] = self._compute_main_traffic_limit_bytes( + tier_baseline_bytes=target.monthly_bytes, + topup_balance_bytes=int(sub.topup_balance_bytes or 0), + regular_bonus_bytes=rb, + regular_unlimited_override=runl, + traffic_used_bytes=used_sub, + ) update_data["period_start_at"] = None update_data["effective_monthly_price_rub"] = target.period_price(1, "rub") or target.min_period_price_rub() if mode == "recalc_days" and options.get("recalc_days") is not None: @@ -1282,15 +1611,24 @@ class SubscriptionService: converted_bytes = self.gb_to_bytes(converted_gb) old_topup = int(sub.topup_balance_bytes or 0) new_balance = old_topup + converted_bytes + rb = int(getattr(sub, "regular_bonus_bytes", 0) or 0) + runl = bool(getattr(sub, "regular_unlimited_override", False)) panel_user = await self.panel_service.get_user_by_uuid(db_user.panel_user_uuid, log_response=False) or {} current_used, _, _ = self._extract_panel_traffic_details(panel_user) + cur_used_int = int(current_used or 0) update_data.update( { "end_date": self._far_future(), "period_start_at": None, "tier_baseline_bytes": 0, "topup_balance_bytes": new_balance, - "traffic_limit_bytes": int(current_used or 0) + new_balance, + "traffic_limit_bytes": self._compute_main_traffic_limit_bytes( + tier_baseline_bytes=0, + topup_balance_bytes=new_balance, + regular_bonus_bytes=rb, + regular_unlimited_override=runl, + traffic_used_bytes=cur_used_int, + ), "traffic_used_bytes": current_used, "effective_monthly_price_rub": None, "auto_renew_enabled": False, @@ -1575,7 +1913,15 @@ class SubscriptionService: premium_topup_used_bytes, ) effective_monthly_price = float(payment_amount) / max(1, months_int) - traffic_limit_bytes = self._traffic_limit_for_period_tariff(tariff, topup_balance_bytes) + regular_bonus_carry = int(getattr(current_active_sub, "regular_bonus_bytes", 0) or 0) + regular_unl_carry = bool(getattr(current_active_sub, "regular_unlimited_override", False)) + traffic_limit_bytes = self._traffic_limit_for_period_tariff( + tariff, + topup_balance_bytes, + regular_bonus_carry, + regular_unlimited_override=regular_unl_carry, + traffic_used_bytes=0, + ) base_hwid_limit = self._base_hwid_limit_for_tariff(tariff) effective_hwid_limit = self._effective_hwid_limit(base_hwid_limit, extra_hwid_devices) premium_is_limited = bool(premium_limit_bytes > 0 and premium_used_bytes >= premium_limit_bytes) @@ -1595,6 +1941,8 @@ class SubscriptionService: "tariff_key": tariff.key if tariff else None, "tier_baseline_bytes": tier_baseline_bytes, "topup_balance_bytes": topup_balance_bytes, + "regular_bonus_bytes": regular_bonus_carry, + "regular_unlimited_override": regular_unl_carry, "premium_baseline_bytes": premium_baseline_bytes, "premium_topup_balance_bytes": premium_topup_balance_bytes, "premium_topup_used_bytes": premium_topup_used_bytes, @@ -1905,6 +2253,10 @@ class SubscriptionService: premium_baseline = int(local_active_sub.premium_baseline_bytes or 0) if local_active_sub else 0 premium_topup_balance = int(local_active_sub.premium_topup_balance_bytes or 0) if local_active_sub else 0 premium_topup_used = int(getattr(local_active_sub, "premium_topup_used_bytes", 0) or 0) if local_active_sub else 0 + premium_bonus_bytes = int(getattr(local_active_sub, "premium_bonus_bytes", 0) or 0) if local_active_sub else 0 + premium_unlimited_override = bool(getattr(local_active_sub, "premium_unlimited_override", False)) if local_active_sub else False + regular_bonus_bytes = int(getattr(local_active_sub, "regular_bonus_bytes", 0) or 0) if local_active_sub else 0 + regular_unlimited_override = bool(getattr(local_active_sub, "regular_unlimited_override", False)) if local_active_sub else False return { "user_id": panel_user_data.get("uuid"), @@ -1922,14 +2274,19 @@ class SubscriptionService: "billing_model": billing_model_display, "tier_baseline_bytes": local_active_sub.tier_baseline_bytes if local_active_sub else None, "topup_balance_bytes": local_active_sub.topup_balance_bytes if local_active_sub else 0, + "regular_bonus_bytes": regular_bonus_bytes, + "regular_unlimited_override": regular_unlimited_override, "premium_baseline_bytes": premium_baseline, "premium_topup_balance_bytes": premium_topup_balance, "premium_topup_used_bytes": premium_topup_used, "premium_used_bytes": local_active_sub.premium_used_bytes if local_active_sub else 0, + "premium_bonus_bytes": premium_bonus_bytes, + "premium_unlimited_override": premium_unlimited_override, "premium_limit_bytes": self._premium_effective_limit_bytes( premium_baseline, premium_topup_balance, premium_topup_used, + premium_bonus_bytes, ), "premium_is_limited": bool(local_active_sub.premium_is_limited) if local_active_sub else False, "premium_period_start_at": getattr(local_active_sub, "premium_period_start_at", None) if local_active_sub else None, diff --git a/bot/services/tariff_worker.py b/bot/services/tariff_worker.py index 0e43166..1f24ea3 100644 --- a/bot/services/tariff_worker.py +++ b/bot/services/tariff_worker.py @@ -4,7 +4,8 @@ from datetime import datetime, timezone from typing import Optional from aiogram import Bot -from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup +from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup, WebAppInfo +from aiogram.utils.text_decorations import html_decoration as hd from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import sessionmaker @@ -14,10 +15,13 @@ from bot.services.panel_api_service import PanelApiService from bot.services.subscription_service import SubscriptionService from bot.utils.date_utils import month_start from config.settings import Settings -from db.dal import subscription_dal, tariff_dal +from bot.utils.mini_app_url import subscription_mini_app_topup_url +from db.dal import subscription_dal, tariff_dal, user_dal from db.models import Subscription PREMIUM_WARNING_LEVEL_OFFSET = 1000 +# Single warning per premium billing period when usage reached or exceeded the quota. +PREMIUM_WARNING_DEPLETED_LEVEL = PREMIUM_WARNING_LEVEL_OFFSET + 100 class TariffTrafficWorker: @@ -39,6 +43,47 @@ class TariffTrafficWorker: self._stopped = asyncio.Event() self._premium_nodes_cache = {} + async def _user_lang(self, session: AsyncSession, user_id: int) -> str: + try: + row = await user_dal.get_user_by_id(session, user_id) + if row and getattr(row, "language_code", None): + code = str(row.language_code or "").strip() + if code: + return code + except Exception: + logging.exception("TariffTrafficWorker: failed to load user language for %s", user_id) + return self.settings.DEFAULT_LANGUAGE + + def _usage_placeholders(self, used_bytes: int, limit_bytes: int) -> dict: + """Formatted traffic stats for warning messages (HTML-safe quoted).""" + used_b = max(0, int(used_bytes or 0)) + lim_b = max(0, int(limit_bytes or 0)) + remaining_b = max(0, lim_b - used_b) + return { + "used": hd.quote(self._fmt_bytes(used_b)), + "remaining": hd.quote(self._fmt_bytes(remaining_b)), + "limit_total": hd.quote(self._fmt_bytes(lim_b)), + } + + def _traffic_topup_markup(self, user_lang: str, kind: str) -> Optional[InlineKeyboardMarkup]: + if not self.bot: + return None + _ = lambda k, **kw: self.i18n.gettext(user_lang, k, **kw) if self.i18n else (lambda key, **_: key) + normalized = "premium" if str(kind or "").lower() == "premium" else "regular" + url = subscription_mini_app_topup_url(self.settings, normalized) + if normalized == "premium": + label_key = "traffic_warn_btn_topup_webapp_premium" + fallback_key = "traffic_warn_btn_topup_premium" + else: + label_key = "traffic_warn_btn_topup_webapp_regular" + fallback_key = "traffic_warn_btn_topup_regular" + # Mini App inside Telegram when SUBSCRIPTION_MINI_APP_URL is configured. + if url: + button = InlineKeyboardButton(text=_(label_key), web_app=WebAppInfo(url=url)) + else: + button = InlineKeyboardButton(text=_(fallback_key), callback_data="tariff_topup:list") + return InlineKeyboardMarkup(inline_keyboard=[[button]]) + async def run(self) -> None: if not self.settings.tariffs_config: return @@ -108,7 +153,22 @@ class TariffTrafficWorker: ) -> None: if str(panel_strategy or "").upper() == "MONTH": return - traffic_limit_bytes = int(limit or sub.traffic_limit_bytes or (tariff.monthly_bytes + int(sub.topup_balance_bytes or 0))) + rb = int(getattr(sub, "regular_bonus_bytes", 0) or 0) + if bool(getattr(sub, "regular_unlimited_override", False)): + baseline = int(sub.tier_baseline_bytes or (tariff.monthly_bytes if tariff else 0) or 0) + traffic_limit_bytes = self.subscription_service._compute_main_traffic_limit_bytes( + tier_baseline_bytes=baseline, + topup_balance_bytes=int(sub.topup_balance_bytes or 0), + regular_bonus_bytes=rb, + regular_unlimited_override=True, + traffic_used_bytes=int(sub.traffic_used_bytes or 0), + ) + else: + traffic_limit_bytes = int( + limit + or sub.traffic_limit_bytes + or (tariff.monthly_bytes + int(sub.topup_balance_bytes or 0) + rb) + ) payload = self.subscription_service._build_panel_update_payload( panel_user_uuid=sub.panel_user_uuid, expire_at=sub.end_date, @@ -131,6 +191,8 @@ class TariffTrafficWorker: *, warning_period_start: Optional[datetime] = None, ) -> None: + if bool(getattr(sub, "regular_unlimited_override", False)): + return used_val = int(used or sub.traffic_used_bytes or 0) limit_val = int(limit or sub.traffic_limit_bytes or 0) if limit_val <= 0: @@ -159,22 +221,35 @@ class TariffTrafficWorker: ) if self.bot: try: + user_lang = await self._user_lang(session, sub.user_id) + _ = ( + (lambda k, **kw: self.i18n.gettext(user_lang, k, **kw)) + if self.i18n + else (lambda k, **kw: k) + ) left_pct = max(0, 100 - level) + tariff_name = hd.quote(str(tariff.name(user_lang))) + usage = self._usage_placeholders(used_val, limit_val) if level < 100: - text = f"Трафик тарифа {tariff.name(self.settings.DEFAULT_LANGUAGE)} почти закончился. Осталось около {left_pct}%." + text = _( + "traffic_warning_regular_almost", + tariff_name=tariff_name, + left_pct=left_pct, + **usage, + ) else: - text = "Трафик закончился. Доступ временно ограничен до сброса или докупки пакета." - markup = InlineKeyboardMarkup( - inline_keyboard=[ - [ - InlineKeyboardButton( - text="Докупить трафик", - callback_data="tariff_topup:list", - ) - ] - ] + text = _( + "traffic_warning_regular_depleted", + tariff_name=tariff_name, + **usage, + ) + markup = self._traffic_topup_markup(user_lang, "regular") + await self.bot.send_message( + sub.user_id, + text, + reply_markup=markup, + parse_mode="HTML", ) - await self.bot.send_message(sub.user_id, text, reply_markup=markup) except Exception: logging.exception("Failed to send traffic warning to user %s", sub.user_id) if ratio >= 1.0 and not sub.is_throttled: @@ -214,8 +289,11 @@ class TariffTrafficWorker: premium_baseline = int(tariff.premium_monthly_bytes or 0) premium_topup_balance = int(sub.premium_topup_balance_bytes or 0) premium_topup_used = int(getattr(sub, "premium_topup_used_bytes", 0) or 0) if same_period else 0 - premium_limit = premium_baseline + premium_topup_balance + premium_topup_used - if premium_limit <= 0: + # Admin-side overrides for free gifted premium traffic. + premium_unlimited_override = bool(getattr(sub, "premium_unlimited_override", False)) + premium_bonus = max(0, int(getattr(sub, "premium_bonus_bytes", 0) or 0)) + premium_limit = premium_baseline + premium_topup_balance + premium_topup_used + premium_bonus + if premium_limit <= 0 and not premium_unlimited_override: return node_uuids = await self._premium_node_uuids_for_tariff(tariff) @@ -235,15 +313,22 @@ class TariffTrafficWorker: if premium_used is None: return - overflow = max(0, int(premium_used) - premium_baseline) + # Consume paid top-up balance only for overflow beyond baseline+bonus. + # Admin-granted bonus is "spent" against usage first along with baseline, + # so the user's paid top-up survives longer. + free_quota = premium_baseline + premium_bonus + overflow = max(0, int(premium_used) - free_quota) delta_overflow = max(0, overflow - premium_topup_used) consume_from_topup = min(premium_topup_balance, delta_overflow) if consume_from_topup > 0: premium_topup_balance -= consume_from_topup premium_topup_used += consume_from_topup - premium_limit = premium_baseline + premium_topup_balance + premium_topup_used + premium_limit = premium_baseline + premium_topup_balance + premium_topup_used + premium_bonus - should_limit = premium_used >= premium_limit + if premium_unlimited_override: + should_limit = False + else: + should_limit = premium_used >= premium_limit changed = ( int(sub.premium_baseline_bytes or 0) != premium_baseline or int(sub.premium_topup_balance_bytes or 0) != premium_topup_balance @@ -258,14 +343,15 @@ class TariffTrafficWorker: sub.premium_used_bytes = int(premium_used) sub.premium_is_limited = bool(should_limit) sub.premium_period_start_at = premium_period_start - await self._maybe_warn_premium_squad_limit( - session, - sub, - tariff, - premium_used, - premium_limit, - premium_period_start, - ) + if not premium_unlimited_override: + await self._maybe_warn_premium_squad_limit( + session, + sub, + tariff, + premium_used, + premium_limit, + premium_period_start, + ) if not changed: return @@ -307,9 +393,67 @@ class TariffTrafficWorker: ) -> None: if limit <= 0: return - ratio = int(used or 0) / int(limit) + used_val = int(used or 0) + limit_val = int(limit) + ratio = used_val / limit_val levels = list(getattr(self.settings, "tariff_traffic_warning_levels", [85, 90, 95])) + + # Fully exhausted or over quota — one message per period (same idea as regular traffic at 100%). + if ratio >= 1.0: + depleted_existing = await tariff_dal.get_warning( + session, + subscription_id=sub.subscription_id, + period_start_at=period_start_at, + level=PREMIUM_WARNING_DEPLETED_LEVEL, + ) + if depleted_existing: + return + await tariff_dal.create_warning( + session, + subscription_id=sub.subscription_id, + period_start_at=period_start_at, + level=PREMIUM_WARNING_DEPLETED_LEVEL, + traffic_limit_bytes=None, + ) + if self.bot: + try: + user_lang = await self._user_lang(session, sub.user_id) + _ = ( + (lambda k, **kw: self.i18n.gettext(user_lang, k, **kw)) + if self.i18n + else (lambda k, **kw: k) + ) + access = await self.subscription_service.premium_access_for_tariff(tariff) + labels = access.get("node_labels") or access.get("squad_labels") or [] + if labels: + visible = [hd.quote(str(x)) for x in labels[:8]] + servers = "\n".join(f"• {label}" for label in visible) + if len(labels) > len(visible): + more = len(labels) - len(visible) + servers += "\n" + _("traffic_warning_premium_servers_more", count=more) + else: + servers = _("traffic_warning_premium_generic_servers") + usage = self._usage_placeholders(used_val, limit_val) + text = _( + "traffic_warning_premium_depleted", + tariff_name=hd.quote(str(tariff.name(user_lang))), + servers=servers, + **usage, + ) + markup = self._traffic_topup_markup(user_lang, "premium") + await self.bot.send_message( + sub.user_id, + text, + reply_markup=markup, + parse_mode="HTML", + ) + except Exception: + logging.exception("Failed to send premium traffic depleted warning to user %s", sub.user_id) + return + for level in levels: + if level >= 100: + continue if ratio < level / 100: continue storage_level = PREMIUM_WARNING_LEVEL_OFFSET + int(level) @@ -331,34 +475,38 @@ class TariffTrafficWorker: if not self.bot: continue try: + user_lang = await self._user_lang(session, sub.user_id) + _ = ( + (lambda k, **kw: self.i18n.gettext(user_lang, k, **kw)) + if self.i18n + else (lambda k, **kw: k) + ) access = await self.subscription_service.premium_access_for_tariff(tariff) labels = access.get("node_labels") or access.get("squad_labels") or [] if labels: - visible = labels[:8] + visible = [hd.quote(str(x)) for x in labels[:8]] servers = "\n".join(f"• {label}" for label in visible) if len(labels) > len(visible): - servers += f"\n• ... еще {len(labels) - len(visible)}" + more = len(labels) - len(visible) + servers += "\n" + _("traffic_warning_premium_servers_more", count=more) else: - servers = "• premium-серверы тарифа" - text = ( - "⚠️ Отдельный лимит premium-серверов почти закончился.\n\n" - f"Тариф: {tariff.name(self.settings.DEFAULT_LANGUAGE)}\n" - f"Использовано: {self._fmt_bytes(used)} из {self._fmt_bytes(limit)} ({level}%).\n\n" - "Этот лимит действует на:\n" - f"{servers}\n\n" - "Можно докупить premium-трафик. Докупленный остаток переносится на следующие месяцы, пока не израсходуется." + servers = _("traffic_warning_premium_generic_servers") + left_pct = max(0, 100 - int(level)) + usage = self._usage_placeholders(used_val, limit_val) + text = _( + "traffic_warning_premium_almost", + tariff_name=hd.quote(str(tariff.name(user_lang))), + left_pct=left_pct, + servers=servers, + **usage, ) - markup = InlineKeyboardMarkup( - inline_keyboard=[ - [ - InlineKeyboardButton( - text="Докупить premium-трафик", - callback_data="tariff_topup:list", - ) - ] - ] + markup = self._traffic_topup_markup(user_lang, "premium") + await self.bot.send_message( + sub.user_id, + text, + reply_markup=markup, + parse_mode="HTML", ) - await self.bot.send_message(sub.user_id, text, reply_markup=markup) except Exception: logging.exception("Failed to send premium traffic warning to user %s", sub.user_id) diff --git a/bot/states/admin_states.py b/bot/states/admin_states.py index 68b0c4a..ab15a3c 100644 --- a/bot/states/admin_states.py +++ b/bot/states/admin_states.py @@ -29,6 +29,8 @@ class AdminStates(StatesGroup): waiting_for_subscription_days_to_add = State() waiting_for_direct_message_to_user = State() waiting_for_user_delete_confirmation = State() + waiting_for_premium_override_bonus_gb = State() + waiting_for_traffic_grant_gb = State() # Ads campaigns waiting_for_ad_source = State() diff --git a/bot/utils/mini_app_url.py b/bot/utils/mini_app_url.py new file mode 100644 index 0000000..51f51f4 --- /dev/null +++ b/bot/utils/mini_app_url.py @@ -0,0 +1,32 @@ +"""Helpers for Telegram Mini App URLs (subscription webapp).""" +from __future__ import annotations + +from typing import Optional +from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit + +from config.settings import Settings + + +def append_query_params(base_url: str, params: dict[str, str]) -> str: + """Merge params into an existing URL query string (adds or replaces keys).""" + raw = (base_url or "").strip() + if not raw: + return "" + parts = urlsplit(raw) + existing = dict(parse_qsl(parts.query, keep_blank_values=True)) + for key, value in params.items(): + if value is None: + existing.pop(key, None) + else: + existing[key] = str(value) + query = urlencode(existing) + return urlunsplit((parts.scheme, parts.netloc, parts.path, query, parts.fragment)) + + +def subscription_mini_app_topup_url(settings: Settings, kind: str) -> Optional[str]: + """Return Mini App URL that opens the traffic top-up flow for ``kind`` (``regular`` or ``premium``).""" + base = str(getattr(settings, "SUBSCRIPTION_MINI_APP_URL", None) or "").strip() + if not base: + return None + normalized = "premium" if str(kind or "").strip().lower() == "premium" else "regular" + return append_query_params(base, {"topup": normalized}) diff --git a/db/migrator.py b/db/migrator.py index 6eee955..3452fbb 100644 --- a/db/migrator.py +++ b/db/migrator.py @@ -544,6 +544,87 @@ def _migration_0016_add_message_logs_admin_fields(connection: Connection) -> Non ) +def _migration_0018_add_premium_admin_overrides(connection: Connection) -> None: + """Per-subscription overrides letting admins gift extra premium traffic or unlimited access.""" + inspector = inspect(connection) + sub_columns: Set[str] = {col["name"] for col in inspector.get_columns("subscriptions")} + statements: List[str] = [] + if "premium_unlimited_override" not in sub_columns: + statements.append( + "ALTER TABLE subscriptions ADD COLUMN premium_unlimited_override BOOLEAN NOT NULL DEFAULT FALSE" + ) + if "premium_bonus_bytes" not in sub_columns: + statements.append( + "ALTER TABLE subscriptions ADD COLUMN premium_bonus_bytes BIGINT NOT NULL DEFAULT 0" + ) + for stmt in statements: + connection.execute(text(stmt)) + connection.execute( + text( + "CREATE INDEX IF NOT EXISTS ix_subscriptions_premium_unlimited_override " + "ON subscriptions (premium_unlimited_override)" + ) + ) + + +def _migration_0021_add_regular_unlimited_override(connection: Connection) -> None: + inspector = inspect(connection) + sub_columns: Set[str] = {col["name"] for col in inspector.get_columns("subscriptions")} + if "regular_unlimited_override" not in sub_columns: + connection.execute( + text( + "ALTER TABLE subscriptions ADD COLUMN regular_unlimited_override BOOLEAN NOT NULL DEFAULT FALSE" + ) + ) + connection.execute( + text( + "CREATE INDEX IF NOT EXISTS ix_subscriptions_regular_unlimited_override " + "ON subscriptions (regular_unlimited_override)" + ) + ) + + +def _migration_0020_add_regular_bonus_bytes(connection: Connection) -> None: + """Admin-granted extra bytes on main (non-premium) traffic limit, like premium_bonus_bytes.""" + inspector = inspect(connection) + sub_columns: Set[str] = {col["name"] for col in inspector.get_columns("subscriptions")} + if "regular_bonus_bytes" not in sub_columns: + connection.execute( + text( + "ALTER TABLE subscriptions ADD COLUMN regular_bonus_bytes BIGINT NOT NULL DEFAULT 0" + ) + ) + + +def _migration_0019_clear_subscription_months_for_non_subscription_payments(connection: Connection) -> None: + """Null out subscription_duration_months for legacy non-subscription payments. + + Older builds stored the raw `months` callback value into + `subscription_duration_months` for every sale_mode, including traffic + top-ups and HWID device packs. This polluted CSV exports and made admin + log messages render top-ups as "N мес.". The new code only sets the + column for subscription sales; this migration aligns historical rows. + """ + inspector = inspect(connection) + table_names = set(inspector.get_table_names()) + if "payments" not in table_names: + return + pay_columns: Set[str] = {col["name"] for col in inspector.get_columns("payments")} + if "subscription_duration_months" not in pay_columns or "sale_mode" not in pay_columns: + return + connection.execute( + text( + """ + UPDATE payments + SET subscription_duration_months = NULL + WHERE subscription_duration_months IS NOT NULL + AND sale_mode IS NOT NULL + AND split_part(split_part(sale_mode, '@', 1), '|', 1) <> 'subscription' + """ + ) + ) + + def _migration_0017_reconcile_legacy_admin_api_schema(connection: Connection) -> None: """Backfill columns required by admin user detail API on legacy databases. @@ -705,6 +786,26 @@ MIGRATIONS: List[Migration] = [ description="Reconcile legacy DB schema for admin user detail endpoint compatibility", upgrade=_migration_0017_reconcile_legacy_admin_api_schema, ), + Migration( + id="0018_add_premium_admin_overrides", + description="Per-subscription admin overrides for premium traffic (unlimited toggle + bonus bytes)", + upgrade=_migration_0018_add_premium_admin_overrides, + ), + Migration( + id="0019_clear_subscription_months_for_non_subscription_payments", + description="Backfill: null out subscription_duration_months for legacy traffic/topup/hwid payments", + upgrade=_migration_0019_clear_subscription_months_for_non_subscription_payments, + ), + Migration( + id="0020_add_regular_bonus_bytes", + description="Per-subscription admin bonus bytes on regular (main) traffic limit", + upgrade=_migration_0020_add_regular_bonus_bytes, + ), + Migration( + id="0021_add_regular_unlimited_override", + description="Admin toggle for effectively unlimited main traffic limit", + upgrade=_migration_0021_add_regular_unlimited_override, + ), ] diff --git a/db/models.py b/db/models.py index 80cb78a..8615670 100644 --- a/db/models.py +++ b/db/models.py @@ -119,6 +119,10 @@ class Subscription(Base): premium_used_bytes = Column(BigInteger, nullable=False, default=0) premium_is_limited = Column(Boolean, nullable=False, default=False, index=True) premium_period_start_at = Column(DateTime(timezone=True), nullable=True) + premium_unlimited_override = Column(Boolean, nullable=False, default=False, index=True) + premium_bonus_bytes = Column(BigInteger, nullable=False, default=0) + regular_bonus_bytes = Column(BigInteger, nullable=False, default=0) + regular_unlimited_override = Column(Boolean, nullable=False, default=False, index=True) period_start_at = Column(DateTime(timezone=True), nullable=True) is_throttled = Column(Boolean, nullable=False, default=False, index=True) effective_monthly_price_rub = Column(Numeric, nullable=True) diff --git a/locales/en.json b/locales/en.json index 49d609b..ebb70a7 100644 --- a/locales/en.json +++ b/locales/en.json @@ -289,6 +289,38 @@ "admin_user_view_logs_button": "📜 User Actions", "admin_user_refresh_button": "🔄 Refresh", "admin_user_delete_button": "❌ Delete User", + "admin_user_premium_override_button": "⭐ Premium override", + "admin_user_premium_override_label": "🌟 Premium override:", + "admin_user_premium_override_unlimited": "Unlimited", + "admin_user_premium_override_bonus_value": "+{gb} GB", + "admin_premium_override_title": "🌟 Premium override", + "admin_premium_override_hint": "Grant free extra premium traffic or unlimited premium access for this user.", + "admin_premium_override_current": "Current: {current}", + "admin_premium_override_state_unlimited": "unlimited", + "admin_premium_override_state_bonus": "+{gb} GB bonus", + "admin_premium_override_state_none": "not set", + "admin_premium_override_btn_unlimited": "♾ Make unlimited", + "admin_premium_override_btn_bonus": "➕ Set bonus GB", + "admin_premium_override_btn_clear": "🗑 Clear override", + "admin_premium_override_no_subscription": "User has no active subscription", + "admin_premium_override_bonus_prompt": "Enter additional premium GB for user {user_id} (number, decimals allowed):", + "admin_premium_override_invalid_gb": "❌ Invalid value. Enter a non-negative number of GB.", + "admin_premium_override_state_missing": "⚠️ Target user missing in state. Open the user card again.", + "admin_premium_override_bonus_set": "✅ Premium override: +{gb} GB for user {user_id}", + "admin_premium_override_saved": "✅ Premium override saved", + "admin_premium_override_save_error": "❌ Failed to save premium override", + "admin_user_traffic_grant_button": "🎁 Grant GB", + "admin_traffic_grant_title": "🎁 Grant traffic", + "admin_traffic_grant_hint": "Credit GB to the user's balance — same effect as a top-up purchase, but without payment. The panel limit and squads refresh immediately.", + "admin_traffic_grant_btn_regular": "🟢 Regular traffic", + "admin_traffic_grant_btn_premium": "⭐ Premium traffic", + "admin_traffic_grant_prompt_regular": "Enter the amount of regular traffic in GB for user {user_id} (number, decimals allowed):", + "admin_traffic_grant_prompt_premium": "Enter the amount of premium traffic in GB for user {user_id} (number, decimals allowed):", + "admin_traffic_grant_invalid_gb": "❌ Enter a positive number of GB (up to 1,000,000).", + "admin_traffic_grant_no_user": "⚠️ No target user selected. Open the user card again.", + "admin_traffic_grant_failed": "❌ Failed to grant traffic. Make sure the user has an active subscription and the tariff supports premium squads.", + "admin_traffic_grant_regular_done": "✅ +{gb} GB of regular traffic granted to user {user_id}", + "admin_traffic_grant_premium_done": "✅ +{gb} GB of premium traffic granted to user {user_id}", "admin_user_delete_not_allowed": "❌ You are not permitted to delete users.", "admin_user_delete_confirmation_prompt": "⚠️ You are about to delete user {user_id} completely.\n\nSend the exact Telegram ID of this user to confirm.\nAny other reply will cancel the operation.", "admin_user_delete_state_missing": "⚠️ No active delete operation. Start again.", @@ -340,7 +372,21 @@ "log_account_email_linked": "📧 Email linked\n\n🆔 User ID: {user_id}\n📨 Telegram ID: {telegram_id}\n👤 User: {user_display}\n📧 Email: {email}\n🕐 Time: {timestamp}", "log_account_telegram_linked": "📨 Telegram linked\n\n🆔 User ID: {user_id}\n📨 Telegram ID: {telegram_id}\n👤 User: {user_display}\n📧 Email: {email}\n🕐 Time: {timestamp}", "log_payment_received": "{provider_emoji} Payment Received\n\n👤 User: {user_display}\n💰 Amount: {amount} {currency}\n📅 Period: {months} mo.\n🏦 Provider: {payment_provider}\n🕐 Time: {timestamp}", - "log_payment_received_traffic": "{provider_emoji} Payment Received\n\n👤 User: {user_display}\n💰 Amount: {amount} {currency}\n🗂 Traffic: {traffic_gb} GB\n🏦 Provider: {payment_provider}\n🕐 Time: {timestamp}", + "log_payment_received_traffic": "{provider_emoji} Payment Received (traffic top-up)\n\n👤 User: {user_display}\n💰 Amount: {amount} {currency}\n🗂 {traffic_summary}\n{tariff_line}🏦 Provider: {payment_provider}\n🕐 Time: {timestamp}", + "log_payment_traffic_purchase_line": "{gb} GB · {kind}", + "log_payment_traffic_kind_regular": "regular traffic", + "log_payment_traffic_kind_premium": "premium traffic", + "log_payment_tariff_line": "📋 Plan: {name}\n", + "traffic_warning_regular_almost": "⚠️ Traffic for plan {tariff_name} is almost used up.\nAbout {left_pct}% remains.\n\nCurrent period:\nused — {used}\navailable — {remaining}\nlimit — {limit_total}\n\nUse the button below to buy more regular traffic in the mini app.", + "traffic_warning_regular_depleted": "⛔️ Traffic for plan {tariff_name} is depleted.\n\nCurrent period:\nused — {used}\navailable — {remaining}\nlimit — {limit_total}\n\nAccess may be limited until the period resets or you top up.\n\nUse the button below to buy traffic.", + "traffic_warning_premium_almost": "⚠️ The premium server quota for plan {tariff_name} is almost used up.\nAbout {left_pct}% remains.\n\nCurrent period:\nused — {used}\navailable — {remaining}\nlimit — {limit_total}\n\nThis limit applies to:\n{servers}\n\nUse the button below to buy premium traffic in the mini app; unused purchased balance carries over to later months.", + "traffic_warning_premium_depleted": "⛔️ The premium server quota for plan {tariff_name} is used up.\n\nCurrent period:\nused — {used}\navailable — {remaining}\nlimit — {limit_total}\n\nThis limit applies to:\n{servers}\n\nAccess may be limited until the period resets or you buy more premium traffic.\n\nUse the button below to top up.", + "traffic_warning_premium_generic_servers": "• servers with premium access under your plan", + "traffic_warning_premium_servers_more": "… and {count} more servers", + "traffic_warn_btn_topup_webapp_regular": "Top up traffic (mini app)", + "traffic_warn_btn_topup_webapp_premium": "Top up premium traffic (mini app)", + "traffic_warn_btn_topup_regular": "Top up traffic", + "traffic_warn_btn_topup_premium": "Top up premium traffic", "log_promo_activation": "🎁 Promo Code Activated\n\n👤 User: {user_display}\n🏷 Code: {promo_code}\n🎯 Bonus: +{bonus_days}d\n🕐 Time: {timestamp}", "log_trial_activation": "🆓 Trial Activated\n\n👤 User: {user_display}\n⏰ Valid until: {end_date}\n🕐 Time: {timestamp}", "log_panel_sync": "{status_emoji} Panel Synchronization\n\n📊 Status: {status}\n👥 Users processed: {users_processed}\n📋 Subscriptions synced: {subs_synced}\n🕐 Time: {timestamp}\n\n📝 Details:\n{details}", @@ -639,6 +685,7 @@ "wa_premium_traffic_title": "Premium servers", "wa_premium_reset_monthly": "Separate monthly limit", "wa_premium_access_limited": "Premium access is temporarily limited", + "wa_premium_unlimited": "Unlimited premium access", "wa_premium_left": "Left", "wa_premium_topup_balance": "Top-up balance", "wa_premium_servers_limited": "Separate limit applies to", @@ -860,6 +907,13 @@ "admin_status": "Status", "admin_date": "Date", "admin_payments_empty": "No payments", + "admin_payments_col_telegram_id": "Telegram ID", + "admin_payments_col_traffic_regular": "Main traffic", + "admin_payments_col_traffic_premium": "Premium traffic", + "admin_payments_col_actions": "", + "admin_payments_open_user": "Open user card", + "admin_payments_desc_traffic_package_regular": "Traffic package {gb} GB (standard)", + "admin_payments_desc_traffic_package_premium": "Traffic package {gb} GB (premium)", "admin_logs_user_filter_placeholder": "Filter by user ID", "admin_apply": "Apply", "admin_reset": "Reset", @@ -988,6 +1042,44 @@ "admin_user_label_main_traffic": "Main Traffic", "admin_user_traffic_left": "Left: {left}", "admin_user_label_premium_squads": "Premium Squads", + "user_override_unlimited_short": "Unlimited", + "user_premium_override_card_title": "Premium traffic", + "user_premium_override_card_hint": "Unlimited access and extra volume for premium squads on top of the tariff.", + "user_regular_override_card_title": "Main traffic", + "user_regular_override_card_hint": "Unlimited-style ceiling and a persistent bonus on the main traffic limit.", + "user_regular_override_save": "Save", + "user_regular_override_status_unlimited": "Current: unlimited", + "regular_override_saved": "Main traffic override saved", + "user_traffic_override_title": "Traffic overrides", + "user_traffic_override_hint": "Extra main or premium traffic limits on top of the tariff; unlimited applies to premium squads only.", + "user_regular_override_bonus": "Extra main traffic, GB", + "user_regular_override_bonus_hint": "Persistent bonus bytes added to the main traffic limit (not a balance top-up).", + "user_regular_override_status_bonus": "Main traffic now: +{gb} GB", + "user_regular_override_status_none": "No main-traffic bonus override", + "regular_override_invalid_bonus": "Invalid GB value for main traffic", + "user_premium_override_title": "Premium override", + "user_premium_override_hint": "Grant free unlimited access or extra premium traffic on top of the tariff limit.", + "user_premium_override_bonus_hint": "Ignored while unlimited premium is enabled.", + "user_premium_override_unlimited": "Unlimited premium", + "user_premium_override_bonus": "Extra premium traffic, GB", + "user_premium_override_save": "Save override", + "user_premium_override_status_unlimited": "Current: unlimited", + "user_premium_override_status_bonus": "Current: +{gb} GB", + "user_premium_override_status_none": "No premium override", + "user_premium_unlimited_value": "∞ (used {used})", + "user_premium_unlimited_hint": "Unlimited (admin override)", + "premium_override_saved": "Premium override saved", + "premium_override_invalid_bonus": "Invalid GB value", + "user_traffic_grant_title": "Grant traffic", + "user_traffic_grant_hint": "Credit GB to the user's balance — same effect as a top-up purchase, but without payment. The panel limit and squads refresh immediately.", + "user_traffic_grant_kind": "Traffic kind", + "user_traffic_grant_kind_regular": "Regular", + "user_traffic_grant_kind_premium": "Premium", + "user_traffic_grant_gb": "GB to grant", + "user_traffic_grant_submit": "Grant", + "traffic_grant_invalid_gb": "Enter a positive number of GB", + "traffic_grant_regular_done": "+{gb} GB of regular traffic granted", + "traffic_grant_premium_done": "+{gb} GB of premium traffic granted", "admin_user_no_active_subscription": "No active subscription", "admin_user_history_title": "Subscription History · {count}", "admin_user_history_no_tariff": "No tariff", diff --git a/locales/ru.json b/locales/ru.json index aee7f56..fcd135d 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -289,6 +289,38 @@ "admin_user_view_logs_button": "📜 Логи", "admin_user_refresh_button": "🔄 Обновить", "admin_user_delete_button": "❌ Удалить пользователя", + "admin_user_premium_override_button": "⭐ Премиум-оверрайд", + "admin_user_premium_override_label": "🌟 Премиум-оверрайд:", + "admin_user_premium_override_unlimited": "Безлимит", + "admin_user_premium_override_bonus_value": "+{gb} GB", + "admin_premium_override_title": "🌟 Премиум-оверрайд", + "admin_premium_override_hint": "Бесплатно увеличьте лимит премиум-трафика или сделайте его безлимитным для этого пользователя.", + "admin_premium_override_current": "Сейчас: {current}", + "admin_premium_override_state_unlimited": "безлимит", + "admin_premium_override_state_bonus": "+{gb} GB бонусом", + "admin_premium_override_state_none": "не задан", + "admin_premium_override_btn_unlimited": "♾ Включить безлимит", + "admin_premium_override_btn_bonus": "➕ Задать бонусные GB", + "admin_premium_override_btn_clear": "🗑 Снять оверрайд", + "admin_premium_override_no_subscription": "У пользователя нет активной подписки", + "admin_premium_override_bonus_prompt": "Введите количество дополнительных премиум-GB для пользователя {user_id} (число, можно дробное):", + "admin_premium_override_invalid_gb": "❌ Неверное значение. Введите неотрицательное число GB.", + "admin_premium_override_state_missing": "⚠️ Целевой пользователь не найден в состоянии. Откройте карточку пользователя заново.", + "admin_premium_override_bonus_set": "✅ Премиум-оверрайд: +{gb} GB для пользователя {user_id}", + "admin_premium_override_saved": "✅ Премиум-оверрайд сохранён", + "admin_premium_override_save_error": "❌ Не удалось сохранить премиум-оверрайд", + "admin_user_traffic_grant_button": "🎁 Выдать ГБ", + "admin_traffic_grant_title": "🎁 Выдать трафик", + "admin_traffic_grant_hint": "Зачисление ГБ на баланс пользователя — как при докупке, но без оплаты. Лимит и сквады в панели обновятся сразу.", + "admin_traffic_grant_btn_regular": "🟢 Обычный трафик", + "admin_traffic_grant_btn_premium": "⭐ Премиум-трафик", + "admin_traffic_grant_prompt_regular": "Введите количество ГБ обычного трафика для пользователя {user_id} (число, можно дробное):", + "admin_traffic_grant_prompt_premium": "Введите количество ГБ премиум-трафика для пользователя {user_id} (число, можно дробное):", + "admin_traffic_grant_invalid_gb": "❌ Введите положительное число ГБ (до 1 000 000).", + "admin_traffic_grant_no_user": "⚠️ Целевой пользователь не выбран. Откройте карточку заново.", + "admin_traffic_grant_failed": "❌ Не удалось выдать трафик. Проверьте, что у пользователя активная подписка и тариф поддерживает премиум-сквады.", + "admin_traffic_grant_regular_done": "✅ +{gb} ГБ обычного трафика выдано пользователю {user_id}", + "admin_traffic_grant_premium_done": "✅ +{gb} ГБ премиум-трафика выдано пользователю {user_id}", "admin_user_delete_not_allowed": "❌ У вас нет прав для удаления пользователей.", "admin_user_delete_confirmation_prompt": "⚠️ Вы хотите полностью удалить пользователя {user_id}.\n\nОтправьте точный Telegram ID этого пользователя, чтобы подтвердить удаление.\nЛюбой другой ответ отменит операцию.", "admin_user_delete_state_missing": "⚠️ Нет активной операции удаления. Начните заново.", @@ -340,7 +372,21 @@ "log_account_email_linked": "📧 Привязана почта\n\n🆔 ID пользователя: {user_id}\n📨 Telegram ID: {telegram_id}\n👤 Пользователь: {user_display}\n📧 Email: {email}\n🕐 Время: {timestamp}", "log_account_telegram_linked": "📨 Привязан Telegram\n\n🆔 ID пользователя: {user_id}\n📨 Telegram ID: {telegram_id}\n👤 Пользователь: {user_display}\n📧 Email: {email}\n🕐 Время: {timestamp}", "log_payment_received": "{provider_emoji} Получен платеж\n\n👤 Пользователь: {user_display}\n💰 Сумма: {amount} {currency}\n📅 Период: {months} мес.\n🏦 Провайдер: {payment_provider}\n🕐 Время: {timestamp}", - "log_payment_received_traffic": "{provider_emoji} Получен платеж\n\n👤 Пользователь: {user_display}\n💰 Сумма: {amount} {currency}\n🗂 Трафик: {traffic_gb} ГБ\n🏦 Провайдер: {payment_provider}\n🕐 Время: {timestamp}", + "log_payment_received_traffic": "{provider_emoji} Получен платеж (докупка трафика)\n\n👤 Пользователь: {user_display}\n💰 Сумма: {amount} {currency}\n🗂 {traffic_summary}\n{tariff_line}🏦 Провайдер: {payment_provider}\n🕐 Время: {timestamp}", + "log_payment_traffic_purchase_line": "{gb} ГБ · {kind}", + "log_payment_traffic_kind_regular": "основной трафик", + "log_payment_traffic_kind_premium": "премиум-трафик", + "log_payment_tariff_line": "📋 Тариф: {name}\n", + "traffic_warning_regular_almost": "⚠️ Трафик тарифа {tariff_name} почти израсходован.\nОсталось примерно {left_pct}%.\n\nТекущий период:\nизрасходовано — {used}\nдоступно — {remaining}\nлимит — {limit_total}\n\nДокупить основной трафик можно через кнопку ниже.", + "traffic_warning_regular_depleted": "⛔️ Трафик тарифа {tariff_name} израсходован.\n\nТекущий период:\nизрасходовано — {used}\nдоступно — {remaining}\nлимит — {limit_total}\n\nДоступ может быть ограничен до сброса периода или докупки пакета.\n\nДокупить трафик можно через кнопку ниже.", + "traffic_warning_premium_almost": "⚠️ Лимит премиум-серверов тарифа {tariff_name} почти израсходован.\nОсталось примерно {left_pct}%.\n\nТекущий период:\nизрасходовано — {used}\nдоступно — {remaining}\nлимит — {limit_total}\n\nЛимит действует на:\n{servers}\n\nДокупить премиум-трафик можно через кнопку ниже; неизрасходованный остаток переносится на следующие месяцы.", + "traffic_warning_premium_depleted": "⛔️ Лимит премиум-серверов тарифа {tariff_name} израсходован.\n\nТекущий период:\nизрасходовано — {used}\nдоступно — {remaining}\nлимит — {limit_total}\n\nЛимит действует на:\n{servers}\n\nДоступ может быть ограничен до сброса периода или докупки премиум-трафика.\n\nДокупить можно через кнопку ниже.", + "traffic_warning_premium_generic_servers": "• серверы с премиум-доступом по вашему тарифу", + "traffic_warning_premium_servers_more": "… и ещё серверов: {count}", + "traffic_warn_btn_topup_webapp_regular": "Докупить трафик (мини-приложение)", + "traffic_warn_btn_topup_webapp_premium": "Докупить премиум-трафик (мини-приложение)", + "traffic_warn_btn_topup_regular": "Докупить трафик", + "traffic_warn_btn_topup_premium": "Докупить премиум-трафик", "log_promo_activation": "🎁 Активирован промокод\n\n👤 Пользователь: {user_display}\n🏷 Код: {promo_code}\n🎯 Бонус: +{bonus_days} дн.\n🕐 Время: {timestamp}", "log_trial_activation": "🆓 Активирован триал\n\n👤 Пользователь: {user_display}\n⏰ Действует до: {end_date}\n🕐 Время: {timestamp}", "log_panel_sync": "{status_emoji} Синхронизация с панелью\n\n📊 Статус: {status}\n👥 Обработано пользователей: {users_processed}\n📋 Синхронизировано подписок: {subs_synced}\n🕐 Время: {timestamp}\n\n📝 Детали:\n{details}", @@ -639,6 +685,7 @@ "wa_premium_traffic_title": "Premium-серверы", "wa_premium_reset_monthly": "Отдельный лимит на месяц", "wa_premium_access_limited": "Доступ к premium временно ограничен", + "wa_premium_unlimited": "Безлимит на premium-сервера", "wa_premium_left": "Осталось", "wa_premium_topup_balance": "Докупленный остаток", "wa_premium_servers_limited": "Отдельный лимит действует на", @@ -860,6 +907,13 @@ "admin_status": "Статус", "admin_date": "Дата", "admin_payments_empty": "Нет платежей", + "admin_payments_col_telegram_id": "Telegram ID", + "admin_payments_col_traffic_regular": "Основной трафик", + "admin_payments_col_traffic_premium": "Премиум трафик", + "admin_payments_col_actions": "", + "admin_payments_open_user": "Открыть карточку пользователя", + "admin_payments_desc_traffic_package_regular": "Пакет трафика {gb} ГБ (обычный)", + "admin_payments_desc_traffic_package_premium": "Пакет трафика {gb} ГБ (премиум)", "admin_logs_user_filter_placeholder": "Фильтр по ID пользователя", "admin_apply": "Применить", "admin_reset": "Сбросить", @@ -988,6 +1042,44 @@ "admin_user_label_main_traffic": "Основной трафик", "admin_user_traffic_left": "Осталось: {left}", "admin_user_label_premium_squads": "Premium-сквады", + "user_override_unlimited_short": "Безлимит", + "user_premium_override_card_title": "Премиум-трафик", + "user_premium_override_card_hint": "Безлимит и дополнительный объём для премиум-сквадов поверх тарифа.", + "user_regular_override_card_title": "Основной трафик", + "user_regular_override_card_hint": "Режим безлимита и постоянный бонус к лимиту основного трафика.", + "user_regular_override_save": "Сохранить", + "user_regular_override_status_unlimited": "Сейчас: безлимит", + "regular_override_saved": "Оверрайд основного трафика сохранён", + "user_traffic_override_title": "Оверрайд трафика", + "user_traffic_override_hint": "Дополнительный лимит основного и премиум-трафика поверх тарифа; безлимит только для премиум-сквадов.", + "user_regular_override_bonus": "Доп. основной трафик, GB", + "user_regular_override_bonus_hint": "Постоянный бонус к лимиту основного трафика (не то же самое, что докупка ГБ).", + "user_regular_override_status_bonus": "Основной сейчас: +{gb} GB", + "user_regular_override_status_none": "Бонус основного трафика не задан", + "regular_override_invalid_bonus": "Некорректное значение GB для основного трафика", + "user_premium_override_title": "Премиум-оверрайд", + "user_premium_override_hint": "Подарите пользователю бесплатный безлимит или дополнительный премиум-трафик сверх лимита тарифа.", + "user_premium_override_bonus_hint": "Не используется, пока включён безлимит на премиум.", + "user_premium_override_unlimited": "Безлимит на премиум", + "user_premium_override_bonus": "Доп. премиум-трафик, GB", + "user_premium_override_save": "Сохранить оверрайд", + "user_premium_override_status_unlimited": "Сейчас: безлимит", + "user_premium_override_status_bonus": "Сейчас: +{gb} GB", + "user_premium_override_status_none": "Премиум-оверрайд не задан", + "user_premium_unlimited_value": "∞ (использовано {used})", + "user_premium_unlimited_hint": "Безлимит (админ-оверрайд)", + "premium_override_saved": "Премиум-оверрайд сохранён", + "premium_override_invalid_bonus": "Некорректное значение GB", + "user_traffic_grant_title": "Выдать трафик", + "user_traffic_grant_hint": "Зачисление ГБ на баланс пользователя — как при докупке, но без оплаты. Лимит и сквады в панели обновятся сразу.", + "user_traffic_grant_kind": "Тип трафика", + "user_traffic_grant_kind_regular": "Обычный", + "user_traffic_grant_kind_premium": "Премиум", + "user_traffic_grant_gb": "ГБ к выдаче", + "user_traffic_grant_submit": "Выдать", + "traffic_grant_invalid_gb": "Введите положительное число ГБ", + "traffic_grant_regular_done": "+{gb} ГБ обычного трафика выдано", + "traffic_grant_premium_done": "+{gb} ГБ премиум-трафика выдано", "admin_user_no_active_subscription": "Активной подписки нет", "admin_user_history_title": "История подписок · {count}", "admin_user_history_no_tariff": "Без тарифа", diff --git a/scripts/send_premium_traffic_warning_test.py b/scripts/send_premium_traffic_warning_test.py new file mode 100644 index 0000000..a57b63a --- /dev/null +++ b/scripts/send_premium_traffic_warning_test.py @@ -0,0 +1,116 @@ +"""One-off: send a synthetic premium traffic warning like TariffTrafficWorker (text + top-up button). + +Target chat (first match): + 1) Positional integer: ``python ... [almost|depleted]`` + 2) Environment variable ``TELEGRAM_TEST_CHAT_ID`` + 3) First entry in ``ADMIN_IDS`` from app settings + +No default Telegram ID is embedded in this script. +""" +import asyncio +import os +import sys + +from aiogram import Bot +from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup, WebAppInfo + +from bot.middlewares.i18n import JsonI18n +from bot.utils.mini_app_url import subscription_mini_app_topup_url +from config.settings import Settings + + +def _premium_topup_markup(settings: Settings, i18n: JsonI18n, user_lang: str) -> InlineKeyboardMarkup: + _ = lambda k, **kw: i18n.gettext(user_lang, k, **kw) + url = subscription_mini_app_topup_url(settings, "premium") + if url: + btn = InlineKeyboardButton( + text=_("traffic_warn_btn_topup_webapp_premium"), + web_app=WebAppInfo(url=url), + ) + else: + btn = InlineKeyboardButton( + text=_("traffic_warn_btn_topup_premium"), + callback_data="tariff_topup:list", + ) + return InlineKeyboardMarkup(inline_keyboard=[[btn]]) + + +def _resolve_target_chat_id(settings: Settings) -> int: + raw = (os.environ.get("TELEGRAM_TEST_CHAT_ID") or "").strip() + if raw: + try: + return int(raw) + except ValueError as exc: + raise SystemExit(f"TELEGRAM_TEST_CHAT_ID must be an integer, got {raw!r}") from exc + admins = getattr(settings, "ADMIN_IDS", None) or [] + if not admins: + raise SystemExit( + "Set TELEGRAM_TEST_CHAT_ID or configure ADMIN_IDS in the environment, " + "or pass a numeric Telegram user id as the first argument." + ) + return int(admins[0]) + + +def _resolve_chat_id_and_mode(settings: Settings) -> tuple[int, str]: + args = [a.strip() for a in sys.argv[1:] if a.strip()] + if not args: + return _resolve_target_chat_id(settings), "almost" + if args[0] in ("almost", "depleted"): + return _resolve_target_chat_id(settings), args[0] + try: + chat_id = int(args[0]) + except ValueError as exc: + raise SystemExit( + "First argument must be a Telegram user id (integer) or a mode: almost | depleted.\n" + "Examples:\n" + " python ... 123456789\n" + " python ... 123456789 depleted\n" + " python ... depleted\n" + " TELEGRAM_TEST_CHAT_ID=123456789 python ..." + ) from exc + mode = args[1] if len(args) > 1 else "almost" + if mode not in ("almost", "depleted"): + mode = "almost" + return chat_id, mode + + +async def main() -> None: + settings = Settings() + chat_id, mode = _resolve_chat_id_and_mode(settings) + default = (settings.DEFAULT_LANGUAGE or "ru").split("-", 1)[0] + i18n = JsonI18n(path="locales", default=default) + lang = default if default in i18n.locales_data else "ru" + servers = "• EU-FR-01\n• EU-DE-02" + usage = {"used": "4.1 GB", "remaining": "0.9 GB", "limit_total": "5.0 GB"} + if mode == "depleted": + body = i18n.gettext( + lang, + "traffic_warning_premium_depleted", + tariff_name="Стандарт", + servers=servers, + **usage, + ) + else: + body = i18n.gettext( + lang, + "traffic_warning_premium_almost", + tariff_name="Стандарт", + left_pct=10, + servers=servers, + **usage, + ) + markup = _premium_topup_markup(settings, i18n, lang) + bot = Bot(token=settings.BOT_TOKEN) + try: + await bot.send_message( + chat_id, + body, + parse_mode="HTML", + reply_markup=markup, + ) + finally: + await bot.session.close() + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/tests/test_admin_traffic_grants.py b/tests/test_admin_traffic_grants.py new file mode 100644 index 0000000..eeee356 --- /dev/null +++ b/tests/test_admin_traffic_grants.py @@ -0,0 +1,200 @@ +"""Unit tests for admin-driven traffic grants on SubscriptionService. + +These tests cover the new ``admin_grant_topup`` and ``admin_grant_premium_topup`` +methods that let admins credit GB to a user as if they had purchased a top-up. +The DAL and panel-service interactions are mocked so the tests stay hermetic. +""" + +import json +import tempfile +import unittest +from datetime import datetime, timedelta, timezone +from pathlib import Path +from types import SimpleNamespace +from unittest.mock import AsyncMock, patch + +from bot.services.panel_api_service import PanelApiService +from bot.services.subscription_service import SubscriptionService +from config.settings import Settings + + +def _tariffs_config_payload(premium: bool = False) -> dict: + tariff = { + "key": "standard", + "names": {"ru": "Стандарт"}, + "descriptions": {"ru": "Base"}, + "squad_uuids": ["squad-1"], + "billing_model": "period", + "monthly_gb": 100, + "prices_rub": {"1": 150}, + "prices_stars": {"1": 0}, + "enabled_periods": [1], + "enabled": True, + } + if premium: + tariff["premium_squad_uuids"] = ["premium-squad"] + tariff["premium_monthly_gb"] = 25 + return {"default_tariff": "standard", "tariffs": [tariff]} + + +def _make_settings(payload: dict, tmpdir: str) -> Settings: + config_path = Path(tmpdir) / "tariffs.json" + config_path.write_text(json.dumps(payload), encoding="utf-8") + return Settings( + _env_file=None, + BOT_TOKEN="token", + POSTGRES_USER="app_user", + POSTGRES_PASSWORD="app_password", + TARIFFS_CONFIG_PATH=str(config_path), + ) + + +class AdminGrantTopupTests(unittest.IsolatedAsyncioTestCase): + async def test_regular_grant_increases_balance_and_panel_limit(self): + with tempfile.TemporaryDirectory() as tmpdir: + settings = _make_settings(_tariffs_config_payload(), tmpdir) + panel_service = AsyncMock(spec=PanelApiService) + panel_service.update_user_details_on_panel = AsyncMock(return_value={"response": {}}) + service = SubscriptionService(settings, panel_service) + + db_user = SimpleNamespace( + user_id=42, + first_name="Tester", + last_name=None, + username="tester", + language_code="ru", + panel_user_uuid="panel-uuid", + email=None, + telegram_id=42, + ) + sub = SimpleNamespace( + subscription_id=7, + user_id=42, + panel_user_uuid="panel-uuid", + end_date=datetime.now(timezone.utc) + timedelta(days=10), + tariff_key="standard", + tier_baseline_bytes=100 * (1024**3), + topup_balance_bytes=5 * (1024**3), + traffic_limit_bytes=105 * (1024**3), + is_throttled=True, + hwid_device_limit=3, + extra_hwid_devices=0, + premium_is_limited=False, + ) + updated_sub = SimpleNamespace(**vars(sub)) + updated_sub.topup_balance_bytes = 55 * (1024**3) + updated_sub.traffic_limit_bytes = 155 * (1024**3) + updated_sub.is_throttled = False + + with patch("bot.services.subscription_service.user_dal.get_user_by_id", new=AsyncMock(return_value=db_user)), \ + patch("bot.services.subscription_service.subscription_dal.get_active_subscription_by_user_id", new=AsyncMock(return_value=sub)), \ + patch("bot.services.subscription_service.subscription_dal.update_subscription", new=AsyncMock(return_value=updated_sub)) as upd, \ + patch("bot.services.subscription_service.tariff_dal.create_traffic_topup", new=AsyncMock()) as topup_log: + result = await service.admin_grant_topup(AsyncMock(), 42, 50.0) + + self.assertIsNotNone(result) + self.assertEqual(result["topup_balance_bytes"], 55 * (1024**3)) + self.assertEqual(result["traffic_limit_bytes"], 155 * (1024**3)) + self.assertEqual(result["granted_bytes"], 50 * (1024**3)) + + upd.assert_awaited_once() + sub_update_payload = upd.await_args.args[2] + self.assertEqual(sub_update_payload["topup_balance_bytes"], 55 * (1024**3)) + self.assertEqual(sub_update_payload["traffic_limit_bytes"], 155 * (1024**3)) + self.assertFalse(sub_update_payload["is_throttled"]) + + topup_log.assert_awaited_once() + self.assertEqual(topup_log.await_args.kwargs["kind"], "admin_topup") + self.assertIsNone(topup_log.await_args.kwargs["payment_id"]) + self.assertEqual(topup_log.await_args.kwargs["purchased_bytes"], 50 * (1024**3)) + + panel_service.update_user_details_on_panel.assert_awaited_once() + panel_payload = panel_service.update_user_details_on_panel.await_args.args[1] + self.assertEqual(panel_payload["trafficLimitBytes"], 155 * (1024**3)) + + async def test_regular_grant_rejects_zero(self): + with tempfile.TemporaryDirectory() as tmpdir: + settings = _make_settings(_tariffs_config_payload(), tmpdir) + service = SubscriptionService(settings, AsyncMock(spec=PanelApiService)) + self.assertIsNone(await service.admin_grant_topup(AsyncMock(), 1, 0)) + self.assertIsNone(await service.admin_grant_topup(AsyncMock(), 1, -10)) + + async def test_premium_grant_clears_limited_state_when_balance_covers_overuse(self): + with tempfile.TemporaryDirectory() as tmpdir: + settings = _make_settings(_tariffs_config_payload(premium=True), tmpdir) + panel_service = AsyncMock(spec=PanelApiService) + panel_service.update_user_details_on_panel = AsyncMock(return_value={"response": {}}) + service = SubscriptionService(settings, panel_service) + + db_user = SimpleNamespace( + user_id=77, + first_name="Premium", + last_name=None, + username="premium", + language_code="ru", + panel_user_uuid="panel-uuid", + email=None, + telegram_id=77, + ) + # User has used 30 GB on a 25 GB premium baseline → currently + # premium_is_limited=True. Admin grants 20 GB of premium top-up. + sub = SimpleNamespace( + subscription_id=9, + user_id=77, + panel_user_uuid="panel-uuid", + tariff_key="standard", + premium_baseline_bytes=25 * (1024**3), + premium_topup_balance_bytes=0, + premium_topup_used_bytes=0, + premium_used_bytes=30 * (1024**3), + premium_is_limited=True, + premium_period_start_at=datetime.now(timezone.utc).replace( + day=1, hour=0, minute=0, second=0, microsecond=0 + ), + premium_unlimited_override=False, + premium_bonus_bytes=0, + ) + updated_sub = SimpleNamespace(**vars(sub)) + + with patch("bot.services.subscription_service.user_dal.get_user_by_id", new=AsyncMock(return_value=db_user)), \ + patch("bot.services.subscription_service.subscription_dal.get_active_subscription_by_user_id", new=AsyncMock(return_value=sub)), \ + patch("bot.services.subscription_service.subscription_dal.update_subscription", new=AsyncMock(return_value=updated_sub)) as upd, \ + patch("bot.services.subscription_service.tariff_dal.create_traffic_topup", new=AsyncMock()) as topup_log: + result = await service.admin_grant_premium_topup(AsyncMock(), 77, 20.0) + + self.assertIsNotNone(result) + self.assertFalse(result["premium_is_limited"]) + # 5 GB of overuse should be backfilled into premium_topup_used. + self.assertEqual(result["premium_topup_used_bytes"], 5 * (1024**3)) + # Remaining balance after backfill: 15 GB. + self.assertEqual(result["premium_topup_balance_bytes"], 15 * (1024**3)) + self.assertEqual(result["granted_bytes"], 20 * (1024**3)) + + topup_log.assert_awaited_once() + self.assertEqual(topup_log.await_args.kwargs["kind"], "admin_premium_topup") + self.assertIsNone(topup_log.await_args.kwargs["payment_id"]) + + panel_service.update_user_details_on_panel.assert_awaited_once() + panel_payload = panel_service.update_user_details_on_panel.await_args.args[1] + self.assertIn("premium-squad", panel_payload["activeInternalSquads"]) + + sub_update_payload = upd.await_args.args[2] + self.assertFalse(sub_update_payload["premium_is_limited"]) + + async def test_premium_grant_fails_when_tariff_has_no_premium_squads(self): + with tempfile.TemporaryDirectory() as tmpdir: + settings = _make_settings(_tariffs_config_payload(premium=False), tmpdir) + service = SubscriptionService(settings, AsyncMock(spec=PanelApiService)) + + db_user = SimpleNamespace( + user_id=11, panel_user_uuid="panel-uuid", + ) + sub = SimpleNamespace(subscription_id=1, user_id=11, panel_user_uuid="panel-uuid", tariff_key="standard") + + with patch("bot.services.subscription_service.user_dal.get_user_by_id", new=AsyncMock(return_value=db_user)), \ + patch("bot.services.subscription_service.subscription_dal.get_active_subscription_by_user_id", new=AsyncMock(return_value=sub)): + self.assertIsNone(await service.admin_grant_premium_topup(AsyncMock(), 11, 10.0)) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_mini_app_url.py b/tests/test_mini_app_url.py new file mode 100644 index 0000000..a6b4265 --- /dev/null +++ b/tests/test_mini_app_url.py @@ -0,0 +1,35 @@ +import unittest + +from bot.utils.mini_app_url import append_query_params, subscription_mini_app_topup_url +from config.settings import Settings + + +class MiniAppUrlTests(unittest.TestCase): + def test_append_query_params_adds_topup(self): + self.assertEqual( + append_query_params("https://app.example.com/home", {"topup": "regular"}), + "https://app.example.com/home?topup=regular", + ) + + def test_append_query_params_merges_existing(self): + self.assertEqual( + append_query_params("https://app.example.com/?lang=ru", {"topup": "premium"}), + "https://app.example.com/?lang=ru&topup=premium", + ) + + def test_subscription_mini_app_topup_url_none_when_unset(self): + s = Settings(_env_file=None, BOT_TOKEN="x", POSTGRES_USER="u", POSTGRES_PASSWORD="p", SUBSCRIPTION_MINI_APP_URL=None) + self.assertIsNone(subscription_mini_app_topup_url(s, "regular")) + + def test_subscription_mini_app_topup_url(self): + s = Settings( + _env_file=None, + BOT_TOKEN="x", + POSTGRES_USER="u", + POSTGRES_PASSWORD="p", + SUBSCRIPTION_MINI_APP_URL="https://app.example.com/webapp", + ) + self.assertEqual( + subscription_mini_app_topup_url(s, "regular"), + "https://app.example.com/webapp?topup=regular", + ) diff --git a/tests/test_tariff_worker.py b/tests/test_tariff_worker.py index ba08b66..d77288c 100644 --- a/tests/test_tariff_worker.py +++ b/tests/test_tariff_worker.py @@ -265,3 +265,121 @@ class TariffWorkerTests(unittest.IsolatedAsyncioTestCase): self.assertEqual(sub.premium_topup_balance_bytes, int(1.5 * (1024**3))) self.assertEqual(sub.premium_topup_used_bytes, 0) self.assertEqual(sub.premium_period_start_at, datetime(2026, 6, 1, tzinfo=timezone.utc)) + + async def test_premium_unlimited_override_never_throttles(self): + payload = _tariffs_config_payload() + payload["tariffs"][0]["premium_squad_uuids"] = ["premium-squad"] + payload["tariffs"][0]["premium_monthly_gb"] = 1 + with tempfile.TemporaryDirectory() as tmpdir: + config_path = Path(tmpdir) / "tariffs.json" + config_path.write_text(json.dumps(payload), encoding="utf-8") + + settings = Settings( + _env_file=None, + BOT_TOKEN="token", + POSTGRES_USER="app_user", + POSTGRES_PASSWORD="app_password", + TARIFFS_CONFIG_PATH=str(config_path), + TARIFF_TRAFFIC_WARNING_LEVELS="101", + ) + panel_service = AsyncMock(spec=PanelApiService) + panel_service.get_internal_squad_accessible_nodes = AsyncMock( + return_value=[{"uuid": "node-1"}] + ) + panel_service.get_node_users_bandwidth_stats = AsyncMock( + return_value={ + "topUsers": [ + {"username": "tg_42", "total": 50 * (1024**3)}, + ] + } + ) + panel_service.update_user_details_on_panel = AsyncMock(return_value={"response": {}}) + subscription_service = SubscriptionService(settings, panel_service) + worker = TariffTrafficWorker( + settings=settings, + session_factory=SimpleNamespace(), + panel_service=panel_service, + subscription_service=subscription_service, + ) + sub = SimpleNamespace( + subscription_id=1, + user_id=42, + panel_user_uuid="panel-uuid", + premium_baseline_bytes=1 * (1024**3), + premium_topup_balance_bytes=0, + premium_topup_used_bytes=0, + premium_used_bytes=0, + premium_is_limited=False, + premium_period_start_at=None, + premium_unlimited_override=True, + premium_bonus_bytes=0, + ) + tariff = settings.tariffs_config.require("standard") + + await worker._sync_premium_squad_limit( + AsyncMock(), sub, tariff, datetime.now(timezone.utc), panel_username="tg_42" + ) + + self.assertFalse(sub.premium_is_limited) + self.assertEqual(int(sub.premium_used_bytes), 50 * (1024**3)) + payload_sent = panel_service.update_user_details_on_panel.await_args.args[1] + self.assertIn("premium-squad", payload_sent["activeInternalSquads"]) + + async def test_premium_bonus_extends_limit(self): + payload = _tariffs_config_payload() + payload["tariffs"][0]["premium_squad_uuids"] = ["premium-squad"] + payload["tariffs"][0]["premium_monthly_gb"] = 1 + with tempfile.TemporaryDirectory() as tmpdir: + config_path = Path(tmpdir) / "tariffs.json" + config_path.write_text(json.dumps(payload), encoding="utf-8") + + settings = Settings( + _env_file=None, + BOT_TOKEN="token", + POSTGRES_USER="app_user", + POSTGRES_PASSWORD="app_password", + TARIFFS_CONFIG_PATH=str(config_path), + TARIFF_TRAFFIC_WARNING_LEVELS="101", + ) + panel_service = AsyncMock(spec=PanelApiService) + panel_service.get_internal_squad_accessible_nodes = AsyncMock( + return_value=[{"uuid": "node-1"}] + ) + # Used 4 GB > tariff baseline 1 GB, but admin granted +10 GB bonus. + panel_service.get_node_users_bandwidth_stats = AsyncMock( + return_value={ + "topUsers": [ + {"username": "tg_77", "total": 4 * (1024**3)}, + ] + } + ) + panel_service.update_user_details_on_panel = AsyncMock(return_value={"response": {}}) + subscription_service = SubscriptionService(settings, panel_service) + worker = TariffTrafficWorker( + settings=settings, + session_factory=SimpleNamespace(), + panel_service=panel_service, + subscription_service=subscription_service, + ) + sub = SimpleNamespace( + subscription_id=1, + user_id=77, + panel_user_uuid="panel-uuid", + premium_baseline_bytes=1 * (1024**3), + premium_topup_balance_bytes=0, + premium_topup_used_bytes=0, + premium_used_bytes=0, + premium_is_limited=False, + premium_period_start_at=None, + premium_unlimited_override=False, + premium_bonus_bytes=10 * (1024**3), + ) + tariff = settings.tariffs_config.require("standard") + + await worker._sync_premium_squad_limit( + AsyncMock(), sub, tariff, datetime.now(timezone.utc), panel_username="tg_77" + ) + + # 4 GB used vs 1 GB baseline + 10 GB bonus = 11 GB limit → not limited. + self.assertFalse(sub.premium_is_limited) + self.assertEqual(int(sub.premium_used_bytes), 4 * (1024**3))