feat: tune visual of web app admin panel, tune limit messages and etc
This commit is contained in:
+275
-8
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 []),
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
|
||||
@@ -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.")
|
||||
|
||||
@@ -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 "—"
|
||||
|
||||
@@ -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.")
|
||||
|
||||
@@ -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")
|
||||
)
|
||||
|
||||
@@ -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.")
|
||||
|
||||
@@ -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.")
|
||||
|
||||
@@ -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}")
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
@@ -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,
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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())
|
||||
@@ -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()
|
||||
@@ -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",
|
||||
)
|
||||
@@ -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))
|
||||
|
||||
Reference in New Issue
Block a user