feat: tune visual of web app admin panel, tune limit messages and etc

This commit is contained in:
3252a8
2026-05-12 15:42:17 +03:00
parent 8af59750c6
commit 475a1953d1
34 changed files with 2777 additions and 114 deletions
+275 -8
View File
@@ -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)
+39
View File
@@ -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() {
@@ -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"}
<PaymentsSection {at} {fmtDate} {fmtMoney} {paymentStatusVariant} />
<PaymentsSection {at} {fmtDate} {fmtMoney} {paymentStatusVariant} onOpenUserCard={openPaymentUserCard} />
{/if}
{#if active === "promos"}
@@ -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<string, unknown>} 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 @@
<div class="admin-table-wrap">
{#if paymentsLoading}
<AdminTableSkeleton headers={paymentHeaders} rows={8} widths={["48px", "120px", "78px", "82px", "180px", "72px", "96px"]} />
<AdminTableSkeleton
headers={paymentHeaders}
rows={8}
widths={["48px", "148px", "88px", "72px", "72px", "78px", "82px", "140px", "72px", "96px"]}
/>
{:else if !payments.length}
<AdminEmptyState tone="card"><span class="admin-muted">{at("payments_empty", {}, "Нет платежей")}</span></AdminEmptyState>
{:else}
@@ -49,6 +107,9 @@
<tr>
<th>{at("id", {}, "ID")}</th>
<th>{at("user", {}, "Пользователь")}</th>
<th>{at("payments_col_telegram_id", {}, "Telegram ID")}</th>
<th>{at("payments_col_traffic_regular", {}, "Основной трафик")}</th>
<th>{at("payments_col_traffic_premium", {}, "Премиум")}</th>
<th>{at("amount", {}, "Сумма")}</th>
<th>{at("provider", {}, "Провайдер")}</th>
<th>{at("description", {}, "Описание")}</th>
@@ -60,10 +121,33 @@
{#each payments as p}
<tr>
<td class="admin-cell-id" data-label="ID">#{p.payment_id}</td>
<td data-label={at("user", {}, "Пользователь")}>{p.user_label || p.user_id}</td>
<td class="admin-cell-user-with-action" data-label={at("user", {}, "Пользователь")}>
<span class="admin-payments-user-cell">
<AdminButton
class="admin-payments-user-btn"
variant="ghost"
size="icon"
title={at("payments_open_user", {}, "Открыть карточку пользователя")}
aria-label={at("payments_open_user", {}, "Открыть карточку пользователя")}
onclick={() => onOpenUserCard(p.user_id)}
>
<User size={14} />
</AdminButton>
<span class="admin-payments-user-name">{p.user_label || p.user_id}</span>
</span>
</td>
<td class="admin-cell-mono" data-label={at("payments_col_telegram_id", {}, "Telegram ID")}>
{p.telegram_id != null ? p.telegram_id : "—"}
</td>
<td class="admin-cell-traffic-gb" data-label={at("payments_col_traffic_regular", {}, "Основной трафик")}>
{formatTrafficGbCell(p.traffic_regular_gb)}
</td>
<td class="admin-cell-traffic-gb" data-label={at("payments_col_traffic_premium", {}, "Премиум")}>
{formatTrafficGbCell(p.traffic_premium_gb)}
</td>
<td data-label={at("amount", {}, "Сумма")}>{fmtMoney(p.amount, p.currency)}</td>
<td data-label={at("provider", {}, "Провайдер")}>{p.provider}</td>
<td class="admin-cell-wrap" data-label={at("description", {}, "Описание")}>{p.description || "—"}</td>
<td class="admin-cell-wrap" data-label={at("description", {}, "Описание")}>{paymentDescriptionDisplay(p)}</td>
<td data-label={at("status", {}, "Статус")}>
<AdminBadge variant={paymentStatusVariant(p.status)}>{p.status}</AdminBadge>
</td>
@@ -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);
}}
/>
<style>
.admin-payments-user-cell {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
}
.admin-payments-user-name {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.admin-cell-user-with-action :global(.admin-payments-user-btn.admin-btn) {
width: 30px;
height: 30px;
min-width: 30px;
min-height: 30px;
flex-shrink: 0;
padding: 0;
border-radius: 7px;
}
.admin-cell-user-with-action :global(.admin-payments-user-btn svg) {
width: 14px;
height: 14px;
}
.admin-cell-traffic-gb {
font-size: 12px;
white-space: nowrap;
color: var(--admin-muted);
}
</style>
@@ -1,7 +1,7 @@
<script>
import { Label, Select, Separator, Tabs } from "$components/ui/primitives.js";
import Dialog from "$components/ui/dialog.svelte";
import { AdminBadge, AdminButton, AdminTrafficCard } from "$components/patterns/admin/index.js";
import { AdminBadge, AdminButton, AdminSectionHeader, AdminSelect, AdminTrafficCard } from "$components/patterns/admin/index.js";
import {
CalendarDays, Copy, CreditCard, ExternalLink, Eye, Info, Key,
Mail, Map, MessageSquare, MousePointerClick, QrCode, RefreshCw, Send,
@@ -41,6 +41,8 @@
userBanConfirmOpen,
userMessageConfirmOpen,
userDetailTab,
premiumUnlimitedDraft,
premiumBonusGbDraft,
} = $usersStore);
</script>
@@ -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}
<AdminTrafficCard
premium
title={at("user_label_premium_squads", {}, "Premium-сквады")}
value={at("user_premium_unlimited_value", { used: trafficLeftLabel(0, openedUserDetail.active_subscription.premium_used_bytes) }, "∞ (использовано " + trafficLeftLabel(0, openedUserDetail.active_subscription.premium_used_bytes) + ")")}
left={at("user_premium_unlimited_hint", {}, "Безлимит (админ-оверрайд)")}
percent={0}
warning={false}
label={at("aria_label_premium_traffic", {}, "Использование premium-трафика")}
/>
{:else if Number(openedUserDetail.active_subscription.premium_limit_bytes || 0) > 0}
<AdminTrafficCard
premium
title={at("user_label_premium_squads", {}, "Premium-сквады")}
@@ -249,6 +261,149 @@
</Label.Root>
</div>
{#if openedUserDetail?.active_subscription}
<section class="admin-user-action-sheet admin-user-action-sheet--premium-override">
<AdminSectionHeader
title={at("user_premium_override_card_title", {}, "Премиум-трафик")}
description={at("user_premium_override_card_hint", {}, "Безлимит и дополнительный объём для премиум-сквадов поверх тарифа.")}
/>
<div class="admin-user-action-sheet-body admin-user-override-stack">
<Label.Root class="admin-field-label admin-extend-field">
<span>{at("user_premium_override_bonus", {}, "Доп. премиум-трафик, GB")}</span>
<small>{at("user_premium_override_bonus_hint", {}, "")}</small>
<input
class="input"
type="number"
min="0"
step="1"
placeholder="0"
disabled={premiumUnlimitedDraft}
aria-label={at("user_premium_override_bonus", {}, "Доп. премиум-трафик, GB")}
bind:value={$usersStore.premiumBonusGbDraft}
/>
</Label.Root>
</div>
<div class="admin-user-action-sheet-footer admin-override-card-footer">
<div class="admin-override-card-toolbar">
<label class="admin-override-unlimited-label">
<input type="checkbox" bind:checked={$usersStore.premiumUnlimitedDraft} aria-label={at("user_override_unlimited_short", {}, "Безлимит")} />
<span>{at("user_override_unlimited_short", {}, "Безлимит")}</span>
</label>
<AdminButton variant="primary" onclick={usersStore.savePremiumTrafficOverride} disabled={userActionBusy}>
{at("user_premium_override_save", {}, "Сохранить")}
</AdminButton>
</div>
<div class="admin-override-status-lines">
{#if openedUserDetail.active_subscription.premium_unlimited_override}
<span class="admin-meta-truncate">
{at("user_premium_override_status_unlimited", {}, "Сейчас: безлимит")}
</span>
{:else if Number(openedUserDetail.active_subscription.premium_bonus_bytes || 0) > 0}
<span class="admin-meta-truncate">
{at(
"user_premium_override_status_bonus",
{ gb: +(Number(openedUserDetail.active_subscription.premium_bonus_bytes) / (1024 ** 3)).toFixed(2) },
`Премиум сейчас: +${+(Number(openedUserDetail.active_subscription.premium_bonus_bytes) / (1024 ** 3)).toFixed(2)} GB`,
)}
</span>
{:else}
<span class="admin-muted">{at("user_premium_override_status_none", {}, "Премиум-оверрайд не задан")}</span>
{/if}
</div>
</div>
</section>
<section class="admin-user-action-sheet admin-user-action-sheet--regular-override">
<AdminSectionHeader
title={at("user_regular_override_card_title", {}, "Основной трафик")}
description={at("user_regular_override_card_hint", {}, "Безлимит и постоянный бонус к лимиту основного трафика.")}
/>
<div class="admin-user-action-sheet-body admin-user-override-stack">
<Label.Root class="admin-field-label admin-extend-field">
<span>{at("user_regular_override_bonus", {}, "Доп. основной трафик, GB")}</span>
<small>{at("user_regular_override_bonus_hint", {}, "")}</small>
<input
class="input"
type="number"
min="0"
step="1"
placeholder="0"
disabled={$usersStore.regularUnlimitedDraft}
aria-label={at("user_regular_override_bonus", {}, "Доп. основной трафик, GB")}
bind:value={$usersStore.regularBonusGbDraft}
/>
</Label.Root>
</div>
<div class="admin-user-action-sheet-footer admin-override-card-footer">
<div class="admin-override-card-toolbar">
<label class="admin-override-unlimited-label">
<input type="checkbox" bind:checked={$usersStore.regularUnlimitedDraft} aria-label={at("user_override_unlimited_short", {}, "Безлимит")} />
<span>{at("user_override_unlimited_short", {}, "Безлимит")}</span>
</label>
<AdminButton variant="primary" onclick={usersStore.saveRegularTrafficOverride} disabled={userActionBusy}>
{at("user_regular_override_save", {}, "Сохранить")}
</AdminButton>
</div>
<div class="admin-override-status-lines">
{#if openedUserDetail.active_subscription.regular_unlimited_override}
<span class="admin-meta-truncate">
{at("user_regular_override_status_unlimited", {}, "Сейчас: безлимит")}
</span>
{:else if Number(openedUserDetail.active_subscription.regular_bonus_bytes || 0) > 0}
<span class="admin-meta-truncate">
{at(
"user_regular_override_status_bonus",
{ gb: +(Number(openedUserDetail.active_subscription.regular_bonus_bytes) / (1024 ** 3)).toFixed(2) },
`Основной сейчас: +${+(Number(openedUserDetail.active_subscription.regular_bonus_bytes) / (1024 ** 3)).toFixed(2)} GB`,
)}
</span>
{:else}
<span class="admin-muted">{at("user_regular_override_status_none", {}, "Бонус основного трафика не задан")}</span>
{/if}
</div>
</div>
</section>
<section class="admin-user-action-sheet admin-user-action-sheet--traffic-grant">
<AdminSectionHeader
title={at("user_traffic_grant_title", {}, "Выдать трафик")}
description={at("user_traffic_grant_hint", {}, "Зачисление ГБ на баланс пользователя — как при докупке, но без оплаты. Лимит и сквады в панели обновятся сразу.")}
/>
<div class="admin-user-action-sheet-body admin-user-grant-stack">
<Label.Root class="admin-field-label admin-extend-field">
<span>{at("user_traffic_grant_kind", {}, "Тип трафика")}</span>
<AdminSelect
class="admin-grant-kind-select"
value={$usersStore.grantTrafficKindDraft}
items={[
{ value: "regular", label: at("user_traffic_grant_kind_regular", {}, "Обычный") },
{ value: "premium", label: at("user_traffic_grant_kind_premium", {}, "Премиум") },
]}
onValueChange={(v) => usersStore.updateState({ grantTrafficKindDraft: v })}
ariaLabel={at("user_traffic_grant_kind", {}, "Тип трафика")}
/>
</Label.Root>
<Label.Root class="admin-field-label admin-extend-field">
<span>{at("user_traffic_grant_gb", {}, "ГБ к выдаче")}</span>
<div class="admin-extend-control">
<input
class="input"
type="number"
min="0"
step="1"
placeholder="0"
aria-label={at("user_traffic_grant_gb", {}, "ГБ к выдаче")}
bind:value={$usersStore.grantTrafficGbDraft}
/>
<AdminButton variant="primary" onclick={usersStore.grantTraffic} disabled={userActionBusy}>
<Plus size={14} /> {at("user_traffic_grant_submit", {}, "Выдать")}
</AdminButton>
</div>
</Label.Root>
</div>
</section>
{/if}
<Label.Root class="admin-field-label">
<span>{at("user_label_telegram_msg", {}, "Сообщение в Telegram")}</span>
<small>{at("user_hint_telegram_msg", {}, "Поддерживается HTML-разметка Telegram")}</small>
@@ -339,3 +494,106 @@
</AdminButton>
</div>
</Dialog>
<style>
.admin-user-action-sheet {
border: 1px solid var(--admin-border-muted, rgba(255, 255, 255, 0.08));
border-radius: 12px;
margin-bottom: 14px;
overflow: hidden;
background: var(--admin-surface-1, rgba(255, 255, 255, 0.02));
}
.admin-user-action-sheet :global(.admin-dashboard-section-head) {
padding: 12px 14px 10px;
margin: 0;
border-bottom: 1px solid var(--admin-border-muted, rgba(255, 255, 255, 0.06));
}
.admin-user-action-sheet-body {
padding: 12px 14px 12px;
display: flex;
flex-direction: column;
gap: 12px;
}
.admin-user-override-stack {
display: flex;
flex-direction: column;
gap: 14px;
}
.admin-user-grant-stack {
display: flex;
flex-direction: column;
gap: 14px;
}
.admin-user-action-sheet-footer {
display: flex;
flex-wrap: wrap;
align-items: flex-start;
gap: 12px;
padding: 4px 14px 12px;
}
.admin-override-card-footer {
flex-direction: column;
align-items: stretch;
gap: 10px;
}
.admin-override-card-toolbar {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 10px 14px;
width: 100%;
}
.admin-override-card-toolbar :global(.admin-btn) {
flex: 0 0 auto;
min-height: 36px;
padding-left: 16px;
padding-right: 16px;
}
.admin-override-unlimited-label {
display: inline-flex;
align-items: center;
gap: 8px;
font-size: 13px;
color: var(--admin-text, inherit);
cursor: pointer;
user-select: none;
min-height: 36px;
}
.admin-override-unlimited-label input[type="checkbox"] {
width: 18px;
height: 18px;
flex-shrink: 0;
}
@media (max-width: 520px) {
.admin-override-card-toolbar {
flex-direction: column;
align-items: stretch;
}
.admin-override-card-toolbar :global(.admin-btn) {
width: 100%;
}
}
.admin-override-status-lines {
display: flex;
flex-direction: column;
gap: 4px;
flex: 1;
min-width: 0;
font-size: 12px;
line-height: 1.35;
}
:global(.admin-user-dialog .admin-actions-tab) {
padding-bottom: 14px;
}
.admin-user-action-sheet--regular-override {
margin-top: 10px;
}
.admin-user-action-sheet--traffic-grant {
margin-top: 10px;
}
.admin-user-action-sheet :global(.admin-grant-kind-select) {
width: 100%;
max-width: 100%;
}
</style>
@@ -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,
};
}
@@ -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;
@@ -100,7 +100,34 @@
<span class="traffic-percent">{trafficPercent(subscription)}%</span>
</div>
</Card>
{#if Number(subscription?.premium_limit_bytes || 0) > 0}
{#if subscription?.premium_unlimited_override}
<Card class="premium-traffic-card">
<div class="traffic-top">
<span>{premiumTitle(subscription)}</span>
<strong></strong>
</div>
<div class="traffic-meta premium-traffic-meta">
{#if premiumServerLabels(subscription).length}
<details class="premium-server-dropdown">
<summary>
<span>{t("wa_premium_unlimited", {}, "Безлимит на premium-сервера")}</span>
<ChevronsUpDown size={13} />
</summary>
<div class="premium-server-list premium-server-list-dropdown">
<small>{t("wa_premium_servers_limited", {}, "Отдельный лимит действует на")}</small>
<div>
{#each premiumServerLabels(subscription).slice(0, 8) as label}
<span>{label}</span>
{/each}
</div>
</div>
</details>
{:else}
<span>{t("wa_premium_unlimited", {}, "Безлимит на premium-сервера")}</span>
{/if}
</div>
</Card>
{:else if Number(subscription?.premium_limit_bytes || 0) > 0}
<Card class={`${canOpenPremiumTopupModal ? "traffic-card-clickable " : ""}premium-traffic-card${subscription?.premium_is_limited ? " premium-traffic-card-limited" : ""}`}>
{#if canOpenPremiumTopupModal}
<button class="card-click-target" type="button" onclick={openPremiumTopupModal} aria-label={premiumTitle(subscription)}></button>
+10 -1
View File
@@ -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 []),
+449 -3
View File
@@ -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"<b>{_('admin_premium_override_title')}</b>",
"",
_("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"<b>{_('admin_traffic_grant_title')}</b>",
"",
_("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:
+15 -6
View File
@@ -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}")
@@ -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,
),
@@ -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,
),
@@ -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,
),
@@ -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),
),
+8 -2
View File
@@ -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.")
+1 -1
View File
@@ -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 ""
+5 -2
View File
@@ -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.")
+35 -3
View File
@@ -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")
)
+4 -2
View File
@@ -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.")
+4 -2
View File
@@ -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.")
+4 -2
View File
@@ -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}")
+366 -9
View File
@@ -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,
+197 -49
View File
@@ -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)
+2
View File
@@ -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()
+32
View File
@@ -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})
+101
View File
@@ -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,
),
]
+4
View File
@@ -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)
+93 -1
View File
@@ -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": "📧 <b>Email linked</b>\n\n🆔 User ID: <code>{user_id}</code>\n📨 Telegram ID: <code>{telegram_id}</code>\n👤 User: {user_display}\n📧 Email: <code>{email}</code>\n🕐 Time: {timestamp}",
"log_account_telegram_linked": "📨 <b>Telegram linked</b>\n\n🆔 User ID: <code>{user_id}</code>\n📨 Telegram ID: <code>{telegram_id}</code>\n👤 User: {user_display}\n📧 Email: <code>{email}</code>\n🕐 Time: {timestamp}",
"log_payment_received": "{provider_emoji} <b>Payment Received</b>\n\n👤 User: {user_display}\n💰 Amount: <b>{amount} {currency}</b>\n📅 Period: <b>{months} mo.</b>\n🏦 Provider: {payment_provider}\n🕐 Time: {timestamp}",
"log_payment_received_traffic": "{provider_emoji} <b>Payment Received</b>\n\n👤 User: {user_display}\n💰 Amount: <b>{amount} {currency}</b>\n🗂 Traffic: <b>{traffic_gb} GB</b>\n🏦 Provider: {payment_provider}\n🕐 Time: {timestamp}",
"log_payment_received_traffic": "{provider_emoji} <b>Payment Received (traffic top-up)</b>\n\n👤 User: {user_display}\n💰 Amount: <b>{amount} {currency}</b>\n🗂 {traffic_summary}\n{tariff_line}🏦 Provider: {payment_provider}\n🕐 Time: {timestamp}",
"log_payment_traffic_purchase_line": "<b>{gb} GB</b> · {kind}",
"log_payment_traffic_kind_regular": "regular traffic",
"log_payment_traffic_kind_premium": "premium traffic",
"log_payment_tariff_line": "📋 Plan: <b>{name}</b>\n",
"traffic_warning_regular_almost": "⚠️ Traffic for plan <b>{tariff_name}</b> is almost used up.\nAbout <b>{left_pct}%</b> remains.\n\n<b>Current period:</b>\nused — <b>{used}</b>\navailable — <b>{remaining}</b>\nlimit — <b>{limit_total}</b>\n\nUse the button below to buy more regular traffic in the mini app.",
"traffic_warning_regular_depleted": "⛔️ Traffic for plan <b>{tariff_name}</b> is depleted.\n\n<b>Current period:</b>\nused — <b>{used}</b>\navailable — <b>{remaining}</b>\nlimit — <b>{limit_total}</b>\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 <b>premium</b> server quota for plan <b>{tariff_name}</b> is almost used up.\nAbout <b>{left_pct}%</b> remains.\n\n<b>Current period:</b>\nused — <b>{used}</b>\navailable — <b>{remaining}</b>\nlimit — <b>{limit_total}</b>\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 <b>premium</b> server quota for plan <b>{tariff_name}</b> is used up.\n\n<b>Current period:</b>\nused — <b>{used}</b>\navailable — <b>{remaining}</b>\nlimit — <b>{limit_total}</b>\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": "🎁 <b>Promo Code Activated</b>\n\n👤 User: {user_display}\n🏷 Code: <code>{promo_code}</code>\n🎯 Bonus: <b>+{bonus_days}d</b>\n🕐 Time: {timestamp}",
"log_trial_activation": "🆓 <b>Trial Activated</b>\n\n👤 User: {user_display}\n⏰ Valid until: <b>{end_date}</b>\n🕐 Time: {timestamp}",
"log_panel_sync": "{status_emoji} <b>Panel Synchronization</b>\n\n📊 Status: <b>{status}</b>\n👥 Users processed: <b>{users_processed}</b>\n📋 Subscriptions synced: <b>{subs_synced}</b>\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",
+93 -1
View File
@@ -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": "📧 <b>Привязана почта</b>\n\n🆔 ID пользователя: <code>{user_id}</code>\n📨 Telegram ID: <code>{telegram_id}</code>\n👤 Пользователь: {user_display}\n📧 Email: <code>{email}</code>\n🕐 Время: {timestamp}",
"log_account_telegram_linked": "📨 <b>Привязан Telegram</b>\n\n🆔 ID пользователя: <code>{user_id}</code>\n📨 Telegram ID: <code>{telegram_id}</code>\n👤 Пользователь: {user_display}\n📧 Email: <code>{email}</code>\n🕐 Время: {timestamp}",
"log_payment_received": "{provider_emoji} <b>Получен платеж</b>\n\n👤 Пользователь: {user_display}\n💰 Сумма: <b>{amount} {currency}</b>\n📅 Период: <b>{months} мес.</b>\n🏦 Провайдер: {payment_provider}\n🕐 Время: {timestamp}",
"log_payment_received_traffic": "{provider_emoji} <b>Получен платеж</b>\n\n👤 Пользователь: {user_display}\n💰 Сумма: <b>{amount} {currency}</b>\n🗂 Трафик: <b>{traffic_gb} ГБ</b>\n🏦 Провайдер: {payment_provider}\n🕐 Время: {timestamp}",
"log_payment_received_traffic": "{provider_emoji} <b>Получен платеж (докупка трафика)</b>\n\n👤 Пользователь: {user_display}\n💰 Сумма: <b>{amount} {currency}</b>\n🗂 {traffic_summary}\n{tariff_line}🏦 Провайдер: {payment_provider}\n🕐 Время: {timestamp}",
"log_payment_traffic_purchase_line": "<b>{gb} ГБ</b> · {kind}",
"log_payment_traffic_kind_regular": "основной трафик",
"log_payment_traffic_kind_premium": "премиум-трафик",
"log_payment_tariff_line": "📋 Тариф: <b>{name}</b>\n",
"traffic_warning_regular_almost": "⚠️ Трафик тарифа <b>{tariff_name}</b> почти израсходован.\nОсталось примерно <b>{left_pct}%</b>.\n\n<b>Текущий период:</b>\nизрасходовано — <b>{used}</b>\nдоступно — <b>{remaining}</b>\nлимит — <b>{limit_total}</b>\n\nДокупить основной трафик можно через кнопку ниже.",
"traffic_warning_regular_depleted": "⛔️ Трафик тарифа <b>{tariff_name}</b> израсходован.\n\n<b>Текущий период:</b>\nизрасходовано — <b>{used}</b>\nдоступно — <b>{remaining}</b>\nлимит — <b>{limit_total}</b>\n\nДоступ может быть ограничен до сброса периода или докупки пакета.\n\nДокупить трафик можно через кнопку ниже.",
"traffic_warning_premium_almost": "⚠️ Лимит премиум-серверов тарифа <b>{tariff_name}</b> почти израсходован.\nОсталось примерно <b>{left_pct}%</b>.\n\n<b>Текущий период:</b>\nизрасходовано — <b>{used}</b>\nдоступно — <b>{remaining}</b>\nлимит — <b>{limit_total}</b>\n\nЛимит действует на:\n{servers}\n\nДокупить премиум-трафик можно через кнопку ниже; неизрасходованный остаток переносится на следующие месяцы.",
"traffic_warning_premium_depleted": "⛔️ Лимит премиум-серверов тарифа <b>{tariff_name}</b> израсходован.\n\n<b>Текущий период:</b>\nизрасходовано — <b>{used}</b>\nдоступно — <b>{remaining}</b>\nлимит — <b>{limit_total}</b>\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": "🎁 <b>Активирован промокод</b>\n\n👤 Пользователь: {user_display}\n🏷 Код: <code>{promo_code}</code>\n🎯 Бонус: <b>+{bonus_days} дн.</b>\n🕐 Время: {timestamp}",
"log_trial_activation": "🆓 <b>Активирован триал</b>\n\n👤 Пользователь: {user_display}\n⏰ Действует до: <b>{end_date}</b>\n🕐 Время: {timestamp}",
"log_panel_sync": "{status_emoji} <b>Синхронизация с панелью</b>\n\n📊 Статус: <b>{status}</b>\n👥 Обработано пользователей: <b>{users_processed}</b>\n📋 Синхронизировано подписок: <b>{subs_synced}</b>\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": "Без тарифа",
@@ -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 ... <telegram_user_id> [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())
+200
View File
@@ -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()
+35
View File
@@ -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",
)
+118
View File
@@ -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))