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
|
import logging
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Dict, List, Optional
|
from typing import Any, Dict, List, Optional, Tuple
|
||||||
|
|
||||||
from aiogram import Bot
|
from aiogram import Bot
|
||||||
from aiohttp import web
|
from aiohttp import web
|
||||||
@@ -163,10 +163,15 @@ def _serialize_user(user: User) -> Dict[str, Any]:
|
|||||||
|
|
||||||
|
|
||||||
def _serialize_subscription(sub: Subscription) -> 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 = (
|
premium_limit_bytes = (
|
||||||
int(sub.premium_baseline_bytes or 0)
|
int(sub.premium_baseline_bytes or 0)
|
||||||
+ int(sub.premium_topup_balance_bytes or 0)
|
+ int(sub.premium_topup_balance_bytes or 0)
|
||||||
+ int(getattr(sub, "premium_topup_used_bytes", 0) or 0)
|
+ int(getattr(sub, "premium_topup_used_bytes", 0) or 0)
|
||||||
|
+ premium_bonus_bytes
|
||||||
)
|
)
|
||||||
return {
|
return {
|
||||||
"subscription_id": int(sub.subscription_id),
|
"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_baseline_bytes": sub.premium_baseline_bytes,
|
||||||
"premium_topup_balance_bytes": sub.premium_topup_balance_bytes,
|
"premium_topup_balance_bytes": sub.premium_topup_balance_bytes,
|
||||||
"premium_topup_used_bytes": getattr(sub, "premium_topup_used_bytes", 0),
|
"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),
|
"premium_is_limited": bool(sub.premium_is_limited),
|
||||||
"tariff_key": sub.tariff_key,
|
"tariff_key": sub.tariff_key,
|
||||||
"auto_renew_enabled": bool(sub.auto_renew_enabled),
|
"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]:
|
def _serialize_payment(payment: Payment) -> Dict[str, Any]:
|
||||||
# Avoid lazy-loading `payment.user` outside an active SQLAlchemy session.
|
# Avoid lazy-loading `payment.user` outside an active SQLAlchemy session.
|
||||||
# Some admin routes serialize payments after the session scope is closed.
|
# Some admin routes serialize payments after the session scope is closed.
|
||||||
user_label = str(payment.user_id)
|
user_label = str(payment.user_id)
|
||||||
|
telegram_id = None
|
||||||
loaded_user = payment.__dict__.get("user")
|
loaded_user = payment.__dict__.get("user")
|
||||||
if loaded_user is not None:
|
if loaded_user is not None:
|
||||||
user_label = loaded_user.username or loaded_user.first_name or str(payment.user_id)
|
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 {
|
return {
|
||||||
"payment_id": int(payment.payment_id),
|
"payment_id": int(payment.payment_id),
|
||||||
"user_id": int(payment.user_id),
|
"user_id": int(payment.user_id),
|
||||||
"user_label": user_label,
|
"user_label": user_label,
|
||||||
|
"telegram_id": telegram_id,
|
||||||
|
"traffic_regular_gb": reg_gb,
|
||||||
|
"traffic_premium_gb": prem_gb,
|
||||||
"provider": payment.provider,
|
"provider": payment.provider,
|
||||||
"provider_payment_id": payment.provider_payment_id,
|
"provider_payment_id": payment.provider_payment_id,
|
||||||
"amount": float(payment.amount),
|
"amount": float(payment.amount),
|
||||||
@@ -992,6 +1031,204 @@ async def admin_user_reset_trial_route(request: web.Request) -> web.Response:
|
|||||||
return _ok({})
|
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:
|
async def admin_user_extend_route(request: web.Request) -> web.Response:
|
||||||
actor_id = _require_admin_user_id(request)
|
actor_id = _require_admin_user_id(request)
|
||||||
target_id = int(request.match_info["user_id"])
|
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:
|
if days <= 0:
|
||||||
return _error(400, "invalid_days")
|
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_session_factory: sessionmaker = request.app["async_session_factory"]
|
||||||
async with async_session_factory() as session:
|
async with async_session_factory() as session:
|
||||||
active = await subscription_dal.get_active_subscription_by_user_id(session, target_id)
|
new_end = await subscription_service.extend_active_subscription_days(
|
||||||
if not active:
|
session,
|
||||||
return _error(404, "no_active_subscription")
|
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(
|
await message_log_dal.create_message_log(
|
||||||
session,
|
session,
|
||||||
{
|
{
|
||||||
@@ -1022,8 +1267,18 @@ async def admin_user_extend_route(request: web.Request) -> web.Response:
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
await session.commit()
|
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 ──────────────────────────────────────────────────────
|
# ─── 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+}/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+}/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+}/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_delete("/api/admin/users/{user_id:-?\\d+}", admin_user_delete_route)
|
||||||
|
|
||||||
router.add_get("/api/admin/payments", admin_payments_list_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() {
|
async function loadData() {
|
||||||
const payload = await api("/me");
|
const payload = await api("/me");
|
||||||
if (!payload.ok) throw new Error(payload.error || "load_failed");
|
if (!payload.ok) throw new Error(payload.error || "load_failed");
|
||||||
@@ -482,6 +492,35 @@
|
|||||||
if (topupModalOpen) await billingStore.loadTopupOptions(topupKind);
|
if (topupModalOpen) await billingStore.loadTopupOptions(topupKind);
|
||||||
if (deviceTopupModalOpen) await billingStore.loadDeviceTopupOptions();
|
if (deviceTopupModalOpen) await billingStore.loadDeviceTopupOptions();
|
||||||
if (changeModalOpen) await billingStore.loadTariffChangeOptions();
|
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() {
|
function showLogin() {
|
||||||
|
|||||||
@@ -219,6 +219,19 @@
|
|||||||
window.open("/api/admin/payments/export.csv", "_blank", "noopener");
|
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) {
|
function resolvedAvatarUrl(user) {
|
||||||
return userAvatarUrl(user) || (!user?.telegram_id && user?.email ? gravatarCache.gravatarUrl(user.email) : "");
|
return userAvatarUrl(user) || (!user?.telegram_id && user?.email ? gravatarCache.gravatarUrl(user.email) : "");
|
||||||
}
|
}
|
||||||
@@ -470,7 +483,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if active === "payments"}
|
{#if active === "payments"}
|
||||||
<PaymentsSection {at} {fmtDate} {fmtMoney} {paymentStatusVariant} />
|
<PaymentsSection {at} {fmtDate} {fmtMoney} {paymentStatusVariant} onOpenUserCard={openPaymentUserCard} />
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if active === "promos"}
|
{#if active === "promos"}
|
||||||
|
|||||||
@@ -2,16 +2,19 @@
|
|||||||
import { getContext, onMount } from "svelte";
|
import { getContext, onMount } from "svelte";
|
||||||
import {
|
import {
|
||||||
AdminBadge,
|
AdminBadge,
|
||||||
|
AdminButton,
|
||||||
AdminEmptyState,
|
AdminEmptyState,
|
||||||
AdminPagination,
|
AdminPagination,
|
||||||
AdminTable,
|
AdminTable,
|
||||||
AdminTableSkeleton,
|
AdminTableSkeleton,
|
||||||
} from "$components/patterns/admin/index.js";
|
} from "$components/patterns/admin/index.js";
|
||||||
|
import { User } from "$components/ui/icons.js";
|
||||||
|
|
||||||
export let at = (key) => key;
|
export let at = (key) => key;
|
||||||
export let fmtDate = (value) => value;
|
export let fmtDate = (value) => value;
|
||||||
export let fmtMoney = (value) => value;
|
export let fmtMoney = (value) => value;
|
||||||
export let paymentStatusVariant = () => "muted";
|
export let paymentStatusVariant = () => "muted";
|
||||||
|
export let onOpenUserCard = () => {};
|
||||||
|
|
||||||
const paymentsStore = getContext("paymentsStore");
|
const paymentsStore = getContext("paymentsStore");
|
||||||
|
|
||||||
@@ -23,9 +26,60 @@
|
|||||||
} = $paymentsStore);
|
} = $paymentsStore);
|
||||||
|
|
||||||
$: paymentsHasMore = payments.length > 0 && paymentsTotal > (paymentsPage + 1) * 25; // 25 is PAYMENTS_PAGE_SIZE
|
$: 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 = [
|
$: paymentHeaders = [
|
||||||
at("id", {}, "ID"),
|
at("id", {}, "ID"),
|
||||||
at("user", {}, "Пользователь"),
|
at("user", {}, "Пользователь"),
|
||||||
|
at("payments_col_telegram_id", {}, "Telegram ID"),
|
||||||
|
at("payments_col_traffic_regular", {}, "Основной трафик"),
|
||||||
|
at("payments_col_traffic_premium", {}, "Премиум"),
|
||||||
at("amount", {}, "Сумма"),
|
at("amount", {}, "Сумма"),
|
||||||
at("provider", {}, "Провайдер"),
|
at("provider", {}, "Провайдер"),
|
||||||
at("description", {}, "Описание"),
|
at("description", {}, "Описание"),
|
||||||
@@ -40,7 +94,11 @@
|
|||||||
|
|
||||||
<div class="admin-table-wrap">
|
<div class="admin-table-wrap">
|
||||||
{#if paymentsLoading}
|
{#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}
|
{:else if !payments.length}
|
||||||
<AdminEmptyState tone="card"><span class="admin-muted">{at("payments_empty", {}, "Нет платежей")}</span></AdminEmptyState>
|
<AdminEmptyState tone="card"><span class="admin-muted">{at("payments_empty", {}, "Нет платежей")}</span></AdminEmptyState>
|
||||||
{:else}
|
{:else}
|
||||||
@@ -49,6 +107,9 @@
|
|||||||
<tr>
|
<tr>
|
||||||
<th>{at("id", {}, "ID")}</th>
|
<th>{at("id", {}, "ID")}</th>
|
||||||
<th>{at("user", {}, "Пользователь")}</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("amount", {}, "Сумма")}</th>
|
||||||
<th>{at("provider", {}, "Провайдер")}</th>
|
<th>{at("provider", {}, "Провайдер")}</th>
|
||||||
<th>{at("description", {}, "Описание")}</th>
|
<th>{at("description", {}, "Описание")}</th>
|
||||||
@@ -60,10 +121,33 @@
|
|||||||
{#each payments as p}
|
{#each payments as p}
|
||||||
<tr>
|
<tr>
|
||||||
<td class="admin-cell-id" data-label="ID">#{p.payment_id}</td>
|
<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("amount", {}, "Сумма")}>{fmtMoney(p.amount, p.currency)}</td>
|
||||||
<td data-label={at("provider", {}, "Провайдер")}>{p.provider}</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", {}, "Статус")}>
|
<td data-label={at("status", {}, "Статус")}>
|
||||||
<AdminBadge variant={paymentStatusVariant(p.status)}>{p.status}</AdminBadge>
|
<AdminBadge variant={paymentStatusVariant(p.status)}>{p.status}</AdminBadge>
|
||||||
</td>
|
</td>
|
||||||
@@ -81,6 +165,47 @@
|
|||||||
nextLabel={at("next", {}, "Далее")}
|
nextLabel={at("next", {}, "Далее")}
|
||||||
prevDisabled={paymentsPage === 0}
|
prevDisabled={paymentsPage === 0}
|
||||||
nextDisabled={!paymentsHasMore}
|
nextDisabled={!paymentsHasMore}
|
||||||
onPrev={() => { paymentsStore.setPage(Math.max(0, paymentsPage - 1)); }}
|
onPrev={() => {
|
||||||
onNext={() => { paymentsStore.setPage(paymentsPage + 1); }}
|
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>
|
<script>
|
||||||
import { Label, Select, Separator, Tabs } from "$components/ui/primitives.js";
|
import { Label, Select, Separator, Tabs } from "$components/ui/primitives.js";
|
||||||
import Dialog from "$components/ui/dialog.svelte";
|
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 {
|
import {
|
||||||
CalendarDays, Copy, CreditCard, ExternalLink, Eye, Info, Key,
|
CalendarDays, Copy, CreditCard, ExternalLink, Eye, Info, Key,
|
||||||
Mail, Map, MessageSquare, MousePointerClick, QrCode, RefreshCw, Send,
|
Mail, Map, MessageSquare, MousePointerClick, QrCode, RefreshCw, Send,
|
||||||
@@ -41,6 +41,8 @@
|
|||||||
userBanConfirmOpen,
|
userBanConfirmOpen,
|
||||||
userMessageConfirmOpen,
|
userMessageConfirmOpen,
|
||||||
userDetailTab,
|
userDetailTab,
|
||||||
|
premiumUnlimitedDraft,
|
||||||
|
premiumBonusGbDraft,
|
||||||
} = $usersStore);
|
} = $usersStore);
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
@@ -177,7 +179,17 @@
|
|||||||
warning={openedUserDetail.active_subscription.is_throttled}
|
warning={openedUserDetail.active_subscription.is_throttled}
|
||||||
label={at("aria_label_main_traffic", {}, "Использование основного трафика")}
|
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
|
<AdminTrafficCard
|
||||||
premium
|
premium
|
||||||
title={at("user_label_premium_squads", {}, "Premium-сквады")}
|
title={at("user_label_premium_squads", {}, "Premium-сквады")}
|
||||||
@@ -249,6 +261,149 @@
|
|||||||
</Label.Root>
|
</Label.Root>
|
||||||
</div>
|
</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">
|
<Label.Root class="admin-field-label">
|
||||||
<span>{at("user_label_telegram_msg", {}, "Сообщение в Telegram")}</span>
|
<span>{at("user_label_telegram_msg", {}, "Сообщение в Telegram")}</span>
|
||||||
<small>{at("user_hint_telegram_msg", {}, "Поддерживается HTML-разметка Telegram")}</small>
|
<small>{at("user_hint_telegram_msg", {}, "Поддерживается HTML-разметка Telegram")}</small>
|
||||||
@@ -339,3 +494,106 @@
|
|||||||
</AdminButton>
|
</AdminButton>
|
||||||
</div>
|
</div>
|
||||||
</Dialog>
|
</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,
|
userBanConfirmOpen: false,
|
||||||
userMessageConfirmOpen: false,
|
userMessageConfirmOpen: false,
|
||||||
userDetailTab: "profile",
|
userDetailTab: "profile",
|
||||||
|
premiumUnlimitedDraft: false,
|
||||||
|
premiumBonusGbDraft: "",
|
||||||
|
regularUnlimitedDraft: false,
|
||||||
|
regularBonusGbDraft: "",
|
||||||
|
grantTrafficGbDraft: "",
|
||||||
|
grantTrafficKindDraft: "regular",
|
||||||
});
|
});
|
||||||
|
|
||||||
let _activeRef = "stats"; // fallback if active isn't tracked
|
let _activeRef = "stats"; // fallback if active isn't tracked
|
||||||
@@ -86,10 +92,19 @@ export function createUsersStore({ api, onToast, at }) {
|
|||||||
try {
|
try {
|
||||||
const res = await api(`/admin/users/${userId}`);
|
const res = await api(`/admin/users/${userId}`);
|
||||||
if (res?.ok) {
|
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 => ({
|
state.update(s => ({
|
||||||
...s,
|
...s,
|
||||||
openedUserDetail: res,
|
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 {
|
} else {
|
||||||
onToast(res?.error || "load_failed");
|
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() {
|
async function deleteUser() {
|
||||||
let s;
|
let s;
|
||||||
state.update(st => { s = st; return st; });
|
state.update(st => { s = st; return st; });
|
||||||
@@ -291,5 +403,8 @@ export function createUsersStore({ api, onToast, at }) {
|
|||||||
extendUser,
|
extendUser,
|
||||||
resetTrialUser,
|
resetTrialUser,
|
||||||
deleteUser,
|
deleteUser,
|
||||||
|
savePremiumTrafficOverride,
|
||||||
|
saveRegularTrafficOverride,
|
||||||
|
grantTraffic,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -267,6 +267,17 @@
|
|||||||
flex: 0 0 auto;
|
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 .input,
|
||||||
.admin-input-row .admin-btn {
|
.admin-input-row .admin-btn {
|
||||||
height: 36px;
|
height: 36px;
|
||||||
|
|||||||
@@ -100,7 +100,34 @@
|
|||||||
<span class="traffic-percent">{trafficPercent(subscription)}%</span>
|
<span class="traffic-percent">{trafficPercent(subscription)}%</span>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</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" : ""}`}>
|
<Card class={`${canOpenPremiumTopupModal ? "traffic-card-clickable " : ""}premium-traffic-card${subscription?.premium_is_limited ? " premium-traffic-card-limited" : ""}`}>
|
||||||
{#if canOpenPremiumTopupModal}
|
{#if canOpenPremiumTopupModal}
|
||||||
<button class="card-click-target" type="button" onclick={openPremiumTopupModal} aria-label={premiumTitle(subscription)}></button>
|
<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",
|
sale_mode="premium_topup",
|
||||||
title_prefix=f"{tariff.premium_name(lang)} ",
|
title_prefix=f"{tariff.premium_name(lang)} ",
|
||||||
) if topup_kind in {"all", "premium"} and tariff.premium_squad_uuids else []
|
) 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 = (
|
premium_limit_bytes = (
|
||||||
int(sub.premium_baseline_bytes or 0)
|
int(sub.premium_baseline_bytes or 0)
|
||||||
+ int(sub.premium_topup_balance_bytes or 0)
|
+ int(sub.premium_topup_balance_bytes or 0)
|
||||||
+ int(getattr(sub, "premium_topup_used_bytes", 0) 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)
|
premium_access = await request.app["subscription_service"].premium_access_for_tariff(tariff)
|
||||||
return web.json_response(
|
return web.json_response(
|
||||||
@@ -2127,7 +2130,7 @@ async def tariff_topup_options_route(request: web.Request) -> web.Response:
|
|||||||
"topup_kind": topup_kind,
|
"topup_kind": topup_kind,
|
||||||
"premium_title": tariff.premium_name(lang),
|
"premium_title": tariff.premium_name(lang),
|
||||||
"traffic_percent": _traffic_percent(sub.traffic_used_bytes, sub.traffic_limit_bytes),
|
"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,
|
sub.premium_used_bytes,
|
||||||
premium_limit_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_baseline_bytes": int(sub.premium_baseline_bytes or 0),
|
||||||
"premium_topup_balance_bytes": int(sub.premium_topup_balance_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_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_is_limited": bool(sub.premium_is_limited),
|
||||||
"premium_squad_labels": premium_access.get("squad_labels") or [],
|
"premium_squad_labels": premium_access.get("squad_labels") or [],
|
||||||
"premium_node_labels": premium_access.get("node_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_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_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_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_is_limited": bool(active.get("premium_is_limited")),
|
||||||
"premium_squad_labels": list(active.get("premium_squad_labels") or []),
|
"premium_squad_labels": list(active.get("premium_squad_labels") or []),
|
||||||
"premium_node_labels": list(active.get("premium_node_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}"
|
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
|
# 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).
|
# (synthetic email-only users have a negative user_id with no tg profile).
|
||||||
has_self_link = user_id > 0
|
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)
|
quick_links_count = (1 if has_self_link else 0) + (1 if has_referrer_link else 0)
|
||||||
if quick_links_count == 0:
|
if quick_links_count == 0:
|
||||||
builder.adjust(2, 2, 2, 1, 2)
|
builder.adjust(2, 2, 2, 2, 1, 2)
|
||||||
else:
|
else:
|
||||||
builder.adjust(2, 2, 2, quick_links_count, 1, 2)
|
builder.adjust(2, 2, 2, 2, quick_links_count, 1, 2)
|
||||||
return builder
|
return builder
|
||||||
|
|
||||||
|
|
||||||
@@ -343,6 +353,18 @@ async def format_user_card(user: User, session: AsyncSession,
|
|||||||
limit_display = _("traffic_unlimited")
|
limit_display = _("traffic_unlimited")
|
||||||
|
|
||||||
card_parts.append(f"{_('admin_user_traffic_label')} {hcode(f'{used_display} / {limit_display}')}")
|
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:
|
else:
|
||||||
card_parts.append(f"{_('admin_user_subscription_label')} {hcode(_('admin_user_subscription_none'))}")
|
card_parts.append(f"{_('admin_user_subscription_label')} {hcode(_('admin_user_subscription_none'))}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -541,10 +563,237 @@ async def user_action_handler(callback: types.CallbackQuery, state: FSMContext,
|
|||||||
await handle_delete_user_prompt(
|
await handle_delete_user_prompt(
|
||||||
callback, state, user, settings, i18n, current_lang, session
|
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:
|
else:
|
||||||
await callback.answer(_("admin_unknown_action"), show_alert=True)
|
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,
|
async def handle_reset_trial(callback: types.CallbackQuery, user: User,
|
||||||
subscription_service: SubscriptionService,
|
subscription_service: SubscriptionService,
|
||||||
session: AsyncSession, i18n_instance, lang: str):
|
session: AsyncSession, i18n_instance, lang: str):
|
||||||
@@ -1372,6 +1621,203 @@ async def process_unban_user_handler(message: types.Message, state: FSMContext,
|
|||||||
await state.clear()
|
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:"))
|
@router.callback_query(F.data.startswith("admin_user_card_from_list:"))
|
||||||
async def user_card_from_list_handler(callback: types.CallbackQuery,
|
async def user_card_from_list_handler(callback: types.CallbackQuery,
|
||||||
state: FSMContext, i18n_data: dict,
|
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}"
|
callback_data=f"admin_action:users_list:{page}"
|
||||||
)
|
)
|
||||||
quick_links_width = 2 if user.referred_by_id else 1
|
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
|
# Format user card
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -266,7 +266,7 @@ async def process_successful_payment(session: AsyncSession, bot: Bot,
|
|||||||
"applied_promo_bonus_days", 0)
|
"applied_promo_bonus_days", 0)
|
||||||
|
|
||||||
referral_bonus_info = None
|
referral_bonus_info = None
|
||||||
if sale_mode != "traffic":
|
if sale_mode_base == "subscription":
|
||||||
referral_bonus_info = await referral_service.apply_referral_bonuses_for_payment(
|
referral_bonus_info = await referral_service.apply_referral_bonuses_for_payment(
|
||||||
session,
|
session,
|
||||||
user_id,
|
user_id,
|
||||||
@@ -292,7 +292,7 @@ async def process_successful_payment(session: AsyncSession, bot: Bot,
|
|||||||
if should_send_lknpd_receipt:
|
if should_send_lknpd_receipt:
|
||||||
receipt_item_name = payment_info_from_webhook.get("description")
|
receipt_item_name = payment_info_from_webhook.get("description")
|
||||||
if not receipt_item_name:
|
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)
|
receipt_item_name = settings.LKNPD_RECEIPT_NAME_TRAFFIC.format(gb=traffic_label)
|
||||||
else:
|
else:
|
||||||
receipt_item_name = settings.LKNPD_RECEIPT_NAME_SUBSCRIPTION.format(months=int(subscription_months))
|
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")
|
config_link_text = config_link_display or _("config_link_not_available")
|
||||||
# For auto-renew charges, avoid re-sending config link; send concise message
|
# 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 = _(
|
details_message = _(
|
||||||
"yookassa_auto_renewal",
|
"yookassa_auto_renewal",
|
||||||
months=int(subscription_months),
|
months=int(subscription_months),
|
||||||
end_date=final_end_date_for_user.strftime('%Y-%m-%d'),
|
end_date=final_end_date_for_user.strftime('%Y-%m-%d'),
|
||||||
)
|
)
|
||||||
details_markup = None
|
details_markup = None
|
||||||
elif sale_mode == "traffic":
|
elif sale_mode_base in {"traffic", "traffic_package", "topup", "premium_topup"}:
|
||||||
details_message = _(
|
details_message = _(
|
||||||
"payment_successful_traffic_full",
|
"payment_successful_traffic_full",
|
||||||
traffic_gb=traffic_label,
|
traffic_gb=traffic_label,
|
||||||
@@ -403,14 +403,23 @@ async def process_successful_payment(session: AsyncSession, bot: Bot,
|
|||||||
try:
|
try:
|
||||||
notification_service = NotificationService(bot, settings, i18n)
|
notification_service = NotificationService(bot, settings, i18n)
|
||||||
user = await user_dal.get_user_by_id(session, user_id)
|
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(
|
await notification_service.notify_payment_received(
|
||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
amount=payment_value,
|
amount=payment_value,
|
||||||
currency=settings.DEFAULT_CURRENCY_SYMBOL,
|
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
|
payment_provider="yookassa", # This is specifically for YooKassa webhook
|
||||||
username=user.username if user else None,
|
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:
|
except Exception as e:
|
||||||
logging.error(f"Failed to send payment notification: {e}")
|
logging.error(f"Failed to send payment notification: {e}")
|
||||||
|
|||||||
@@ -75,7 +75,7 @@ async def pay_fk_callback_handler(
|
|||||||
"currency": currency_code,
|
"currency": currency_code,
|
||||||
"status": "pending_freekassa",
|
"status": "pending_freekassa",
|
||||||
"description": payment_description,
|
"description": payment_description,
|
||||||
"subscription_duration_months": int(months),
|
"subscription_duration_months": int(months) if sale_base == "subscription" else None,
|
||||||
"provider": "freekassa",
|
"provider": "freekassa",
|
||||||
"sale_mode": sale_mode,
|
"sale_mode": sale_mode,
|
||||||
"tariff_key": sale_mode.split("@", 1)[1] if "@" in sale_mode else None,
|
"tariff_key": sale_mode.split("@", 1)[1] if "@" in sale_mode else None,
|
||||||
@@ -147,7 +147,7 @@ async def pay_fk_callback_handler(
|
|||||||
try:
|
try:
|
||||||
await callback.message.edit_text(
|
await callback.message.edit_text(
|
||||||
f"{order_info_text}\n\n" + get_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),
|
months=int(months),
|
||||||
traffic_gb=human_value,
|
traffic_gb=human_value,
|
||||||
),
|
),
|
||||||
@@ -165,7 +165,7 @@ async def pay_fk_callback_handler(
|
|||||||
try:
|
try:
|
||||||
await callback.message.answer(
|
await callback.message.answer(
|
||||||
f"{order_info_text}\n\n" + get_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),
|
months=int(months),
|
||||||
traffic_gb=human_value,
|
traffic_gb=human_value,
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -102,7 +102,7 @@ async def pay_platega_callback_handler(
|
|||||||
"currency": currency_code,
|
"currency": currency_code,
|
||||||
"status": "pending_platega",
|
"status": "pending_platega",
|
||||||
"description": payment_description,
|
"description": payment_description,
|
||||||
"subscription_duration_months": int(months),
|
"subscription_duration_months": int(months) if sale_base == "subscription" else None,
|
||||||
"provider": "platega",
|
"provider": "platega",
|
||||||
"sale_mode": sale_mode,
|
"sale_mode": sale_mode,
|
||||||
"tariff_key": sale_mode.split("@", 1)[1] if "@" in sale_mode else None,
|
"tariff_key": sale_mode.split("@", 1)[1] if "@" in sale_mode else None,
|
||||||
@@ -178,7 +178,7 @@ async def pay_platega_callback_handler(
|
|||||||
try:
|
try:
|
||||||
await callback.message.edit_text(
|
await callback.message.edit_text(
|
||||||
get_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),
|
months=int(months),
|
||||||
traffic_gb=human_value,
|
traffic_gb=human_value,
|
||||||
),
|
),
|
||||||
@@ -196,7 +196,7 @@ async def pay_platega_callback_handler(
|
|||||||
try:
|
try:
|
||||||
await callback.message.answer(
|
await callback.message.answer(
|
||||||
get_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),
|
months=int(months),
|
||||||
traffic_gb=human_value,
|
traffic_gb=human_value,
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -74,7 +74,7 @@ async def pay_severpay_callback_handler(
|
|||||||
"currency": currency_code,
|
"currency": currency_code,
|
||||||
"status": "pending_severpay",
|
"status": "pending_severpay",
|
||||||
"description": payment_description,
|
"description": payment_description,
|
||||||
"subscription_duration_months": int(months),
|
"subscription_duration_months": int(months) if sale_base == "subscription" else None,
|
||||||
"provider": "severpay",
|
"provider": "severpay",
|
||||||
"sale_mode": sale_mode,
|
"sale_mode": sale_mode,
|
||||||
"tariff_key": sale_mode.split("@", 1)[1] if "@" in sale_mode else None,
|
"tariff_key": sale_mode.split("@", 1)[1] if "@" in sale_mode else None,
|
||||||
@@ -138,7 +138,7 @@ async def pay_severpay_callback_handler(
|
|||||||
try:
|
try:
|
||||||
await callback.message.edit_text(
|
await callback.message.edit_text(
|
||||||
get_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),
|
months=int(months),
|
||||||
traffic_gb=human_value,
|
traffic_gb=human_value,
|
||||||
),
|
),
|
||||||
@@ -156,7 +156,7 @@ async def pay_severpay_callback_handler(
|
|||||||
try:
|
try:
|
||||||
await callback.message.answer(
|
await callback.message.answer(
|
||||||
get_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),
|
months=int(months),
|
||||||
traffic_gb=human_value,
|
traffic_gb=human_value,
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -93,7 +93,7 @@ async def _initiate_yk_payment(
|
|||||||
"currency": currency_code_for_yk,
|
"currency": currency_code_for_yk,
|
||||||
"status": "pending_yookassa",
|
"status": "pending_yookassa",
|
||||||
"description": payment_description,
|
"description": payment_description,
|
||||||
"subscription_duration_months": int(months),
|
"subscription_duration_months": int(months) if sale_base == "subscription" else None,
|
||||||
"sale_mode": sale_base,
|
"sale_mode": sale_base,
|
||||||
"tariff_key": sale_mode.split("@", 1)[1] if "@" in sale_mode else None,
|
"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,
|
"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:
|
try:
|
||||||
await callback.message.edit_text(
|
await callback.message.edit_text(
|
||||||
get_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),
|
months=int(months),
|
||||||
traffic_gb=_format_value(months),
|
traffic_gb=_format_value(months),
|
||||||
),
|
),
|
||||||
@@ -243,7 +243,7 @@ async def _initiate_yk_payment(
|
|||||||
try:
|
try:
|
||||||
await callback.message.answer(
|
await callback.message.answer(
|
||||||
get_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),
|
months=int(months),
|
||||||
traffic_gb=_format_value(months),
|
traffic_gb=_format_value(months),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -87,7 +87,7 @@ class CryptoPayService:
|
|||||||
"currency": self.settings.CRYPTOPAY_ASSET,
|
"currency": self.settings.CRYPTOPAY_ASSET,
|
||||||
"status": "pending_cryptopay",
|
"status": "pending_cryptopay",
|
||||||
"description": description,
|
"description": description,
|
||||||
"subscription_duration_months": int(months),
|
"subscription_duration_months": int(months) if sale_base == "subscription" else None,
|
||||||
"provider": "cryptopay",
|
"provider": "cryptopay",
|
||||||
"sale_mode": sale_mode,
|
"sale_mode": sale_mode,
|
||||||
"tariff_key": sale_mode.split("@", 1)[1] if "@" in sale_mode else None,
|
"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.")
|
logging.exception("Failed to send CryptoPay success message.")
|
||||||
|
|
||||||
# Send notification about payment
|
# 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:
|
try:
|
||||||
notification_service = NotificationService(bot, settings, i18n)
|
notification_service = NotificationService(bot, settings, i18n)
|
||||||
user = await user_dal.get_user_by_id(session, user_id)
|
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,
|
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,
|
traffic_gb=traffic_gb if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"} else None,
|
||||||
payment_provider="crypto_pay",
|
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:
|
except Exception:
|
||||||
logging.exception("Failed to send crypto_pay payment notification.")
|
logging.exception("Failed to send crypto_pay payment notification.")
|
||||||
|
|||||||
@@ -347,7 +347,7 @@ def render_payment_success(
|
|||||||
lang = _normalize_lang(language_code, settings)
|
lang = _normalize_lang(language_code, settings)
|
||||||
accent = _safe_color(settings.WEBAPP_PRIMARY_COLOR)
|
accent = _safe_color(settings.WEBAPP_PRIMARY_COLOR)
|
||||||
brand = _brand_title(settings)
|
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)
|
amount_text = _format_amount(amount, currency)
|
||||||
safe_dashboard_url = (dashboard_url or "").strip()
|
safe_dashboard_url = (dashboard_url or "").strip()
|
||||||
end_date = end_date_text or "—"
|
end_date = end_date_text or "—"
|
||||||
|
|||||||
@@ -315,6 +315,7 @@ class FreeKassaService:
|
|||||||
final_end = activation.get("end_date") if activation else None
|
final_end = activation.get("end_date") if activation else None
|
||||||
months = payment.purchased_gb or payment.subscription_duration_months or 1
|
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_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
|
applied_days = 0
|
||||||
if referral_bonus and referral_bonus.get("referee_new_end_date"):
|
if referral_bonus and referral_bonus.get("referee_new_end_date"):
|
||||||
@@ -395,10 +396,12 @@ class FreeKassaService:
|
|||||||
user_id=payment.user_id,
|
user_id=payment.user_id,
|
||||||
amount=float(payment.amount),
|
amount=float(payment.amount),
|
||||||
currency=self.default_currency,
|
currency=self.default_currency,
|
||||||
months=int(months) if sale_mode != "traffic" else 0,
|
months=int(months) if sale_base == "subscription" else 0,
|
||||||
traffic_gb=months if sale_mode == "traffic" else None,
|
traffic_gb=float(months) if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"} else None,
|
||||||
payment_provider="freekassa",
|
payment_provider="freekassa",
|
||||||
username=db_user.username if db_user else None,
|
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:
|
except Exception:
|
||||||
logging.exception("FreeKassa notification: failed to notify admins.")
|
logging.exception("FreeKassa notification: failed to notify admins.")
|
||||||
|
|||||||
@@ -324,10 +324,31 @@ class NotificationService:
|
|||||||
profile_keyboard = self._build_profile_keyboard(_, telegram_id)
|
profile_keyboard = self._build_profile_keyboard(_, telegram_id)
|
||||||
await self._send_to_log_channel(message, reply_markup=profile_keyboard)
|
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,
|
async def notify_payment_received(self, user_id: int, amount: float, currency: str,
|
||||||
months: int, payment_provider: str,
|
months: int, payment_provider: str,
|
||||||
username: Optional[str] = None,
|
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"""
|
"""Send notification about successful payment"""
|
||||||
if not self.settings.LOG_PAYMENTS:
|
if not self.settings.LOG_PAYMENTS:
|
||||||
return
|
return
|
||||||
@@ -350,14 +371,25 @@ class NotificationService:
|
|||||||
}.get(payment_provider.lower(), "💰")
|
}.get(payment_provider.lower(), "💰")
|
||||||
|
|
||||||
if traffic_gb is not None:
|
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 = _(
|
message = _(
|
||||||
"log_payment_received_traffic",
|
"log_payment_received_traffic",
|
||||||
provider_emoji=provider_emoji,
|
provider_emoji=provider_emoji,
|
||||||
user_display=user_display,
|
user_display=user_display,
|
||||||
amount=amount,
|
amount=amount,
|
||||||
currency=currency,
|
currency=currency,
|
||||||
traffic_gb=traffic_label,
|
traffic_summary=traffic_summary,
|
||||||
|
tariff_line=tariff_line,
|
||||||
payment_provider=payment_provider,
|
payment_provider=payment_provider,
|
||||||
timestamp=datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
timestamp=datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -318,10 +318,12 @@ class PlategaService:
|
|||||||
user_id=payment.user_id,
|
user_id=payment.user_id,
|
||||||
amount=float(payment.amount),
|
amount=float(payment.amount),
|
||||||
currency=currency,
|
currency=currency,
|
||||||
months=int(payment_months) if sale_mode != "traffic" else 0,
|
months=int(payment_months) if sale_base == "subscription" else 0,
|
||||||
traffic_gb=payment_months if sale_mode == "traffic" else None,
|
traffic_gb=float(payment_months) if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"} else None,
|
||||||
payment_provider="platega",
|
payment_provider="platega",
|
||||||
username=db_user.username if db_user else None,
|
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:
|
except Exception:
|
||||||
logging.exception("Platega webhook: failed to notify admins.")
|
logging.exception("Platega webhook: failed to notify admins.")
|
||||||
|
|||||||
@@ -314,10 +314,12 @@ class SeverPayService:
|
|||||||
user_id=payment.user_id,
|
user_id=payment.user_id,
|
||||||
amount=float(payment.amount),
|
amount=float(payment.amount),
|
||||||
currency=payment.currency,
|
currency=payment.currency,
|
||||||
months=int(payment_months) if sale_mode != "traffic" else 0,
|
months=int(payment_months) if sale_base == "subscription" else 0,
|
||||||
traffic_gb=payment_months if sale_mode == "traffic" else None,
|
traffic_gb=float(payment_months) if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"} else None,
|
||||||
payment_provider="severpay",
|
payment_provider="severpay",
|
||||||
username=db_user.username if db_user else None,
|
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:
|
except Exception:
|
||||||
logging.exception("SeverPay webhook: failed to notify admins.")
|
logging.exception("SeverPay webhook: failed to notify admins.")
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ class StarsService:
|
|||||||
"currency": "XTR",
|
"currency": "XTR",
|
||||||
"status": "pending_stars",
|
"status": "pending_stars",
|
||||||
"description": description,
|
"description": description,
|
||||||
"subscription_duration_months": int(months),
|
"subscription_duration_months": int(months) if sale_base == "subscription" else None,
|
||||||
"provider": "telegram_stars",
|
"provider": "telegram_stars",
|
||||||
"sale_mode": sale_mode,
|
"sale_mode": sale_mode,
|
||||||
"tariff_key": sale_mode.split("@", 1)[1] if "@" in sale_mode else None,
|
"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,
|
months=int(months) if sale_base == "subscription" else 0,
|
||||||
payment_provider="stars",
|
payment_provider="stars",
|
||||||
username=user.username if user else None,
|
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:
|
except Exception as e:
|
||||||
logging.error(f"Failed to send stars payment notification: {e}")
|
logging.error(f"Failed to send stars payment notification: {e}")
|
||||||
|
|||||||
@@ -85,10 +85,25 @@ class SubscriptionService:
|
|||||||
return list(dict.fromkeys(squads))
|
return list(dict.fromkeys(squads))
|
||||||
return self.settings.parsed_user_squad_uuids
|
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:
|
if tariff:
|
||||||
return int(tariff.monthly_bytes + max(0, topup_balance_bytes))
|
baseline = int(tariff.monthly_bytes or 0)
|
||||||
return self.settings.user_traffic_limit_bytes
|
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:
|
def _premium_limit_for_tariff(self, tariff: Optional[Tariff], topup_balance_bytes: int = 0) -> int:
|
||||||
if not tariff:
|
if not tariff:
|
||||||
@@ -100,11 +115,35 @@ class SubscriptionService:
|
|||||||
premium_baseline_bytes: int,
|
premium_baseline_bytes: int,
|
||||||
premium_topup_balance_bytes: int = 0,
|
premium_topup_balance_bytes: int = 0,
|
||||||
premium_topup_used_bytes: int = 0,
|
premium_topup_used_bytes: int = 0,
|
||||||
|
premium_bonus_bytes: int = 0,
|
||||||
) -> int:
|
) -> int:
|
||||||
return int(premium_baseline_bytes or 0) + max(0, int(premium_topup_balance_bytes or 0)) + max(
|
return (
|
||||||
0, int(premium_topup_used_bytes or 0)
|
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]:
|
async def premium_access_for_tariff(self, tariff: Optional[Tariff]) -> Dict[str, Any]:
|
||||||
if not tariff or not tariff.premium_squad_uuids:
|
if not tariff or not tariff.premium_squad_uuids:
|
||||||
return {"squad_uuids": [], "squad_labels": [], "node_labels": []}
|
return {"squad_uuids": [], "squad_labels": [], "node_labels": []}
|
||||||
@@ -949,7 +988,17 @@ class SubscriptionService:
|
|||||||
|
|
||||||
purchase_bytes = self.gb_to_bytes(traffic_gb)
|
purchase_bytes = self.gb_to_bytes(traffic_gb)
|
||||||
new_topup_balance = int(sub.topup_balance_bytes or 0) + purchase_bytes
|
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 = (
|
base_hwid_limit = (
|
||||||
int(sub.hwid_device_limit)
|
int(sub.hwid_device_limit)
|
||||||
if sub.hwid_device_limit is not None
|
if sub.hwid_device_limit is not None
|
||||||
@@ -1084,6 +1133,277 @@ class SubscriptionService:
|
|||||||
"tariff_key": tariff.key,
|
"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(
|
async def activate_hwid_device_topup(
|
||||||
self,
|
self,
|
||||||
session: AsyncSession,
|
session: AsyncSession,
|
||||||
@@ -1272,7 +1592,16 @@ class SubscriptionService:
|
|||||||
|
|
||||||
if target.billing_model == "period":
|
if target.billing_model == "period":
|
||||||
update_data["tier_baseline_bytes"] = target.monthly_bytes
|
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["period_start_at"] = None
|
||||||
update_data["effective_monthly_price_rub"] = target.period_price(1, "rub") or target.min_period_price_rub()
|
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:
|
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)
|
converted_bytes = self.gb_to_bytes(converted_gb)
|
||||||
old_topup = int(sub.topup_balance_bytes or 0)
|
old_topup = int(sub.topup_balance_bytes or 0)
|
||||||
new_balance = old_topup + converted_bytes
|
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 {}
|
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)
|
current_used, _, _ = self._extract_panel_traffic_details(panel_user)
|
||||||
|
cur_used_int = int(current_used or 0)
|
||||||
update_data.update(
|
update_data.update(
|
||||||
{
|
{
|
||||||
"end_date": self._far_future(),
|
"end_date": self._far_future(),
|
||||||
"period_start_at": None,
|
"period_start_at": None,
|
||||||
"tier_baseline_bytes": 0,
|
"tier_baseline_bytes": 0,
|
||||||
"topup_balance_bytes": new_balance,
|
"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,
|
"traffic_used_bytes": current_used,
|
||||||
"effective_monthly_price_rub": None,
|
"effective_monthly_price_rub": None,
|
||||||
"auto_renew_enabled": False,
|
"auto_renew_enabled": False,
|
||||||
@@ -1575,7 +1913,15 @@ class SubscriptionService:
|
|||||||
premium_topup_used_bytes,
|
premium_topup_used_bytes,
|
||||||
)
|
)
|
||||||
effective_monthly_price = float(payment_amount) / max(1, months_int)
|
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)
|
base_hwid_limit = self._base_hwid_limit_for_tariff(tariff)
|
||||||
effective_hwid_limit = self._effective_hwid_limit(base_hwid_limit, extra_hwid_devices)
|
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)
|
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,
|
"tariff_key": tariff.key if tariff else None,
|
||||||
"tier_baseline_bytes": tier_baseline_bytes,
|
"tier_baseline_bytes": tier_baseline_bytes,
|
||||||
"topup_balance_bytes": topup_balance_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_baseline_bytes": premium_baseline_bytes,
|
||||||
"premium_topup_balance_bytes": premium_topup_balance_bytes,
|
"premium_topup_balance_bytes": premium_topup_balance_bytes,
|
||||||
"premium_topup_used_bytes": premium_topup_used_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_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_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_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 {
|
return {
|
||||||
"user_id": panel_user_data.get("uuid"),
|
"user_id": panel_user_data.get("uuid"),
|
||||||
@@ -1922,14 +2274,19 @@ class SubscriptionService:
|
|||||||
"billing_model": billing_model_display,
|
"billing_model": billing_model_display,
|
||||||
"tier_baseline_bytes": local_active_sub.tier_baseline_bytes if local_active_sub else None,
|
"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,
|
"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_baseline_bytes": premium_baseline,
|
||||||
"premium_topup_balance_bytes": premium_topup_balance,
|
"premium_topup_balance_bytes": premium_topup_balance,
|
||||||
"premium_topup_used_bytes": premium_topup_used,
|
"premium_topup_used_bytes": premium_topup_used,
|
||||||
"premium_used_bytes": local_active_sub.premium_used_bytes if local_active_sub else 0,
|
"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_limit_bytes": self._premium_effective_limit_bytes(
|
||||||
premium_baseline,
|
premium_baseline,
|
||||||
premium_topup_balance,
|
premium_topup_balance,
|
||||||
premium_topup_used,
|
premium_topup_used,
|
||||||
|
premium_bonus_bytes,
|
||||||
),
|
),
|
||||||
"premium_is_limited": bool(local_active_sub.premium_is_limited) if local_active_sub else False,
|
"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,
|
"premium_period_start_at": getattr(local_active_sub, "premium_period_start_at", None) if local_active_sub else None,
|
||||||
|
|||||||
+187
-39
@@ -4,7 +4,8 @@ from datetime import datetime, timezone
|
|||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from aiogram import Bot
|
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 import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy.orm import sessionmaker
|
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.services.subscription_service import SubscriptionService
|
||||||
from bot.utils.date_utils import month_start
|
from bot.utils.date_utils import month_start
|
||||||
from config.settings import Settings
|
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
|
from db.models import Subscription
|
||||||
|
|
||||||
PREMIUM_WARNING_LEVEL_OFFSET = 1000
|
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:
|
class TariffTrafficWorker:
|
||||||
@@ -39,6 +43,47 @@ class TariffTrafficWorker:
|
|||||||
self._stopped = asyncio.Event()
|
self._stopped = asyncio.Event()
|
||||||
self._premium_nodes_cache = {}
|
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:
|
async def run(self) -> None:
|
||||||
if not self.settings.tariffs_config:
|
if not self.settings.tariffs_config:
|
||||||
return
|
return
|
||||||
@@ -108,7 +153,22 @@ class TariffTrafficWorker:
|
|||||||
) -> None:
|
) -> None:
|
||||||
if str(panel_strategy or "").upper() == "MONTH":
|
if str(panel_strategy or "").upper() == "MONTH":
|
||||||
return
|
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(
|
payload = self.subscription_service._build_panel_update_payload(
|
||||||
panel_user_uuid=sub.panel_user_uuid,
|
panel_user_uuid=sub.panel_user_uuid,
|
||||||
expire_at=sub.end_date,
|
expire_at=sub.end_date,
|
||||||
@@ -131,6 +191,8 @@ class TariffTrafficWorker:
|
|||||||
*,
|
*,
|
||||||
warning_period_start: Optional[datetime] = None,
|
warning_period_start: Optional[datetime] = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
|
if bool(getattr(sub, "regular_unlimited_override", False)):
|
||||||
|
return
|
||||||
used_val = int(used or sub.traffic_used_bytes or 0)
|
used_val = int(used or sub.traffic_used_bytes or 0)
|
||||||
limit_val = int(limit or sub.traffic_limit_bytes or 0)
|
limit_val = int(limit or sub.traffic_limit_bytes or 0)
|
||||||
if limit_val <= 0:
|
if limit_val <= 0:
|
||||||
@@ -159,22 +221,35 @@ class TariffTrafficWorker:
|
|||||||
)
|
)
|
||||||
if self.bot:
|
if self.bot:
|
||||||
try:
|
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)
|
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:
|
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:
|
else:
|
||||||
text = "Трафик закончился. Доступ временно ограничен до сброса или докупки пакета."
|
text = _(
|
||||||
markup = InlineKeyboardMarkup(
|
"traffic_warning_regular_depleted",
|
||||||
inline_keyboard=[
|
tariff_name=tariff_name,
|
||||||
[
|
**usage,
|
||||||
InlineKeyboardButton(
|
|
||||||
text="Докупить трафик",
|
|
||||||
callback_data="tariff_topup:list",
|
|
||||||
)
|
)
|
||||||
]
|
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:
|
except Exception:
|
||||||
logging.exception("Failed to send traffic warning to user %s", sub.user_id)
|
logging.exception("Failed to send traffic warning to user %s", sub.user_id)
|
||||||
if ratio >= 1.0 and not sub.is_throttled:
|
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_baseline = int(tariff.premium_monthly_bytes or 0)
|
||||||
premium_topup_balance = int(sub.premium_topup_balance_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_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
|
# Admin-side overrides for free gifted premium traffic.
|
||||||
if premium_limit <= 0:
|
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
|
return
|
||||||
|
|
||||||
node_uuids = await self._premium_node_uuids_for_tariff(tariff)
|
node_uuids = await self._premium_node_uuids_for_tariff(tariff)
|
||||||
@@ -235,14 +313,21 @@ class TariffTrafficWorker:
|
|||||||
if premium_used is None:
|
if premium_used is None:
|
||||||
return
|
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)
|
delta_overflow = max(0, overflow - premium_topup_used)
|
||||||
consume_from_topup = min(premium_topup_balance, delta_overflow)
|
consume_from_topup = min(premium_topup_balance, delta_overflow)
|
||||||
if consume_from_topup > 0:
|
if consume_from_topup > 0:
|
||||||
premium_topup_balance -= consume_from_topup
|
premium_topup_balance -= consume_from_topup
|
||||||
premium_topup_used += 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
|
||||||
|
|
||||||
|
if premium_unlimited_override:
|
||||||
|
should_limit = False
|
||||||
|
else:
|
||||||
should_limit = premium_used >= premium_limit
|
should_limit = premium_used >= premium_limit
|
||||||
changed = (
|
changed = (
|
||||||
int(sub.premium_baseline_bytes or 0) != premium_baseline
|
int(sub.premium_baseline_bytes or 0) != premium_baseline
|
||||||
@@ -258,6 +343,7 @@ class TariffTrafficWorker:
|
|||||||
sub.premium_used_bytes = int(premium_used)
|
sub.premium_used_bytes = int(premium_used)
|
||||||
sub.premium_is_limited = bool(should_limit)
|
sub.premium_is_limited = bool(should_limit)
|
||||||
sub.premium_period_start_at = premium_period_start
|
sub.premium_period_start_at = premium_period_start
|
||||||
|
if not premium_unlimited_override:
|
||||||
await self._maybe_warn_premium_squad_limit(
|
await self._maybe_warn_premium_squad_limit(
|
||||||
session,
|
session,
|
||||||
sub,
|
sub,
|
||||||
@@ -307,9 +393,67 @@ class TariffTrafficWorker:
|
|||||||
) -> None:
|
) -> None:
|
||||||
if limit <= 0:
|
if limit <= 0:
|
||||||
return
|
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]))
|
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:
|
for level in levels:
|
||||||
|
if level >= 100:
|
||||||
|
continue
|
||||||
if ratio < level / 100:
|
if ratio < level / 100:
|
||||||
continue
|
continue
|
||||||
storage_level = PREMIUM_WARNING_LEVEL_OFFSET + int(level)
|
storage_level = PREMIUM_WARNING_LEVEL_OFFSET + int(level)
|
||||||
@@ -331,34 +475,38 @@ class TariffTrafficWorker:
|
|||||||
if not self.bot:
|
if not self.bot:
|
||||||
continue
|
continue
|
||||||
try:
|
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)
|
access = await self.subscription_service.premium_access_for_tariff(tariff)
|
||||||
labels = access.get("node_labels") or access.get("squad_labels") or []
|
labels = access.get("node_labels") or access.get("squad_labels") or []
|
||||||
if labels:
|
if labels:
|
||||||
visible = labels[:8]
|
visible = [hd.quote(str(x)) for x in labels[:8]]
|
||||||
servers = "\n".join(f"• {label}" for label in visible)
|
servers = "\n".join(f"• {label}" for label in visible)
|
||||||
if len(labels) > len(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:
|
else:
|
||||||
servers = "• premium-серверы тарифа"
|
servers = _("traffic_warning_premium_generic_servers")
|
||||||
text = (
|
left_pct = max(0, 100 - int(level))
|
||||||
"⚠️ Отдельный лимит premium-серверов почти закончился.\n\n"
|
usage = self._usage_placeholders(used_val, limit_val)
|
||||||
f"Тариф: {tariff.name(self.settings.DEFAULT_LANGUAGE)}\n"
|
text = _(
|
||||||
f"Использовано: {self._fmt_bytes(used)} из {self._fmt_bytes(limit)} ({level}%).\n\n"
|
"traffic_warning_premium_almost",
|
||||||
"Этот лимит действует на:\n"
|
tariff_name=hd.quote(str(tariff.name(user_lang))),
|
||||||
f"{servers}\n\n"
|
left_pct=left_pct,
|
||||||
"Можно докупить premium-трафик. Докупленный остаток переносится на следующие месяцы, пока не израсходуется."
|
servers=servers,
|
||||||
|
**usage,
|
||||||
)
|
)
|
||||||
markup = InlineKeyboardMarkup(
|
markup = self._traffic_topup_markup(user_lang, "premium")
|
||||||
inline_keyboard=[
|
await self.bot.send_message(
|
||||||
[
|
sub.user_id,
|
||||||
InlineKeyboardButton(
|
text,
|
||||||
text="Докупить premium-трафик",
|
reply_markup=markup,
|
||||||
callback_data="tariff_topup:list",
|
parse_mode="HTML",
|
||||||
)
|
)
|
||||||
]
|
|
||||||
]
|
|
||||||
)
|
|
||||||
await self.bot.send_message(sub.user_id, text, reply_markup=markup)
|
|
||||||
except Exception:
|
except Exception:
|
||||||
logging.exception("Failed to send premium traffic warning to user %s", sub.user_id)
|
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_subscription_days_to_add = State()
|
||||||
waiting_for_direct_message_to_user = State()
|
waiting_for_direct_message_to_user = State()
|
||||||
waiting_for_user_delete_confirmation = State()
|
waiting_for_user_delete_confirmation = State()
|
||||||
|
waiting_for_premium_override_bonus_gb = State()
|
||||||
|
waiting_for_traffic_grant_gb = State()
|
||||||
|
|
||||||
# Ads campaigns
|
# Ads campaigns
|
||||||
waiting_for_ad_source = State()
|
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:
|
def _migration_0017_reconcile_legacy_admin_api_schema(connection: Connection) -> None:
|
||||||
"""Backfill columns required by admin user detail API on legacy databases.
|
"""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",
|
description="Reconcile legacy DB schema for admin user detail endpoint compatibility",
|
||||||
upgrade=_migration_0017_reconcile_legacy_admin_api_schema,
|
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_used_bytes = Column(BigInteger, nullable=False, default=0)
|
||||||
premium_is_limited = Column(Boolean, nullable=False, default=False, index=True)
|
premium_is_limited = Column(Boolean, nullable=False, default=False, index=True)
|
||||||
premium_period_start_at = Column(DateTime(timezone=True), nullable=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)
|
period_start_at = Column(DateTime(timezone=True), nullable=True)
|
||||||
is_throttled = Column(Boolean, nullable=False, default=False, index=True)
|
is_throttled = Column(Boolean, nullable=False, default=False, index=True)
|
||||||
effective_monthly_price_rub = Column(Numeric, nullable=True)
|
effective_monthly_price_rub = Column(Numeric, nullable=True)
|
||||||
|
|||||||
+93
-1
@@ -289,6 +289,38 @@
|
|||||||
"admin_user_view_logs_button": "📜 User Actions",
|
"admin_user_view_logs_button": "📜 User Actions",
|
||||||
"admin_user_refresh_button": "🔄 Refresh",
|
"admin_user_refresh_button": "🔄 Refresh",
|
||||||
"admin_user_delete_button": "❌ Delete User",
|
"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_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_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.",
|
"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_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_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": "{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_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_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}",
|
"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_traffic_title": "Premium servers",
|
||||||
"wa_premium_reset_monthly": "Separate monthly limit",
|
"wa_premium_reset_monthly": "Separate monthly limit",
|
||||||
"wa_premium_access_limited": "Premium access is temporarily limited",
|
"wa_premium_access_limited": "Premium access is temporarily limited",
|
||||||
|
"wa_premium_unlimited": "Unlimited premium access",
|
||||||
"wa_premium_left": "Left",
|
"wa_premium_left": "Left",
|
||||||
"wa_premium_topup_balance": "Top-up balance",
|
"wa_premium_topup_balance": "Top-up balance",
|
||||||
"wa_premium_servers_limited": "Separate limit applies to",
|
"wa_premium_servers_limited": "Separate limit applies to",
|
||||||
@@ -860,6 +907,13 @@
|
|||||||
"admin_status": "Status",
|
"admin_status": "Status",
|
||||||
"admin_date": "Date",
|
"admin_date": "Date",
|
||||||
"admin_payments_empty": "No payments",
|
"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_logs_user_filter_placeholder": "Filter by user ID",
|
||||||
"admin_apply": "Apply",
|
"admin_apply": "Apply",
|
||||||
"admin_reset": "Reset",
|
"admin_reset": "Reset",
|
||||||
@@ -988,6 +1042,44 @@
|
|||||||
"admin_user_label_main_traffic": "Main Traffic",
|
"admin_user_label_main_traffic": "Main Traffic",
|
||||||
"admin_user_traffic_left": "Left: {left}",
|
"admin_user_traffic_left": "Left: {left}",
|
||||||
"admin_user_label_premium_squads": "Premium Squads",
|
"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_no_active_subscription": "No active subscription",
|
||||||
"admin_user_history_title": "Subscription History · {count}",
|
"admin_user_history_title": "Subscription History · {count}",
|
||||||
"admin_user_history_no_tariff": "No tariff",
|
"admin_user_history_no_tariff": "No tariff",
|
||||||
|
|||||||
+93
-1
@@ -289,6 +289,38 @@
|
|||||||
"admin_user_view_logs_button": "📜 Логи",
|
"admin_user_view_logs_button": "📜 Логи",
|
||||||
"admin_user_refresh_button": "🔄 Обновить",
|
"admin_user_refresh_button": "🔄 Обновить",
|
||||||
"admin_user_delete_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_not_allowed": "❌ У вас нет прав для удаления пользователей.",
|
||||||
"admin_user_delete_confirmation_prompt": "⚠️ Вы хотите полностью удалить пользователя {user_id}.\n\nОтправьте точный Telegram ID этого пользователя, чтобы подтвердить удаление.\nЛюбой другой ответ отменит операцию.",
|
"admin_user_delete_confirmation_prompt": "⚠️ Вы хотите полностью удалить пользователя {user_id}.\n\nОтправьте точный Telegram ID этого пользователя, чтобы подтвердить удаление.\nЛюбой другой ответ отменит операцию.",
|
||||||
"admin_user_delete_state_missing": "⚠️ Нет активной операции удаления. Начните заново.",
|
"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_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_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": "{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_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_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}",
|
"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_traffic_title": "Premium-серверы",
|
||||||
"wa_premium_reset_monthly": "Отдельный лимит на месяц",
|
"wa_premium_reset_monthly": "Отдельный лимит на месяц",
|
||||||
"wa_premium_access_limited": "Доступ к premium временно ограничен",
|
"wa_premium_access_limited": "Доступ к premium временно ограничен",
|
||||||
|
"wa_premium_unlimited": "Безлимит на premium-сервера",
|
||||||
"wa_premium_left": "Осталось",
|
"wa_premium_left": "Осталось",
|
||||||
"wa_premium_topup_balance": "Докупленный остаток",
|
"wa_premium_topup_balance": "Докупленный остаток",
|
||||||
"wa_premium_servers_limited": "Отдельный лимит действует на",
|
"wa_premium_servers_limited": "Отдельный лимит действует на",
|
||||||
@@ -860,6 +907,13 @@
|
|||||||
"admin_status": "Статус",
|
"admin_status": "Статус",
|
||||||
"admin_date": "Дата",
|
"admin_date": "Дата",
|
||||||
"admin_payments_empty": "Нет платежей",
|
"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_logs_user_filter_placeholder": "Фильтр по ID пользователя",
|
||||||
"admin_apply": "Применить",
|
"admin_apply": "Применить",
|
||||||
"admin_reset": "Сбросить",
|
"admin_reset": "Сбросить",
|
||||||
@@ -988,6 +1042,44 @@
|
|||||||
"admin_user_label_main_traffic": "Основной трафик",
|
"admin_user_label_main_traffic": "Основной трафик",
|
||||||
"admin_user_traffic_left": "Осталось: {left}",
|
"admin_user_traffic_left": "Осталось: {left}",
|
||||||
"admin_user_label_premium_squads": "Premium-сквады",
|
"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_no_active_subscription": "Активной подписки нет",
|
||||||
"admin_user_history_title": "История подписок · {count}",
|
"admin_user_history_title": "История подписок · {count}",
|
||||||
"admin_user_history_no_tariff": "Без тарифа",
|
"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_balance_bytes, int(1.5 * (1024**3)))
|
||||||
self.assertEqual(sub.premium_topup_used_bytes, 0)
|
self.assertEqual(sub.premium_topup_used_bytes, 0)
|
||||||
self.assertEqual(sub.premium_period_start_at, datetime(2026, 6, 1, tzinfo=timezone.utc))
|
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