Compare commits

...
38 Commits
Author SHA1 Message Date
3252a8andGitHub 9393962510 Merge pull request #22 from 3252a8/dev
Multicurrency (future-proof feature), panel dry-run mode (dev feature), install telemetry and admin user tools
2026-06-01 22:55:30 +03:00
3252a8 2c32e81638 fix: build dry-run log entirely from constant labels
CodeQL still traced taint through the previous helpers: the endpoint
regex fallback returned source-derived text and the payload dict keys
flowed verbatim into the log. Resolve both by sourcing every logged
token from literal lookup tables — endpoints map to constant path
labels and payload keys map through a field-name allow-list, with
unknown keys/paths collapsing to <field>/<other>. No source-derived
string (key, value or id) reaches the logger.
2026-06-01 22:53:48 +03:00
3252a8 0032c1804b fix: drop clear-text values and ids from panel dry-run logs
Rebuild logged endpoints from constant path templates and reduce every
payload leaf to a JSON type token, so user/squad UUIDs and PII (email,
telegramId) can never reach the dry-run log as clear text. Resolves
CodeQL py/clear-text-logging-sensitive-data findings.
2026-06-01 22:48:21 +03:00
3252a8andGitHub a98ea65ef4 Merge pull request #21 from 3252a8/feature/ui-improvements
Improve admin UX, trial reset behavior, and channel subscription checks
2026-06-01 22:29:24 +03:00
3252a8 5218ede0f1 fix: normalize required channel checks 2026-06-01 22:13:35 +03:00
3252a8 5b19ba2c2f fix: keep deeplink gateway open during app prompt 2026-06-01 20:34:49 +03:00
3252a8 687fc03e8c fix: reset trial eligibility from web admin 2026-06-01 19:30:16 +03:00
3252a8 74272039c5 feat: add login language selector 2026-06-01 19:06:27 +03:00
3252a8 eadb86faf2 fix: restore mobile admin language select 2026-06-01 18:33:45 +03:00
3252a8 4fa18a1262 fix: enlarge mobile renewal button 2026-06-01 18:17:02 +03:00
3252a8 7d3178bd48 feat: improve admin user filters 2026-06-01 18:04:08 +03:00
3252a8andGitHub 88d99fb578 Merge pull request #20 from 3252a8/feature/users-details
Enhance admin users and subscription workflows
2026-06-01 15:59:52 +03:00
3252a8 8366a73575 style: format admin users api 2026-06-01 15:58:28 +03:00
3252a8 dcdddeb1c5 feat: add sortable admin user columns 2026-06-01 15:53:03 +03:00
3252a8 d7840d3a86 feat: expose admin user list metrics 2026-06-01 15:52:54 +03:00
3252a8 6a40d0c9ce feat: add expired subscription broadcast target 2026-06-01 15:05:10 +03:00
3252a8andGitHub 3d579b12d4 Merge pull request #19 from 3252a8/feature/telemetry
Feature/telemetry
2026-06-01 14:24:28 +03:00
3252a8 0d932b0915 style: ruff format panel dry-run service 2026-06-01 14:22:33 +03:00
3252a8 2b763efef9 fix: redact secrets and ids from panel dry-run logs
CodeQL flagged clear-text logging of sensitive information in the panel
dry-run logger: it dumped the full request payload (which can include
proxy credentials like trojanPassword/ssPassword and PII such as email
and telegramId) and the raw endpoint (embedding user UUIDs).

Recursively redact values under sensitive keys before building the
payload preview, and mask opaque id-like segments in logged endpoints.
2026-06-01 14:20:38 +03:00
3252a8 a0ea2261f4 feat: anonymous opt-out install telemetry beacon
Add a once-a-day anonymous heartbeat (PostHog) so maintainers can see
active installs and version/OS breakdowns. Self-hosted friendly: opt out
via TELEMETRY_ENABLED in .env or the Admin -> System toggle (applied
without a restart), or by clearing the endpoint/key.

- Share version resolution in bot/utils/app_version.py so the admin
  sidebar and the beacon report the same build version
- TelemetryWorker sends an opaque install id plus coarse facts only
  (version, OS/arch, python, locale, enabled providers, user-count
  range); never tokens, domains or user data
- Register the worker in main_worker.py behind a Redis single-flight lock
- Expose TELEMETRY_* settings and an Admin -> System manifest toggle
- Document the payload and opt-out in docs/configuration/telemetry.md
- Cover bucketing, payload shape and anonymity with tests
2026-06-01 14:14:02 +03:00
3252a8 5bb1400917 chore: improve tariff admin layout 2026-06-01 11:55:41 +03:00
3252a8andGitHub 63ec3a6152 Merge pull request #18 from 3252a8/feature/multicurrency
Add ability to use different currency than rub
2026-06-01 11:26:18 +03:00
3252a8 3896c455b8 fix: decouple docs email previews from bot deps 2026-06-01 11:23:45 +03:00
3252a8 d97afbec18 fix: apply ruff formatting 2026-06-01 11:16:28 +03:00
3252a8 21079f78dc fix: satisfy ruff line length 2026-06-01 11:11:15 +03:00
3252a8 3e922d8edb docs: document panel dry-run development mode 2026-06-01 10:35:22 +03:00
3252a8 391487811b feat: add remnawave panel dry-run mode 2026-06-01 10:32:38 +03:00
3252a8 939bc37995 Merge branch 'dev' into feature/multicurrency 2026-06-01 08:18:25 +03:00
3252a8 c2344824dc feat: show referral relationships in admin user cards 2026-06-01 00:36:05 +03:00
3252a8 7cf1d577f3 fix: restore pending email code screens 2026-05-31 23:27:38 +03:00
3252a8 ecf779763c feat: audit outbound user notifications 2026-05-31 23:05:40 +03:00
3252a8 1578a9da36 fix: align traffic top-up flows and unlimited overrides 2026-05-31 23:05:29 +03:00
3252a8 80e5f0c80d feat: add multicurrency tariff payments 2026-05-31 22:17:28 +03:00
3252a8 df8f2636d2 docs: render email previews from templates 2026-05-31 15:08:22 +03:00
3252a8 45543983c2 fix: run migrations after database restore 2026-05-31 14:57:32 +03:00
3252a8 b531d4de3b docs: add email preview demo section 2026-05-31 14:41:40 +03:00
3252a8 eda5d3e633 fix: localize support email templates 2026-05-31 14:41:26 +03:00
3252a8 e4ef52df8b ci: tag dev images only with dev (drop per-commit dev-<sha> tags) 2026-05-31 07:59:18 +03:00
121 changed files with 7431 additions and 734 deletions
+12
View File
@@ -73,3 +73,15 @@ FRONTEND_PORT=8082
# Reverse proxy IPs/CIDRs trusted for X-Forwarded-For.
# Keep loopback for local proxy; add your proxy network if needed.
TRUSTED_PROXIES=127.0.0.1,::1
# ─── Anonymous install telemetry (opt-out) ──────────────────────────────
# Once a day the worker sends a single anonymous "heartbeat" so the project
# maintainer can see how many installs are active and which versions/OSes are
# used. It contains an opaque random install id and coarse facts only:
# version, OS/arch, Python version, language, enabled payment providers and a
# user-count RANGE (e.g. "51-200"). No bot token, domain, user data or any
# personal information is ever sent. Full details: docs/configuration/telemetry.md
#
# Set to False to disable, or toggle it any time in Admin -> System ->
# "Anonymous install analytics" (applies without a restart).
TELEMETRY_ENABLED=True
-1
View File
@@ -93,7 +93,6 @@ jobs:
ghcr.io/3252a8/${{ matrix.image }}
tags: |
type=raw,value=dev,enable=${{ inputs.tag_mode == 'dev' }}
type=sha,prefix=dev-,format=short,enable=${{ inputs.tag_mode == 'dev' }}
type=raw,value=latest,enable=${{ inputs.tag_mode == 'release' }}
type=raw,value=${{ steps.version.outputs.version }},enable=${{ inputs.tag_mode == 'release' }}
+6 -1
View File
@@ -11,6 +11,7 @@ from bot.services.email_auth_service import EmailAuthService
from bot.services.lknpd_service import LknpdService
from bot.services.notification_service import NotificationService
from bot.services.panel_api_service import PanelApiService
from bot.services.panel_dry_run_api_service import PanelDryRunApiService
from bot.services.panel_webhook_service import PanelWebhookService
from bot.services.promo_code_service import PromoCodeService
from bot.services.referral_service import ReferralService
@@ -26,7 +27,11 @@ def build_core_services(
i18n: JsonI18n,
bot_username_for_default_return: str,
):
panel_service = PanelApiService(settings)
panel_service = (
PanelDryRunApiService(settings)
if bool(getattr(settings, "panel_dry_run_enabled", False))
else PanelApiService(settings)
)
subscription_service = SubscriptionService(settings, panel_service, bot, i18n)
referral_service = ReferralService(settings, subscription_service, bot, i18n)
promo_code_service = PromoCodeService(settings, subscription_service, bot, i18n)
@@ -37,7 +37,7 @@ from bot.services.settings_override_service import (
from bot.utils import MessageContent, send_message_via_queue
from bot.utils.message_queue import get_queue_manager
from config.settings import Settings
from config.tariffs_config import TariffsConfig
from config.tariffs_config import TariffsConfig, default_payment_currency_code_for_settings
from db.dal import (
ad_dal,
app_settings_dal,
@@ -169,12 +169,11 @@ async def admin_backups_restore_route(request: web.Request) -> web.Response:
except (OSError, subprocess.SubprocessError, TimeoutError) as exc:
logger.exception("Backup restore failed")
return _error(500, "backup_restore_failed", str(exc))
if result.database_restored:
finally:
try:
from db import database_setup
if database_setup.async_engine is not None:
if restore_database and database_setup.async_engine is not None:
await database_setup.async_engine.dispose()
except Exception:
logger.exception("Failed to dispose DB engine after backup restore")
@@ -9,7 +9,7 @@ async def admin_broadcast_route(request: web.Request) -> web.Response:
target = str(payload.get("target") or "all").strip().lower()
if not text:
return _error(400, "empty_text")
if target not in {"all", "active", "inactive"}:
if target not in {"all", "active", "inactive", "expired"}:
target = "all"
queue_manager = get_queue_manager()
@@ -22,6 +22,8 @@ async def admin_broadcast_route(request: web.Request) -> web.Response:
user_ids = await user_dal.get_user_ids_with_active_subscription(session)
elif target == "inactive":
user_ids = await user_dal.get_user_ids_without_active_subscription(session)
elif target == "expired":
user_ids = await user_dal.get_user_ids_with_expired_subscription(session)
else:
user_ids = await user_dal.get_all_active_user_ids_for_broadcast(session)
@@ -9,6 +9,7 @@ def setup_admin_routes(app: web.Application) -> None:
router.add_get("/api/admin/users", admin_users_list_route)
router.add_get("/api/admin/users/{user_id:-?\\d+}", admin_user_detail_route)
router.add_get("/api/admin/users/{user_id:-?\\d+}/referrals", admin_user_referrals_route)
router.add_get("/api/admin/users/{user_id:-?\\d+}/avatar", admin_user_avatar_route)
router.add_post("/api/admin/users/{user_id:-?\\d+}/ban", admin_user_ban_route)
router.add_post("/api/admin/users/{user_id:-?\\d+}/message", admin_user_message_route)
+1 -1
View File
@@ -34,7 +34,7 @@ async def admin_stats_route(request: web.Request) -> web.Response:
except Exception: # pragma: no cover - defensive
payload["queue"] = None
payload["currency_symbol"] = settings.DEFAULT_CURRENCY_SYMBOL or "RUB"
payload["currency_symbol"] = default_payment_currency_code_for_settings(settings)
return _ok(payload)
+54 -1
View File
@@ -21,9 +21,14 @@ async def admin_tariffs_get_route(request: web.Request) -> web.Response:
"path": str(path),
"catalog": {
"default_tariff": "",
"default_currency": "rub",
"topup_packages_default": {"rub": [], "stars": []},
"tariffs": [],
},
"provider_currency_support": _provider_currency_support_payload(
settings,
request.app,
),
}
)
@@ -32,6 +37,7 @@ async def admin_tariffs_get_route(request: web.Request) -> web.Response:
"exists": True,
"path": str(path),
"catalog": _tariffs_config_payload(config),
"provider_currency_support": _provider_currency_support_payload(settings, request.app),
}
)
@@ -58,4 +64,51 @@ async def admin_tariffs_save_route(request: web.Request) -> web.Response:
await refresh_webapp_runtime_after_settings_change(request, updates={}, deletes=[])
return _ok({"exists": True, "path": str(path), "catalog": _tariffs_config_payload(config)})
return _ok(
{
"exists": True,
"path": str(path),
"catalog": _tariffs_config_payload(config),
"provider_currency_support": _provider_currency_support_payload(settings, request.app),
}
)
def _provider_currency_support_payload(
settings: Settings,
app: web.Application,
) -> List[Dict[str, Any]]:
from bot.payment_providers import iter_provider_specs, resolve_provider_presentation
default_currency = default_payment_currency_code_for_settings(settings)
providers: List[Dict[str, Any]] = []
for spec in iter_provider_specs():
presentation = resolve_provider_presentation(spec, settings)
supported = spec.supported_currency_codes(settings)
providers.append(
{
"id": spec.id,
"provider_key": spec.provider_key,
"label": presentation.webapp_label or spec.label,
"telegram_label": presentation.telegram_label,
"icon": presentation.webapp_icon,
"enabled": spec.is_effectively_enabled(settings),
"configured": spec.is_service_configured(app),
"admin_only": spec.is_admin_only_enabled(settings),
"price_source": spec.price_source,
"currencies": list(supported) if supported is not None else None,
"accepts_any_currency": supported is None,
"supports_default_currency": spec.is_usable_for_payment_currency(
settings,
default_currency,
),
"directly_supports_default_currency": spec.supports_currency(
settings,
default_currency,
),
"default_currency": default_currency,
"note": spec.currency_support_note,
"docs_url": spec.currency_support_url,
}
)
return providers
+247 -18
View File
@@ -16,6 +16,7 @@ import hashlib
from html import escape as html_escape
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
from sqlalchemy.orm import aliased
from bot.app.web.webapp.cache_helpers import invalidate_webapp_user_caches
from bot.infra.redis import cache_delete_pattern, redis_key
@@ -127,12 +128,15 @@ async def _load_admin_users_list_payload_uncached(
active_subs = await _bulk_active_subscriptions_for_users(
session, [u.user_id for u in users]
)
payment_summaries = await _bulk_user_payment_summaries(session, [u.user_id for u in users])
referral_counts = await _bulk_user_referral_counts(session, [u.user_id for u in users])
serialized = []
for user in users:
payload = _serialize_user(user)
status_payload = statuses.get(user.user_id) or {"status": "bot_only", "end_date": None}
payload["panel_status"] = status_payload.get("status")
payload["subscription_expires_at"] = status_payload.get("end_date")
if status_payload.get("status") == "expired" and status_payload.get("end_date"):
payload["panel_status_expired_at"] = status_payload["end_date"]
payload["avatar_url"] = (
@@ -141,6 +145,11 @@ async def _load_admin_users_list_payload_uncached(
else None
)
payload["premium_traffic"] = _premium_traffic_list_payload(active_subs.get(user.user_id))
payment_summary = payment_summaries.get(user.user_id) or {}
payload["payments_total_amount"] = float(payment_summary.get("total_amount") or 0)
payload["payments_count"] = int(payment_summary.get("count") or 0)
payload["payments_currency"] = payment_summary.get("currency")
payload["invited_users_count"] = int(referral_counts.get(user.user_id) or 0)
serialized.append(payload)
return {
@@ -255,6 +264,17 @@ async def _bulk_user_avatar_keys(session: AsyncSession, user_ids: List[int]) ->
return {int(uid): (updated_at.isoformat() if updated_at else "") for uid, updated_at in rows}
def _serialize_admin_user_with_avatar(user: User, avatar_keys: Dict[int, str]) -> Dict[str, Any]:
payload = _serialize_user(user)
user_id = int(user.user_id)
payload["avatar_url"] = (
f"/api/admin/users/{user_id}/avatar?v={avatar_keys[user_id]}"
if user_id in avatar_keys
else None
)
return payload
async def admin_user_avatar_route(request: web.Request) -> web.Response:
"""Serve the cached Telegram avatar for any user (admin-only).
@@ -353,6 +373,88 @@ async def _bulk_active_subscriptions_for_users(
return out
def _user_payment_summary_sq():
return (
select(
Payment.user_id.label("user_id"),
sa_func.coalesce(sa_func.sum(Payment.amount), 0.0).label("payments_total_amount"),
sa_func.count(Payment.payment_id).label("payments_count"),
)
.where(Payment.status == "succeeded")
.group_by(Payment.user_id)
.subquery(name="user_payment_summary")
)
def _user_referral_count_sq():
referred_user = aliased(User)
return (
select(
referred_user.referred_by_id.label("user_id"),
sa_func.count(referred_user.user_id).label("invited_users_count"),
)
.where(referred_user.referred_by_id.is_not(None))
.group_by(referred_user.referred_by_id)
.subquery(name="user_referral_count")
)
def _user_subscription_expiry_sq():
return (
select(
Subscription.user_id.label("user_id"),
sa_func.max(Subscription.end_date).label("subscription_expires_at"),
)
.group_by(Subscription.user_id)
.subquery(name="user_subscription_expiry")
)
async def _bulk_user_payment_summaries(
session: AsyncSession,
user_ids: List[int],
) -> Dict[int, Dict[str, Any]]:
if not user_ids:
return {}
stmt = (
select(
Payment.user_id,
sa_func.coalesce(sa_func.sum(Payment.amount), 0.0),
sa_func.count(Payment.payment_id),
sa_func.max(Payment.currency),
)
.where(Payment.user_id.in_(user_ids), Payment.status == "succeeded")
.group_by(Payment.user_id)
)
rows = (await session.execute(stmt)).all()
return {
int(user_id): {
"total_amount": float(total_amount or 0),
"count": int(payments_count or 0),
"currency": currency,
}
for user_id, total_amount, payments_count, currency in rows
}
async def _bulk_user_referral_counts(
session: AsyncSession,
user_ids: List[int],
) -> Dict[int, int]:
if not user_ids:
return {}
referred_user = aliased(User)
stmt = (
select(referred_user.referred_by_id, sa_func.count(referred_user.user_id))
.where(referred_user.referred_by_id.in_(user_ids))
.group_by(referred_user.referred_by_id)
)
rows = (await session.execute(stmt)).all()
return {int(user_id): int(count or 0) for user_id, count in rows}
async def _filter_and_sort_users(
session: AsyncSession,
*,
@@ -381,6 +483,13 @@ async def _filter_and_sort_users(
ratio_expr = None
plim_expr = None
pu_expr = None
payment_summary_sq = None
payment_total_expr = None
payment_count_expr = None
referral_count_sq = None
referral_count_expr = None
subscription_expiry_sq = None
subscription_expires_expr = None
if needs_premium_sq:
sq = _ranked_active_subscriptions_sq(now)
@@ -401,6 +510,42 @@ async def _filter_and_sort_users(
else_=cast(pu_expr, Float) / cast(plim_expr, Float),
)
if sort_key in {
"payments_total_asc",
"payments_total_desc",
"payments_count_asc",
"payments_count_desc",
}:
payment_summary_sq = _user_payment_summary_sq()
stmt = stmt.outerjoin(payment_summary_sq, User.user_id == payment_summary_sq.c.user_id)
count_stmt = count_stmt.outerjoin(
payment_summary_sq,
User.user_id == payment_summary_sq.c.user_id,
)
payment_total_expr = sa_func.coalesce(payment_summary_sq.c.payments_total_amount, 0.0)
payment_count_expr = sa_func.coalesce(payment_summary_sq.c.payments_count, 0)
if sort_key in {"invited_users_count_asc", "invited_users_count_desc"}:
referral_count_sq = _user_referral_count_sq()
stmt = stmt.outerjoin(referral_count_sq, User.user_id == referral_count_sq.c.user_id)
count_stmt = count_stmt.outerjoin(
referral_count_sq,
User.user_id == referral_count_sq.c.user_id,
)
referral_count_expr = sa_func.coalesce(referral_count_sq.c.invited_users_count, 0)
if sort_key in {"subscription_expires_at_asc", "subscription_expires_at_desc"}:
subscription_expiry_sq = _user_subscription_expiry_sq()
stmt = stmt.outerjoin(
subscription_expiry_sq,
User.user_id == subscription_expiry_sq.c.user_id,
)
count_stmt = count_stmt.outerjoin(
subscription_expiry_sq,
User.user_id == subscription_expiry_sq.c.user_id,
)
subscription_expires_expr = subscription_expiry_sq.c.subscription_expires_at
search_cond = _user_search_condition(query)
if search_cond is not None:
stmt = stmt.where(search_cond)
@@ -499,6 +644,22 @@ async def _filter_and_sort_users(
stmt = stmt.order_by(ratio_expr.asc().nullslast(), User.user_id.asc())
elif needs_premium_sq and ratio_expr is not None and sort_key == "premium_ratio_desc":
stmt = stmt.order_by(ratio_expr.desc().nullslast(), User.user_id.desc())
elif payment_total_expr is not None and sort_key == "payments_total_asc":
stmt = stmt.order_by(payment_total_expr.asc(), User.user_id.asc())
elif payment_total_expr is not None and sort_key == "payments_total_desc":
stmt = stmt.order_by(payment_total_expr.desc(), User.user_id.desc())
elif payment_count_expr is not None and sort_key == "payments_count_asc":
stmt = stmt.order_by(payment_count_expr.asc(), User.user_id.asc())
elif payment_count_expr is not None and sort_key == "payments_count_desc":
stmt = stmt.order_by(payment_count_expr.desc(), User.user_id.desc())
elif referral_count_expr is not None and sort_key == "invited_users_count_asc":
stmt = stmt.order_by(referral_count_expr.asc(), User.user_id.asc())
elif referral_count_expr is not None and sort_key == "invited_users_count_desc":
stmt = stmt.order_by(referral_count_expr.desc(), User.user_id.desc())
elif subscription_expires_expr is not None and sort_key == "subscription_expires_at_asc":
stmt = stmt.order_by(subscription_expires_expr.asc().nullslast(), User.user_id.asc())
elif subscription_expires_expr is not None and sort_key == "subscription_expires_at_desc":
stmt = stmt.order_by(subscription_expires_expr.desc().nullslast(), User.user_id.desc())
else:
order = sort_map.get(sort_key, sort_map["registered_desc"])
if isinstance(order, tuple):
@@ -527,9 +688,34 @@ def _user_panel_status_condition(panel_status: str):
normalized_status == "active", blank_status & Subscription.is_active.is_(True)
)
elif status == "expired":
status_cond = or_(
normalized_status == "expired", blank_status & Subscription.is_active.is_(False)
now = datetime.now(timezone.utc)
expired_subs = aliased(Subscription)
active_subs = aliased(Subscription)
expired_status = sa_func.lower(sa_func.coalesce(expired_subs.status_from_panel, ""))
expired_blank_status = or_(
expired_subs.status_from_panel.is_(None),
expired_subs.status_from_panel == "",
)
expired_condition = or_(
expired_status == "expired",
expired_blank_status & expired_subs.is_active.is_(False),
expired_subs.end_date <= now,
)
expired_exists = (
select(expired_subs.subscription_id)
.where(expired_subs.user_id == User.user_id, expired_condition)
.exists()
)
active_exists = (
select(active_subs.subscription_id)
.where(
active_subs.user_id == User.user_id,
active_subs.is_active.is_(True),
active_subs.end_date > now,
)
.exists()
)
return and_(expired_exists, ~active_exists)
else:
status_cond = normalized_status == "limited"
@@ -587,7 +773,12 @@ async def admin_user_detail_route(request: web.Request) -> web.Response:
)
recent_payments = (await session.execute(recent_payments_stmt)).scalars().all()
log_count = await message_log_dal.count_user_message_logs(session, target_id)
avatar_keys = await _bulk_user_avatar_keys(session, [target_id])
inviter = await user_dal.get_referrer_for_user(session, user)
invitees_total = await user_dal.count_users_referred_by(session, target_id)
avatar_user_ids = [target_id]
if inviter is not None:
avatar_user_ids.append(int(inviter.user_id))
avatar_keys = await _bulk_user_avatar_keys(session, avatar_user_ids)
# Referral links — both the bot deep-link and the webapp deep-link.
referral_code: Optional[str] = None
@@ -635,11 +826,9 @@ async def admin_user_detail_route(request: web.Request) -> web.Response:
exc_panel,
)
serialized_user = _serialize_user(user)
serialized_user["avatar_url"] = (
f"/api/admin/users/{target_id}/avatar?v={avatar_keys[target_id]}"
if target_id in avatar_keys
else None
serialized_user = _serialize_admin_user_with_avatar(user, avatar_keys)
serialized_inviter = (
_serialize_admin_user_with_avatar(inviter, avatar_keys) if inviter is not None else None
)
return _ok(
@@ -655,11 +844,54 @@ async def admin_user_detail_route(request: web.Request) -> web.Response:
"code": referral_code,
"bot_link": referral_bot_link,
"webapp_link": referral_webapp_link,
"inviter": serialized_inviter,
"invitees_total": int(invitees_total or 0),
},
}
)
async def admin_user_referrals_route(request: web.Request) -> web.Response:
_require_admin_user_id(request)
target_id = int(request.match_info["user_id"])
page = max(0, int(request.query.get("page", 0) or 0))
page_size = min(100, max(1, int(request.query.get("page_size", 25) or 25)))
async_session_factory: sessionmaker = request.app["async_session_factory"]
async with async_session_factory() as session:
user = await user_dal.get_user_by_id(session, target_id)
if not user:
return _error(404, "not_found", "User not found")
inviter = await user_dal.get_referrer_for_user(session, user)
invitees_total = await user_dal.count_users_referred_by(session, target_id)
invitees = await user_dal.get_users_referred_by(
session,
target_id,
limit=page_size,
offset=page * page_size,
)
avatar_user_ids = [target_id, *(int(u.user_id) for u in invitees)]
if inviter is not None:
avatar_user_ids.append(int(inviter.user_id))
avatar_keys = await _bulk_user_avatar_keys(session, avatar_user_ids)
return _ok(
{
"user": _serialize_admin_user_with_avatar(user, avatar_keys),
"inviter": _serialize_admin_user_with_avatar(inviter, avatar_keys)
if inviter is not None
else None,
"invitees": [
_serialize_admin_user_with_avatar(invitee, avatar_keys) for invitee in invitees
],
"total": int(invitees_total or 0),
"page": page,
"page_size": page_size,
}
)
async def admin_user_ban_route(request: web.Request) -> web.Response:
_require_admin_user_id(request)
target_id = int(request.match_info["user_id"])
@@ -940,10 +1172,6 @@ async def admin_user_reset_trial_route(request: web.Request) -> web.Response:
actor_id = _require_admin_user_id(request)
target_id = int(request.match_info["user_id"])
settings: Settings = request.app["settings"]
panel_service = request.app.get("panel_service")
subscription_service = request.app.get("subscription_service")
if panel_service is None or subscription_service is None:
return _error(503, "service_unavailable")
async_session_factory: sessionmaker = request.app["async_session_factory"]
async with async_session_factory() as session:
@@ -951,16 +1179,17 @@ async def admin_user_reset_trial_route(request: web.Request) -> web.Response:
if not user:
return _error(404, "not_found")
active = await subscription_dal.get_active_subscription_by_user_id(session, target_id)
if active:
await session.delete(active)
reset_at = await user_dal.mark_trial_eligibility_reset(session, target_id)
if reset_at is None:
await session.rollback()
return _error(404, "not_found")
await message_log_dal.create_message_log(
await message_log_dal.create_message_log_no_commit(
session,
{
"user_id": actor_id,
"event_type": "admin_reset_trial_webapp",
"content": f"Reset trial for user_id={target_id}",
"content": f"Reset trial eligibility for user_id={target_id}",
"is_admin_event": True,
"target_user_id": target_id,
},
@@ -1031,7 +1260,7 @@ async def admin_user_premium_override_route(request: web.Request) -> web.Respons
async def admin_user_regular_traffic_override_route(request: web.Request) -> web.Response:
"""Main (regular) traffic: unlimited-style ceiling + admin bonus GB."""
"""Main (regular) traffic: native unlimited panel limit + admin bonus GB."""
actor_id = _require_admin_user_id(request)
target_id = int(request.match_info["user_id"])
settings: Settings = request.app["settings"]
@@ -588,6 +588,17 @@ SETTINGS_MANIFEST: List[SettingField] = [
),
SettingField("USER_TRAFFIC_LIMIT_GB", "float", "devices", "Лимит трафика пользователя (ГБ)"),
SettingField("USER_TRAFFIC_STRATEGY", "string", "devices", "Стратегия сброса трафика"),
# ─── System ────────────────────────────────────────────────────
SettingField(
"TELEMETRY_ENABLED",
"bool",
"system",
"Анонимная статистика установки",
"Раз в сутки отправляет обезличенный сигнал: версия, ОС, локаль и число "
"пользователей в виде диапазона. Без персональных данных, токенов и "
"доменов. Помогает понять число активных установок и какие версии "
"используются. Можно отключить здесь без перезапуска.",
),
]
@@ -722,6 +733,7 @@ def manifest_payload() -> List[dict]:
"backups": 9,
"devices": 10,
"subscription_guides": 10,
"system": 12,
}
exclusive_map = {
key: opposite
@@ -109,6 +109,8 @@
let attempted = false;
let pageLeft = false;
let state = "opening";
let closeAttemptTimer = null;
const CLOSE_ATTEMPT_DELAY_MS = 2500;
function hasControlChars(value) {
return Array.from(String(value || "")).some((char) => {
@@ -167,7 +169,10 @@
function markDone() {
if (state === "done" || isUnsafe) return;
render("done");
window.setTimeout(tryCloseWindow, 120);
if (closeAttemptTimer) window.clearTimeout(closeAttemptTimer);
closeAttemptTimer = window.setTimeout(() => {
if (pageLeft || document.hidden) tryCloseWindow();
}, CLOSE_ATTEMPT_DELAY_MS);
}
function notePageLeft() {
@@ -201,7 +206,6 @@
render("done");
});
window.addEventListener("pagehide", notePageLeft);
window.addEventListener("blur", notePageLeft);
document.addEventListener("visibilitychange", () => {
if (!attempted) return;
if (document.hidden) {
+5
View File
@@ -55,6 +55,11 @@ from bot.utils.text_sanitizer import (
sanitize_username,
)
from config.settings import Settings
from config.tariffs_config import (
default_currency_key_for_settings,
default_payment_currency_code_for_settings,
payment_currency_code,
)
from db.dal import payment_dal, security_dal, subscription_dal, support_dal, user_dal
from db.dal.user_dal import UserMergeConflictError
from db.models import Payment, User, UserTelegramAvatar
+4 -79
View File
@@ -934,87 +934,12 @@ def _get_cached_webapp_settings(request: web.Request) -> Dict[str, Any]:
return cache["data"]
def _run_git_command(*args: str) -> str:
repo_root = APP_ROOT
try:
result = subprocess.run(
["git", *args],
cwd=repo_root,
check=True,
capture_output=True,
text=True,
timeout=1.5,
)
except (OSError, subprocess.SubprocessError):
return ""
return result.stdout.strip()
def _normalize_version_branch(raw_branch: str) -> str:
branch = str(raw_branch or "").strip()
for prefix in ("refs/heads/", "refs/remotes/origin/", "origin/"):
if branch.startswith(prefix):
branch = branch[len(prefix) :]
break
if branch == "HEAD":
return ""
return re.sub(r"[^A-Za-z0-9._-]+", "-", branch).strip("-")[:48]
def _resolve_version_branch() -> str:
for env_name in (
"REMNAWAVE_MINISHOP_BRANCH",
"GIT_BRANCH",
"BRANCH_NAME",
"GITHUB_REF_NAME",
"CI_COMMIT_REF_NAME",
):
branch = _normalize_version_branch(os.getenv(env_name, ""))
if branch:
return branch
return _normalize_version_branch(
_run_git_command("branch", "--show-current")
or _run_git_command("symbolic-ref", "--quiet", "--short", "HEAD")
)
def _format_app_version(tag: str, sha: str, branch: str) -> str:
branch_suffix = "" if not branch or branch == "main" else f"-{branch}"
if tag and sha:
return f"{tag}{branch_suffix}+g{sha}"
if sha:
return f"dev{branch_suffix}+g{sha}"
if tag:
return f"{tag}{branch_suffix}"
return f"dev{branch_suffix}+unknown"
def _resolve_app_version() -> str:
global _APP_VERSION_CACHE
if _APP_VERSION_CACHE:
return _APP_VERSION_CACHE
# Single source of truth shared with the telemetry worker so the admin
# sidebar and the install beacon always report the same version.
from bot.utils.app_version import resolve_app_version
env_version = os.getenv("REMNAWAVE_MINISHOP_VERSION", "").strip()
if env_version:
_APP_VERSION_CACHE = env_version
return env_version
build_version_path = APP_ROOT / ".build-version"
try:
build_version = build_version_path.read_text(encoding="utf-8").strip()
except OSError:
build_version = ""
if build_version:
_APP_VERSION_CACHE = build_version
return build_version
tag = _run_git_command("describe", "--tags", "--abbrev=0")
sha = _run_git_command("rev-parse", "--short", "HEAD")
branch = _resolve_version_branch()
version = _format_app_version(tag, sha, branch)
_APP_VERSION_CACHE = version
return version
return resolve_app_version()
async def _enforce_webapp_rate_limit(
+56 -21
View File
@@ -103,6 +103,8 @@ async def create_payment_route(request: web.Request) -> web.Response:
subscription_service: SubscriptionService = request.app["subscription_service"]
cached = _get_cached_webapp_settings(request)
tariffs_config = settings.tariffs_config
default_currency = default_currency_key_for_settings(settings)
default_currency_code = payment_currency_code(default_currency)
traffic_mode = bool(settings.traffic_sale_mode)
sale_mode = "subscription"
traffic_gb_for_payment: Optional[float] = None
@@ -155,17 +157,17 @@ async def create_payment_route(request: web.Request) -> web.Response:
if requested_sale_mode == "premium_topup"
else tariffs_config.topup_packages_for(tariff)
)
rub_packages = {
currency_packages = {
float(package.gb): float(package.price)
for package in (packages.rub if packages else [])
for package in (packages.for_currency(default_currency) if packages else [])
}
stars_packages = {
float(package.gb): int(float(package.price))
for package in (packages.stars if packages else [])
}
package_key = _resolve_numeric_option_key(rub_packages, traffic_gb)
package_key = _resolve_numeric_option_key(currency_packages, traffic_gb)
stars_package_key = _resolve_numeric_option_key(stars_packages, traffic_gb)
price = rub_packages.get(package_key) if package_key is not None else None
price = currency_packages.get(package_key) if package_key is not None else None
stars_price = (
stars_packages.get(stars_package_key) if stars_package_key is not None else None
)
@@ -196,17 +198,21 @@ async def create_payment_route(request: web.Request) -> web.Response:
return _json_error(400, "invalid_plan", "Invalid traffic package")
if traffic_gb <= 0:
return _json_error(400, "invalid_plan", "Invalid traffic package")
rub_packages = {
currency_packages = {
float(package.gb): float(package.price)
for package in (tariff.traffic_packages.rub if tariff.traffic_packages else [])
for package in (
tariff.traffic_packages.for_currency(default_currency)
if tariff.traffic_packages
else []
)
}
stars_packages = {
float(package.gb): int(float(package.price))
for package in (tariff.traffic_packages.stars if tariff.traffic_packages else [])
}
package_key = _resolve_numeric_option_key(rub_packages, traffic_gb)
package_key = _resolve_numeric_option_key(currency_packages, traffic_gb)
stars_package_key = _resolve_numeric_option_key(stars_packages, traffic_gb)
price = rub_packages.get(package_key) if package_key is not None else None
price = currency_packages.get(package_key) if package_key is not None else None
stars_price = (
stars_packages.get(stars_package_key) if stars_package_key is not None else None
)
@@ -224,7 +230,7 @@ async def create_payment_route(request: web.Request) -> web.Response:
return _json_error(400, "invalid_plan", "Invalid subscription period")
if months not in tariff.enabled_periods:
return _json_error(400, "invalid_plan", "Subscription period is not available")
price = tariff.period_price(months, "rub")
price = tariff.period_price(months, default_currency)
stars_price_raw = tariff.period_price(months, "stars")
stars_price = int(stars_price_raw) if stars_price_raw and stars_price_raw > 0 else None
if price is None and method != "stars":
@@ -296,7 +302,7 @@ async def create_payment_route(request: web.Request) -> web.Response:
active_tariff = None
if not active_tariff or active_tariff.billing_model != "period":
return _json_error(400, "invalid_plan", "Device top-up is not available")
currency = "stars" if method == "stars" else "rub"
currency = "stars" if method == "stars" else default_currency
hwid_quote = await subscription_service.quote_hwid_device_topup(
session,
user_id=user_id,
@@ -325,6 +331,7 @@ async def create_payment_route(request: web.Request) -> web.Response:
months=payment_units,
price=float(price or 0),
stars_price=stars_price,
currency=default_currency_code,
lang=lang,
sale_mode=sale_mode,
traffic_gb=traffic_gb_for_payment,
@@ -583,6 +590,7 @@ async def tariff_change_payment_route(request: web.Request) -> web.Response:
tariff_key = str(payment_payload.tariff_key or "").strip()
settings: Settings = request.app["settings"]
config = settings.tariffs_config
default_currency_code = default_payment_currency_code_for_settings(settings)
if not config:
return _json_error(404, "tariffs_unavailable", "Tariffs are not configured")
if not tariff_key:
@@ -618,6 +626,7 @@ async def tariff_change_payment_route(request: web.Request) -> web.Response:
months=1,
price=price,
stars_price=None,
currency=default_currency_code,
lang=db_user.language_code or settings.DEFAULT_LANGUAGE,
sale_mode=f"tariff_upgrade@{target.key}",
)
@@ -656,20 +665,26 @@ async def device_topup_options_route(request: web.Request) -> web.Response:
active.get("extra_hwid_devices_valid_until_text") if active else None
) or _billing_datetime_text(extra_hwid_valid_until)
packages = tariff.hwid_device_packages
rub_counts = {int(package.count) for package in (packages.rub if packages else [])}
default_currency = default_currency_key_for_settings(settings)
default_currency_code = payment_currency_code(default_currency)
if packages and hasattr(packages, "for_currency"):
default_packages = packages.for_currency(default_currency)
else:
default_packages = getattr(packages, default_currency, []) if packages else []
currency_counts = {int(package.count) for package in default_packages}
stars_counts = {int(package.count) for package in (packages.stars if packages else [])}
plans = []
for count in sorted(rub_counts | stars_counts):
rub_quote = (
for count in sorted(currency_counts | stars_counts):
currency_quote = (
await subscription_service.quote_hwid_device_topup(
session,
user_id=user_id,
device_count=count,
tariff_key=tariff.key,
renewal=renewal_available,
currency="rub",
currency=default_currency,
)
if count in rub_counts
if count in currency_counts
else None
)
stars_quote = (
@@ -684,7 +699,7 @@ async def device_topup_options_route(request: web.Request) -> web.Response:
if count in stars_counts
else None
)
if not rub_quote and not stars_quote:
if not currency_quote and not stars_quote:
continue
sale_mode_for_plan = "hwid_devices_renewal" if renewal_available else "hwid_devices"
plan = {
@@ -695,13 +710,19 @@ async def device_topup_options_route(request: web.Request) -> web.Response:
"sale_mode": sale_mode_for_plan,
"months": count,
"device_count": count,
"price": float(rub_quote.get("price") if rub_quote else 0),
"currency": settings.DEFAULT_CURRENCY_SYMBOL or "RUB",
"price": float(currency_quote.get("price") if currency_quote else 0),
"currency": default_currency_code,
"title": f"+{count}",
"subtitle": tariff.name(lang),
"valid_from": _billing_iso_datetime((rub_quote or stars_quote).get("valid_from")),
"valid_until": _billing_iso_datetime((rub_quote or stars_quote).get("valid_until")),
"proration_ratio": float((rub_quote or stars_quote).get("proration_ratio") or 0),
"valid_from": _billing_iso_datetime(
(currency_quote or stars_quote).get("valid_from")
),
"valid_until": _billing_iso_datetime(
(currency_quote or stars_quote).get("valid_until")
),
"proration_ratio": float(
(currency_quote or stars_quote).get("proration_ratio") or 0
),
}
if stars_quote and int(stars_quote.get("price") or 0) > 0:
plan["stars_price"] = int(stars_quote["price"])
@@ -929,12 +950,14 @@ async def _create_subscription_payment(
price: float,
stars_price: Optional[int],
lang: str,
currency: Optional[str] = None,
sale_mode: str = "subscription",
traffic_gb: Optional[float] = None,
is_admin: bool = False,
hwid_quote: Optional[Dict[str, Any]] = None,
) -> web.Response:
settings: Settings = request.app["settings"]
payment_currency = (currency or default_payment_currency_code_for_settings(settings)).upper()
sale_mode = str(sale_mode or "subscription")
traffic_sale = _sale_mode_is_traffic(sale_mode)
hwid_devices_sale = _sale_mode_is_hwid_devices(sale_mode)
@@ -958,6 +981,17 @@ async def _create_subscription_payment(
provider_spec.is_service_configured(request.app),
)
return _json_error(400, "payment_unavailable", "Payment method unavailable")
if not provider_spec.is_usable_for_payment_currency(settings, payment_currency):
logger.warning(
"WebApp payment method does not support currency: method=%s currency=%s",
method,
payment_currency,
)
return _json_error(
400,
"unsupported_currency",
"Payment method does not support this currency",
)
return await provider_spec.create_webapp_payment(
WebAppPaymentContext(
request=request,
@@ -967,6 +1001,7 @@ async def _create_subscription_payment(
months=months,
price=price,
stars_price=stars_price,
currency=payment_currency,
description=description,
sale_mode=sale_mode,
traffic_gb=traffic_gb,
+46 -23
View File
@@ -67,7 +67,7 @@ async def _build_user_payload(request: web.Request, user_id: int) -> Dict[str, A
trial_available = bool(
settings.TRIAL_ENABLED
and settings.TRIAL_DURATION_DAYS > 0
and not await subscription_service.has_had_any_subscription(session, user_id)
and not await subscription_service.has_trial_blocking_subscription(session, user_id)
)
avatar = await _ensure_cached_telegram_avatar(request, session, db_user)
try:
@@ -472,6 +472,8 @@ def _serialize_plans(
) -> List[Dict[str, Any]]:
tariffs_config = settings.tariffs_config
if tariffs_config:
default_currency = default_currency_key_for_settings(settings)
default_currency_code = payment_currency_code(default_currency)
plans: List[Dict[str, Any]] = []
for tariff in tariffs_config.enabled_tariffs:
common = {
@@ -481,7 +483,7 @@ def _serialize_plans(
"billing_model": tariff.billing_model,
"description": tariff.description(lang),
"squad_uuids": tariff.squad_uuids,
"currency": settings.DEFAULT_CURRENCY_SYMBOL or "RUB",
"currency": default_currency_code,
"hwid_device_limit": tariff.hwid_device_limit,
"hwid_device_packages": _serialize_hwid_device_packages(
settings,
@@ -494,7 +496,7 @@ def _serialize_plans(
}
if tariff.billing_model == "period":
for months in sorted(tariff.enabled_periods):
price = tariff.period_price(int(months), "rub")
price = tariff.period_price(int(months), default_currency)
stars_price = tariff.period_price(int(months), "stars")
if price is None and (stars_price is None or int(stars_price) <= 0):
continue
@@ -512,9 +514,13 @@ def _serialize_plans(
plan["stars_price"] = int(stars_price)
plans.append(plan)
else:
rub_packages = {
currency_packages = {
float(package.gb): float(package.price)
for package in (tariff.traffic_packages.rub if tariff.traffic_packages else [])
for package in (
tariff.traffic_packages.for_currency(default_currency)
if tariff.traffic_packages
else []
)
}
stars_packages = {
float(package.gb): int(float(package.price))
@@ -522,8 +528,8 @@ def _serialize_plans(
tariff.traffic_packages.stars if tariff.traffic_packages else []
)
}
for traffic_gb in sorted(set(rub_packages) | set(stars_packages)):
price = rub_packages.get(traffic_gb)
for traffic_gb in sorted(set(currency_packages) | set(stars_packages)):
price = currency_packages.get(traffic_gb)
stars_price = stars_packages.get(traffic_gb)
if price is None and (stars_price is None or int(stars_price) <= 0):
continue
@@ -609,16 +615,19 @@ def _serialize_topup_packages(
sale_mode: str = "topup",
title_prefix: str = "",
) -> List[Dict[str, Any]]:
rub_packages = {
float(package.gb): float(package.price) for package in (packages.rub if packages else [])
default_currency = default_currency_key_for_settings(settings)
default_currency_code = payment_currency_code(default_currency)
currency_packages = {
float(package.gb): float(package.price)
for package in (packages.for_currency(default_currency) if packages else [])
}
stars_packages = {
float(package.gb): int(float(package.price))
for package in (packages.stars if packages else [])
}
plans: List[Dict[str, Any]] = []
for traffic_gb in sorted(set(rub_packages) | set(stars_packages)):
price = rub_packages.get(traffic_gb)
for traffic_gb in sorted(set(currency_packages) | set(stars_packages)):
price = currency_packages.get(traffic_gb)
stars_price = stars_packages.get(traffic_gb)
if price is None and (stars_price is None or int(stars_price) <= 0):
continue
@@ -632,7 +641,7 @@ def _serialize_topup_packages(
"months": int(traffic_value) if traffic_value.is_integer() else traffic_value,
"traffic_gb": traffic_value,
"price": float(price or 0),
"currency": settings.DEFAULT_CURRENCY_SYMBOL or "RUB",
"currency": default_currency_code,
"title": f"{title_prefix}{_format_traffic_title(traffic_value, lang)}",
"subtitle": tariff.premium_name(lang)
if sale_mode == "premium_topup"
@@ -650,16 +659,19 @@ def _serialize_hwid_device_packages(
packages: Optional[Any],
lang: str,
) -> List[Dict[str, Any]]:
rub_packages = {
int(package.count): float(package.price) for package in (packages.rub if packages else [])
default_currency = default_currency_key_for_settings(settings)
default_currency_code = payment_currency_code(default_currency)
currency_packages = {
int(package.count): float(package.price)
for package in (packages.for_currency(default_currency) if packages else [])
}
stars_packages = {
int(package.count): int(float(package.price))
for package in (packages.stars if packages else [])
}
plans: List[Dict[str, Any]] = []
for count in sorted(set(rub_packages) | set(stars_packages)):
price = rub_packages.get(count)
for count in sorted(set(currency_packages) | set(stars_packages)):
price = currency_packages.get(count)
stars_price = stars_packages.get(count)
if price is None and (stars_price is None or int(stars_price) <= 0):
continue
@@ -672,7 +684,7 @@ def _serialize_hwid_device_packages(
"months": int(count),
"device_count": int(count),
"price": float(price or 0),
"currency": settings.DEFAULT_CURRENCY_SYMBOL or "RUB",
"currency": default_currency_code,
"title": f"+{count}",
"subtitle": tariff.name(lang),
}
@@ -689,6 +701,8 @@ def _serialize_tariff_change_target(
options: Dict[str, Any],
lang: str,
) -> Dict[str, Any]:
default_currency = default_currency_key_for_settings(settings)
default_currency_code = payment_currency_code(default_currency)
actions: List[Dict[str, Any]] = []
mode = str(options.get("mode") or "")
if mode == "period_to_period":
@@ -711,7 +725,7 @@ def _serialize_tariff_change_target(
"kind": "payment",
"title": "paid_diff",
"price": paid_diff,
"currency": settings.DEFAULT_CURRENCY_SYMBOL or "RUB",
"currency": default_currency_code,
}
)
elif mode == "period_to_traffic":
@@ -733,13 +747,17 @@ def _serialize_tariff_change_target(
"title": f"+{package.gb:g} GB",
"traffic_gb": float(package.gb),
"price": float(package.price),
"currency": settings.DEFAULT_CURRENCY_SYMBOL or "RUB",
"currency": default_currency_code,
}
for package in (tariff.traffic_packages.rub if tariff.traffic_packages else [])
for package in (
tariff.traffic_packages.for_currency(default_currency)
if tariff.traffic_packages
else []
)
)
else:
for months in tariff.enabled_periods:
price = tariff.period_price(int(months), "rub")
price = tariff.period_price(int(months), default_currency)
if price:
actions.append(
{
@@ -748,7 +766,7 @@ def _serialize_tariff_change_target(
"months": int(months),
"title": _format_months_title(int(months), lang),
"price": float(price),
"currency": settings.DEFAULT_CURRENCY_SYMBOL or "RUB",
"currency": default_currency_code,
}
)
return {
@@ -772,10 +790,15 @@ def _serialize_payment_methods(
from bot.payment_providers import get_provider_spec, resolve_provider_presentation
methods: List[Dict[str, Any]] = []
payment_currency = default_payment_currency_code_for_settings(settings)
for method in settings.payment_methods_order:
method = method.lower()
spec = get_provider_spec(method)
if spec and spec.is_visible_for_user(settings, app, is_admin=is_admin):
if (
spec
and spec.is_visible_for_user(settings, app, is_admin=is_admin)
and spec.is_usable_for_payment_currency(settings, payment_currency)
):
presentation = resolve_provider_presentation(spec, settings, language=lang)
methods.append(
{
+3 -1
View File
@@ -155,7 +155,7 @@ async def change_broadcast_target_handler(
return
new_target = callback.data.split(":")[1]
if new_target not in {"all", "active", "inactive"}:
if new_target not in {"all", "active", "inactive", "expired"}:
await callback.answer("Unknown target.", show_alert=True)
return
@@ -247,6 +247,8 @@ async def confirm_broadcast_callback_handler(
user_ids = await user_dal.get_user_ids_with_active_subscription(session)
elif target == "inactive":
user_ids = await user_dal.get_user_ids_without_active_subscription(session)
elif target == "expired":
user_ids = await user_dal.get_user_ids_with_expired_subscription(session)
else:
user_ids = await user_dal.get_all_active_user_ids_for_broadcast(session)
+6 -4
View File
@@ -13,6 +13,7 @@ from bot.middlewares.i18n import JsonI18n
from bot.payment_providers import pending_statuses
from bot.services.panel_api_service import PanelApiService
from config.settings import Settings
from config.tariffs_config import default_payment_currency_code_for_settings
from db.dal import panel_sync_dal, payment_dal, user_dal
from db.models import PanelSyncStatus, Payment
@@ -195,19 +196,20 @@ async def show_statistics_handler(
# Financial statistics
financial_stats = await payment_dal.get_financial_statistics(session)
currency = default_payment_currency_code_for_settings(settings)
stats_text_parts.append(f"\n<b>💰 {_('admin_financial_stats_header')}</b>")
stats_text_parts.append(
f"📅 {_('admin_financial_today_label')}: <b>{financial_stats['today_revenue']:.2f} RUB</b> ({financial_stats['today_payments_count']} {_('admin_financial_payments_label')})" # noqa: E501
f"📅 {_('admin_financial_today_label')}: <b>{financial_stats['today_revenue']:.2f} {currency}</b> ({financial_stats['today_payments_count']} {_('admin_financial_payments_label')})" # noqa: E501
)
stats_text_parts.append(
f"📅 {_('admin_financial_week_label')}: <b>{financial_stats['week_revenue']:.2f} RUB</b>"
f"📅 {_('admin_financial_week_label')}: <b>{financial_stats['week_revenue']:.2f} {currency}</b>" # noqa: E501
)
stats_text_parts.append(
f"📅 {_('admin_financial_month_label')}: <b>{financial_stats['month_revenue']:.2f} RUB</b>"
f"📅 {_('admin_financial_month_label')}: <b>{financial_stats['month_revenue']:.2f} {currency}</b>" # noqa: E501
)
stats_text_parts.append(
f"🏆 {_('admin_financial_all_time_label')}: <b>{financial_stats['all_time_revenue']:.2f} RUB</b>" # noqa: E501
f"🏆 {_('admin_financial_all_time_label')}: <b>{financial_stats['all_time_revenue']:.2f} {currency}</b>" # noqa: E501
)
last_payments_models: List[Payment] = await payment_dal.get_recent_payment_logs_with_user(
+167 -11
View File
@@ -27,6 +27,7 @@ from bot.utils.text_sanitizer import (
username_for_display,
)
from config.settings import Settings
from config.tariffs_config import default_payment_currency_code_for_settings
from db.dal import message_log_dal, subscription_dal, user_dal
from db.models import User
@@ -88,6 +89,31 @@ async def _find_user_by_admin_input(
return None
def _admin_user_reference_label(
user: Optional[User], fallback_user_id: Optional[int] = None
) -> str:
if user is None:
return f"ID {fallback_user_id}" if fallback_user_id is not None else "N/A"
first_name = sanitize_display_name(user.first_name) if user.first_name else ""
last_name = sanitize_display_name(user.last_name) if user.last_name else ""
full_name = f"{first_name} {last_name}".strip()
if full_name:
label = full_name
elif user.username:
label = username_for_display(user.username, with_at=True)
elif user.email:
label = user.email
else:
label = f"ID {user.user_id}"
return f"{label} · ID {user.user_id}"
def _admin_user_button_label(user: User) -> str:
label = _admin_user_reference_label(user)
return label[:64]
async def users_list_handler(
callback: types.CallbackQuery,
i18n_data: dict,
@@ -195,7 +221,13 @@ def get_user_card_keyboard(
text=_(key="admin_user_refresh_button"), callback_data=f"user_action:refresh:{user_id}"
)
# Row 3b: Premium override + traffic grant
# Row 3b: Referral details
builder.button(
text=_(key="admin_user_invitees_button"),
callback_data=f"user_action:invitees:{user_id}:0",
)
# Row 4: Premium override + traffic grant
builder.button(
text=_(key="admin_user_premium_override_button"),
callback_data=f"user_action:premium_override:{user_id}",
@@ -229,9 +261,9 @@ def get_user_card_keyboard(
quick_links_count = (1 if has_self_link else 0) + (1 if has_referrer_link else 0)
if quick_links_count == 0:
builder.adjust(2, 2, 2, 2, 1, 2)
builder.adjust(2, 2, 2, 1, 2, 1, 2)
else:
builder.adjust(2, 2, 2, 2, quick_links_count, 1, 2)
builder.adjust(2, 2, 2, 1, 2, quick_links_count, 1, 2)
return builder
@@ -314,7 +346,11 @@ async def format_user_card(
# Referral info
if user.referred_by_id:
card_parts.append(f"{_('admin_user_referral_label')} {hcode(str(user.referred_by_id))}")
referrer = await user_dal.get_referrer_for_user(session, user)
card_parts.append(
f"{_('admin_user_invited_by_label')} "
f"{hcode(_admin_user_reference_label(referrer, user.referred_by_id))}"
)
# Panel info
if user.panel_user_uuid:
@@ -407,17 +443,18 @@ async def format_user_card(
try:
from db.dal import payment_dal
currency = default_payment_currency_code_for_settings(settings)
# Total amount paid by this user
total_paid = await payment_dal.get_user_total_paid(session, user.user_id)
card_parts.append(
f"{_('admin_user_total_paid_label')} {hcode(f'{total_paid:.2f} RUB')}"
f"{_('admin_user_total_paid_label')} {hcode(f'{total_paid:.2f} {currency}')}"
)
# Total revenue from referrals
referral_revenue = await payment_dal.get_referral_revenue(session, user.user_id)
card_parts.append(
f"{_('admin_user_referral_revenue_label')} {hcode(f'{referral_revenue:.2f} RUB')}"
)
referral_revenue_text = hcode(f"{referral_revenue:.2f} {currency}")
card_parts.append(f"{_('admin_user_referral_revenue_label')} {referral_revenue_text}")
except Exception as e_fin:
logging.error(
f"Failed to build financial analytics for admin card {user.user_id}: {e_fin}"
@@ -619,6 +656,12 @@ async def user_action_handler(
await handle_send_message_prompt(callback, state, user, i18n, current_lang)
elif action == "view_logs":
await handle_view_user_logs(callback, user, session, settings, i18n, current_lang)
elif action == "invitees":
try:
page = max(0, int(parts[3])) if len(parts) > 3 else 0
except (TypeError, ValueError):
page = 0
await handle_view_user_invitees(callback, user, session, i18n, current_lang, page=page)
elif action == "refresh":
await handle_refresh_user_card(
callback, user, subscription_service, session, settings, i18n, current_lang
@@ -889,8 +932,7 @@ async def handle_reset_trial(
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
try:
# Delete all user subscriptions to reset trial eligibility
await subscription_dal.delete_all_user_subscriptions(session, user.user_id)
await user_dal.mark_trial_eligibility_reset(session, user.user_id)
await session.commit()
await callback.answer(_("admin_user_trial_reset_success"), show_alert=True)
@@ -1056,6 +1098,120 @@ async def handle_view_user_logs(
await callback.answer(_("admin_user_logs_error"), show_alert=True)
async def handle_view_user_invitees(
callback: types.CallbackQuery,
user: User,
session: AsyncSession,
i18n_instance,
lang: str,
*,
page: int = 0,
):
"""Show users invited by the selected account."""
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
page_size = 10
safe_page = max(0, int(page or 0))
try:
total = await user_dal.count_users_referred_by(session, user.user_id)
total_pages = max(1, (total + page_size - 1) // page_size)
if safe_page >= total_pages:
safe_page = total_pages - 1
invitees = await user_dal.get_users_referred_by(
session,
user.user_id,
limit=page_size,
offset=safe_page * page_size,
)
header = _(
"admin_user_invitees_message_title",
user=hcode(_admin_user_reference_label(user)),
total=total,
current=safe_page + 1,
total_pages=total_pages,
)
if total <= 0:
invitees_text = f"{header}\n\n{_('admin_user_invitees_empty')}"
else:
lines = []
for index, invitee in enumerate(invitees, start=safe_page * page_size + 1):
registered = (
invitee.registration_date.strftime("%Y-%m-%d")
if invitee.registration_date
else ""
)
suffix = (
_("admin_user_invitee_registered_suffix", date=registered) if registered else ""
)
lines.append(
_(
"admin_user_invitee_item",
index=index,
user=hcode(_admin_user_reference_label(invitee)),
suffix=suffix,
)
)
invitees_text = "\n".join([header, "", *lines])
builder = InlineKeyboardBuilder()
for invitee in invitees:
builder.row(
types.InlineKeyboardButton(
text=_admin_user_button_label(invitee),
callback_data=f"user_action:refresh:{invitee.user_id}",
)
)
pagination_buttons = []
if safe_page > 0:
pagination_buttons.append(
types.InlineKeyboardButton(
text=_("prev_page_button"),
callback_data=f"user_action:invitees:{user.user_id}:{safe_page - 1}",
)
)
if safe_page < total_pages - 1:
pagination_buttons.append(
types.InlineKeyboardButton(
text=_("next_page_button"),
callback_data=f"user_action:invitees:{user.user_id}:{safe_page + 1}",
)
)
if pagination_buttons:
builder.row(*pagination_buttons)
builder.row(
types.InlineKeyboardButton(
text=_("admin_user_back_to_card_button"),
callback_data=f"user_action:refresh:{user.user_id}",
)
)
builder.row(
types.InlineKeyboardButton(
text=_("back_to_admin_panel_button"), callback_data="admin_action:main"
)
)
try:
await callback.message.edit_text(
invitees_text, reply_markup=builder.as_markup(), parse_mode="HTML"
)
except Exception:
await callback.message.answer(
invitees_text, reply_markup=builder.as_markup(), parse_mode="HTML"
)
await callback.answer()
except Exception as exc:
logging.error(
"Error viewing invitees for user %s: %s",
user.user_id,
exc,
exc_info=True,
)
await callback.answer(_("admin_user_invitees_error"), show_alert=True)
async def handle_refresh_user_card(
callback: types.CallbackQuery,
user: User,
@@ -1964,7 +2120,7 @@ async def user_card_from_list_handler(
text=_("admin_user_back_to_list_button"), callback_data=f"admin_action:users_list:{page}"
)
quick_links_width = 2 if user.referred_by_id else 1
keyboard.adjust(2, 2, 2, 2, quick_links_width, 1, 2, 1)
keyboard.adjust(2, 2, 2, 1, 2, quick_links_width, 1, 2, 1)
# Format user card
try:
+32 -5
View File
@@ -24,6 +24,10 @@ from bot.services.referral_service import ReferralService
from bot.services.subscription_service import SubscriptionService
from bot.services.telegram_notifications import TELEGRAM_NOTIFICATIONS_ENABLED
from bot.utils.callback_answer import safe_answer_callback
from bot.utils.channel_subscription import (
is_required_channel_access_error,
normalize_required_channel_id,
)
from bot.utils.install_links import (
append_install_share_link_text,
ensure_user_install_guide_links,
@@ -45,12 +49,12 @@ async def should_show_trial_button(
if not settings.TRIAL_ENABLED:
return False
if hasattr(subscription_service, "has_had_any_subscription") and callable(
getattr(subscription_service, "has_had_any_subscription")
if hasattr(subscription_service, "has_trial_blocking_subscription") and callable(
getattr(subscription_service, "has_trial_blocking_subscription")
):
return not await subscription_service.has_had_any_subscription(session, user_id)
return not await subscription_service.has_trial_blocking_subscription(session, user_id)
logging.error("Method has_had_any_subscription is missing in SubscriptionService!")
logging.error("Method has_trial_blocking_subscription is missing in SubscriptionService!")
return False
@@ -215,7 +219,7 @@ async def ensure_required_channel_subscription(
Verify that the user is a member of the required channel (if configured).
Returns True when access can proceed, False when user must subscribe first.
"""
required_channel_id = settings.REQUIRED_CHANNEL_ID
required_channel_id = normalize_required_channel_id(settings.REQUIRED_CHANNEL_ID)
if not required_channel_id:
return True
@@ -279,6 +283,29 @@ async def ensure_required_channel_subscription(
if status_value in allowed_statuses:
is_member = True
except TelegramBadRequest as bad_request:
if is_required_channel_access_error(bad_request):
logging.error(
"Required channel check failed due to channel access/configuration error "
"(configured=%s, normalized=%s): %s",
settings.REQUIRED_CHANNEL_ID,
required_channel_id,
bad_request,
)
error_text = translate("channel_subscription_check_failed")
if isinstance(event, types.CallbackQuery):
try:
await event.answer(error_text, show_alert=True)
except Exception:
pass
if message_obj:
try:
await message_obj.answer(error_text)
except Exception:
pass
else:
await event.answer(error_text)
return False
logging.info(
"Required channel check: user %s not subscribed (details: %s)",
user_id,
+77 -30
View File
@@ -32,6 +32,10 @@ from bot.utils.install_links import (
ensure_user_install_guide_links,
)
from config.settings import Settings
from config.tariffs_config import (
default_currency_key_for_settings,
default_payment_currency_code_for_settings,
)
from db.dal import subscription_dal, user_billing_dal
from db.models import Subscription
@@ -80,11 +84,13 @@ def _tariff_purchase_markup(
back_callback=back_callback,
callback_context=callback_context,
)
default_currency = default_currency_key_for_settings(settings)
return get_tariff_packages_keyboard(
tariff,
tariff.traffic_packages.rub,
tariff.traffic_packages.for_currency(default_currency),
current_lang,
i18n,
currency_symbol=default_payment_currency_code_for_settings(settings),
back_callback=back_callback,
callback_context=callback_context,
)
@@ -185,6 +191,7 @@ async def display_subscription_options(
enabled_tariffs,
current_lang,
i18n,
settings=settings,
back_callback=back_callback,
callback_context=callback_context,
)
@@ -329,7 +336,9 @@ async def select_tariff_period_callback(
callback_context = parts[4] if len(parts) > 4 else None
tariff = config.require(tariff_key)
months = int(months_raw)
price_rub = tariff.period_price(months, "rub")
default_currency = default_currency_key_for_settings(settings)
currency_code = default_payment_currency_code_for_settings(settings)
price_rub = tariff.period_price(months, default_currency)
stars_price = tariff.period_price(months, "stars")
if price_rub is None:
await callback.answer(get_text("error_try_again"), show_alert=True)
@@ -338,7 +347,7 @@ async def select_tariff_period_callback(
months,
price_rub,
int(stars_price) if stars_price else None,
settings.DEFAULT_CURRENCY_SYMBOL,
currency_code,
current_lang,
i18n,
settings,
@@ -369,10 +378,16 @@ async def select_tariff_package_callback(
callback_context = parts[4] if len(parts) > 4 else None
tariff = config.require(tariff_key)
gb = float(gb_raw)
default_currency = default_currency_key_for_settings(settings)
currency_code = default_payment_currency_code_for_settings(settings)
packages = (
tariff.traffic_packages.rub
tariff.traffic_packages.for_currency(default_currency)
if tariff.billing_model == "traffic"
else (config.topup_packages_for(tariff).rub if config.topup_packages_for(tariff) else [])
else (
config.topup_packages_for(tariff).for_currency(default_currency)
if config.topup_packages_for(tariff)
else []
)
)
package = next((pkg for pkg in packages if float(pkg.gb) == gb), None)
if not package:
@@ -391,7 +406,7 @@ async def select_tariff_package_callback(
gb,
package.price,
None,
settings.DEFAULT_CURRENCY_SYMBOL,
currency_code,
current_lang,
i18n,
settings,
@@ -423,14 +438,19 @@ async def tariff_topup_list_callback(
return
tariff = config.require(active["tariff_key"])
packages = config.topup_packages_for(tariff)
rub_packages = packages.rub if packages else []
premium_packages = tariff.premium_topup_packages.rub if tariff.premium_topup_packages else []
if not rub_packages and not premium_packages:
default_currency = default_currency_key_for_settings(settings)
currency = default_payment_currency_code_for_settings(settings)
currency_packages = packages.for_currency(default_currency) if packages else []
premium_packages = (
tariff.premium_topup_packages.for_currency(default_currency)
if tariff.premium_topup_packages
else []
)
if not currency_packages and not premium_packages:
await callback.answer(get_text("no_subscription_options_available"), show_alert=True)
return
builder = InlineKeyboardBuilder()
currency = settings.DEFAULT_CURRENCY_SYMBOL
for package in rub_packages:
for package in currency_packages:
builder.row(
InlineKeyboardButton(
text=f"Обычный трафик +{package.gb:g} GB — {package.price:g} {currency}",
@@ -452,7 +472,7 @@ async def tariff_topup_list_callback(
premium_lines = []
carryover_lines = []
if rub_packages or premium_packages:
if currency_packages or premium_packages:
carryover_lines.append(
"Докупленный трафик не сгорает: сначала расходуется месячный лимит, затем докупленный остаток." # noqa: E501
)
@@ -495,7 +515,13 @@ async def select_tariff_premium_package_callback(
_, _, tariff_key, gb_raw = callback.data.split(":", 3)
tariff = config.require(tariff_key)
gb = float(gb_raw)
packages = tariff.premium_topup_packages.rub if tariff.premium_topup_packages else []
default_currency = default_currency_key_for_settings(settings)
currency_code = default_payment_currency_code_for_settings(settings)
packages = (
tariff.premium_topup_packages.for_currency(default_currency)
if tariff.premium_topup_packages
else []
)
package = next((pkg for pkg in packages if float(pkg.gb) == gb), None)
if not package:
await callback.answer(get_text("error_try_again"), show_alert=True)
@@ -504,7 +530,7 @@ async def select_tariff_premium_package_callback(
gb,
package.price,
None,
settings.DEFAULT_CURRENCY_SYMBOL,
currency_code,
current_lang,
i18n,
settings,
@@ -542,7 +568,12 @@ async def hwid_devices_list_callback(
if tariff.billing_model != "period":
await callback.answer(get_text("no_hwid_device_packages_available"), show_alert=True)
return
packages = tariff.hwid_device_packages.rub if tariff.hwid_device_packages else []
default_currency = default_currency_key_for_settings(settings)
packages = (
tariff.hwid_device_packages.for_currency(default_currency)
if tariff.hwid_device_packages
else []
)
if not packages:
await callback.answer(get_text("no_hwid_device_packages_available"), show_alert=True)
return
@@ -594,7 +625,13 @@ async def hwid_devices_package_callback(
package = next(
(
pkg
for pkg in (tariff.hwid_device_packages.rub if tariff.hwid_device_packages else [])
for pkg in (
tariff.hwid_device_packages.for_currency(
default_currency_key_for_settings(settings)
)
if tariff.hwid_device_packages
else []
)
if int(pkg.count) == count
),
None,
@@ -603,13 +640,15 @@ async def hwid_devices_package_callback(
await callback.answer(get_text("error_try_again"), show_alert=True)
return
sale_mode_base = "hwid_devices_renewal" if action == "renewal_package" else "hwid_devices"
rub_quote = await subscription_service.quote_hwid_device_topup(
default_currency = default_currency_key_for_settings(settings)
currency_code = default_payment_currency_code_for_settings(settings)
currency_quote = await subscription_service.quote_hwid_device_topup(
session,
user_id=callback.from_user.id,
device_count=count,
tariff_key=tariff.key,
renewal=action == "renewal_package",
currency="rub",
currency=default_currency,
)
stars_quote = await subscription_service.quote_hwid_device_topup(
session,
@@ -619,16 +658,16 @@ async def hwid_devices_package_callback(
renewal=action == "renewal_package",
currency="stars",
)
if not rub_quote and not stars_quote:
if not currency_quote and not stars_quote:
await callback.answer(get_text("error_try_again"), show_alert=True)
return
markup = get_payment_method_keyboard(
count,
float(rub_quote.get("price") if rub_quote else 0),
float(currency_quote.get("price") if currency_quote else 0),
int(stars_quote["price"])
if stars_quote and int(stars_quote.get("price") or 0) > 0
else None,
settings.DEFAULT_CURRENCY_SYMBOL,
currency_code,
current_lang,
i18n,
settings,
@@ -715,6 +754,8 @@ async def tariff_change_select_callback(
options = await subscription_service.calculate_tariff_switch_options_with_hwid(
session, db_sub, target
)
default_currency = default_currency_key_for_settings(settings)
currency_code = default_payment_currency_code_for_settings(settings)
rows = []
if options["mode"] == "period_to_period":
rows.append(
@@ -729,7 +770,7 @@ async def tariff_change_select_callback(
rows.append(
[
InlineKeyboardButton(
text=f"Доплатить {options['paid_diff_rub']} RUB",
text=f"Доплатить {options['paid_diff_rub']} {currency_code}",
callback_data=f"tariff_change:confirm_pay:{target.key}:{options['paid_diff_rub']}",
)
]
@@ -743,23 +784,23 @@ async def tariff_change_select_callback(
)
]
)
for package in target.traffic_packages.rub:
for package in target.traffic_packages.for_currency(default_currency):
rows.append(
[
InlineKeyboardButton(
text=f"+ {package.gb:g} GB за {package.price:g} RUB",
text=f"+ {package.gb:g} GB за {package.price:g} {currency_code}",
callback_data=f"tariff:package:{target.key}:{package.gb:g}",
)
]
)
else:
for months in target.enabled_periods:
price = target.period_price(months, "rub")
price = target.period_price(months, default_currency)
if price:
rows.append(
[
InlineKeyboardButton(
text=f"{months} мес. за {price:g} RUB",
text=f"{months} мес. за {price:g} {currency_code}",
callback_data=f"tariff:period:{target.key}:{months}",
)
]
@@ -842,6 +883,7 @@ async def tariff_change_confirm_pay_callback(
return
_, _, tariff_key, amount_raw = callback.data.split(":", 3)
target = config.require(tariff_key)
currency_code = default_payment_currency_code_for_settings(settings)
rows = [
[
InlineKeyboardButton(
@@ -857,7 +899,7 @@ async def tariff_change_confirm_pay_callback(
],
]
await callback.message.edit_text(
f"Подтвердите смену тарифа\n\nНовый тариф: {target.name(current_lang)}\nБудет создана оплата на {amount_raw} RUB.", # noqa: E501
f"Подтвердите смену тарифа\n\nНовый тариф: {target.name(current_lang)}\nБудет создана оплата на {amount_raw} {currency_code}.", # noqa: E501
reply_markup=InlineKeyboardMarkup(inline_keyboard=rows),
)
await callback.answer()
@@ -899,11 +941,12 @@ async def tariff_change_pay_callback(
i18n: JsonI18n = i18n_data.get("i18n_instance")
_, _, tariff_key, amount_raw = callback.data.split(":", 3)
amount = float(amount_raw)
currency_code = default_payment_currency_code_for_settings(settings)
markup = get_payment_method_keyboard(
1,
amount,
None,
settings.DEFAULT_CURRENCY_SYMBOL,
currency_code,
current_lang,
i18n,
settings,
@@ -1226,7 +1269,9 @@ async def my_subscription_command_handler(
if (
tariff_for_devices.billing_model == "period"
and tariff_for_devices.hwid_device_packages
and tariff_for_devices.hwid_device_packages.rub
and tariff_for_devices.hwid_device_packages.for_currency(
default_currency_key_for_settings(settings)
)
):
prepend_rows.append(
[
@@ -1443,7 +1488,9 @@ async def my_devices_command_handler(
if (
tariff_for_devices.billing_model == "period"
and tariff_for_devices.hwid_device_packages
and tariff_for_devices.hwid_device_packages.rub
and tariff_for_devices.hwid_device_packages.for_currency(
default_currency_key_for_settings(settings)
)
):
devices_kb.append(
[
+9 -7
View File
@@ -46,7 +46,7 @@ async def request_trial_confirmation_handler(
return
if settings.TRIAL_ENABLED:
if not await subscription_service.has_had_any_subscription(session, user_id):
if not await subscription_service.has_trial_blocking_subscription(session, user_id):
pass
if not settings.TRIAL_ENABLED:
@@ -60,7 +60,7 @@ async def request_trial_confirmation_handler(
pass
return
if await subscription_service.has_had_any_subscription(session, user_id):
if await subscription_service.has_trial_blocking_subscription(session, user_id):
await callback.message.edit_text(
_("trial_already_had_subscription_or_trial"),
reply_markup=get_main_menu_inline_keyboard(current_lang, i18n, settings, False),
@@ -147,8 +147,9 @@ async def request_trial_confirmation_handler(
await callback.answer(final_message_text_in_chat, show_alert=True)
except Exception:
pass
if settings.TRIAL_ENABLED and not await subscription_service.has_had_any_subscription(
session, user_id
if (
settings.TRIAL_ENABLED
and not await subscription_service.has_trial_blocking_subscription(session, user_id)
):
show_trial_button_after_action = True
@@ -218,7 +219,7 @@ async def confirm_activate_trial_handler(
callback, settings, i18n_data, subscription_service, session, is_edit=True
)
return
if await subscription_service.has_had_any_subscription(session, user_id):
if await subscription_service.has_trial_blocking_subscription(session, user_id):
try:
await callback.answer(_("trial_already_had_subscription_or_trial"), show_alert=True)
except Exception:
@@ -283,8 +284,9 @@ async def confirm_activate_trial_handler(
await callback.answer(final_message_text_in_chat, show_alert=True)
except Exception:
pass
if settings.TRIAL_ENABLED and not await subscription_service.has_had_any_subscription(
session, user_id
if (
settings.TRIAL_ENABLED
and not await subscription_service.has_trial_blocking_subscription(session, user_id)
):
show_trial_button_after_action = True
@@ -452,10 +452,11 @@ def get_broadcast_confirmation_keyboard(
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
builder = InlineKeyboardBuilder()
# Row: target selection (all / active / inactive)
# Row: target selection (all / active / inactive / expired)
target_all_label = _(key="broadcast_target_all_button")
target_active_label = _(key="broadcast_target_active_button")
target_inactive_label = _(key="broadcast_target_inactive_button")
target_expired_label = _(key="broadcast_target_expired_button")
# Highlight current selection with a prefix
def mark_selected(label: str, is_selected: bool) -> str:
@@ -473,7 +474,10 @@ def get_broadcast_confirmation_keyboard(
text=mark_selected(target_inactive_label, target == "inactive"),
callback_data="broadcast_target:inactive",
)
builder.adjust(3)
builder.button(
text=mark_selected(target_expired_label, target == "expired"),
callback_data="broadcast_target:expired",
)
# Row: confirmation
builder.button(
@@ -482,7 +486,7 @@ def get_broadcast_confirmation_keyboard(
builder.button(
text=_(key="cancel_broadcast_button"), callback_data="broadcast_final_action:cancel"
)
builder.adjust(2)
builder.adjust(2, 2, 2)
return builder.as_markup()
+27 -6
View File
@@ -7,6 +7,10 @@ from bot.middlewares.i18n import locale_language_options
from bot.utils.install_links import bot_install_guide_url
from bot.utils.mini_app_url import subscription_mini_app_trial_url
from config.settings import Settings
from config.tariffs_config import (
default_currency_key_for_settings,
default_payment_currency_code_for_settings,
)
BOT_MENU_CONTEXT = "bot"
@@ -328,19 +332,31 @@ def get_tariff_catalog_keyboard(
tariffs: List[Any],
lang: str,
i18n_instance,
settings: Optional[Settings] = None,
back_callback: str = "main_action:back_to_main",
callback_context: Optional[str] = None,
) -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
callback_context = callback_context or callback_context_from_back_callback(back_callback)
default_currency = default_currency_key_for_settings(settings) if settings else "rub"
for tariff in tariffs:
label = tariff.name(lang)
if tariff.billing_model == "period":
min_price = tariff.min_period_price_rub()
if hasattr(tariff, "min_period_price"):
min_price = tariff.min_period_price(default_currency)
elif default_currency == "rub" and hasattr(tariff, "min_period_price_rub"):
min_price = tariff.min_period_price_rub()
else:
min_price = None
if min_price is not None:
label = f"{label} от {min_price:g}"
else:
package = tariff.min_traffic_package_rub()
if hasattr(tariff, "min_traffic_package"):
package = tariff.min_traffic_package(default_currency)
elif default_currency == "rub" and hasattr(tariff, "min_traffic_package_rub"):
package = tariff.min_traffic_package_rub()
else:
package = None
if package:
label = f"{label} от {package.price:g} / {package.gb:g} GB"
builder.row(
@@ -368,8 +384,10 @@ def get_tariff_periods_keyboard(
builder = InlineKeyboardBuilder()
callback_context = callback_context or callback_context_from_back_callback(back_callback)
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
default_currency = default_currency_key_for_settings(settings)
currency_code = default_payment_currency_code_for_settings(settings)
for months in tariff.enabled_periods:
rub_price = tariff.period_price(months, "rub")
rub_price = tariff.period_price(months, default_currency)
if rub_price and rub_price > 0:
builder.row(
InlineKeyboardButton(
@@ -377,7 +395,7 @@ def get_tariff_periods_keyboard(
"subscribe_for_months_button",
months=months,
price=rub_price,
currency_symbol=settings.DEFAULT_CURRENCY_SYMBOL,
currency_symbol=currency_code,
),
callback_data=f"tariff:period:{tariff.key}:{months}"
f"{callback_suffix_for_context(callback_context)}",
@@ -394,6 +412,7 @@ def get_tariff_packages_keyboard(
packages: List[Any],
lang: str,
i18n_instance,
currency_symbol: str = "RUB",
back_callback: str = "main_action:subscribe",
callback_context: Optional[str] = None,
) -> InlineKeyboardMarkup:
@@ -407,7 +426,7 @@ def get_tariff_packages_keyboard(
"buy_traffic_package_button",
traffic_gb=f"{package.gb:g}",
price=package.price,
currency_symbol="RUB",
currency_symbol=currency_symbol,
),
callback_data=f"tariff:package:{tariff.key}:{package.gb:g}"
f"{callback_suffix_for_context(callback_context)}",
@@ -430,6 +449,7 @@ def get_hwid_device_packages_keyboard(
) -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
currency_code = default_payment_currency_code_for_settings(settings)
for package in packages:
builder.row(
InlineKeyboardButton(
@@ -437,7 +457,7 @@ def get_hwid_device_packages_keyboard(
"buy_hwid_devices_button",
count=package.count,
price=package.price,
currency_symbol=settings.DEFAULT_CURRENCY_SYMBOL,
currency_symbol=currency_code,
),
callback_data=(
f"hwid_devices:{'renewal_package' if renewal else 'package'}:"
@@ -484,6 +504,7 @@ def get_payment_method_keyboard(
if (
not spec
or not spec.callback_prefix
or not spec.is_usable_for_payment_currency(settings, currency_symbol_val)
or not spec.is_available_to_user(
settings,
user_id=user_id,
@@ -11,6 +11,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from bot.keyboards.inline.user_keyboards import get_channel_subscription_keyboard
from bot.middlewares.i18n import JsonI18n
from bot.utils.channel_subscription import normalize_required_channel_id
from config.settings import Settings
from db.dal import user_dal
@@ -32,7 +33,7 @@ class ChannelSubscriptionMiddleware(BaseMiddleware):
event: Update,
data: Dict[str, Any],
) -> Any:
required_channel_id = self.settings.REQUIRED_CHANNEL_ID
required_channel_id = normalize_required_channel_id(self.settings.REQUIRED_CHANNEL_ID)
if not required_channel_id:
return await handler(event, data)
+68
View File
@@ -112,6 +112,7 @@ class WebAppPaymentContext:
stars_price: Optional[int]
description: str
sale_mode: str
currency: str = "RUB"
traffic_gb: Optional[float] = None
hwid_valid_from: Optional[Any] = None
hwid_valid_until: Optional[Any] = None
@@ -125,6 +126,36 @@ ServiceFactory = Callable[[ServiceFactoryContext], Any]
WebhookPathGetter = Callable[[Any], str]
WebhookRoute = Callable[[Any], Awaitable[Any]]
WebAppPaymentFactory = Callable[[WebAppPaymentContext], Awaitable[Any]]
CurrencySupportResolver = Callable[[Any], Optional[Sequence[str]]]
def normalize_payment_currency_code(value: Any, default: str = "RUB") -> str:
text = str(value or "").strip().upper()
if not text:
text = str(default).strip().upper() if default is not None else ""
if not text:
return ""
aliases = {"RUR": "RUB", "STARS": "XTR", "STAR": "XTR"}
normalized = aliases.get(text, text)
return "".join(ch for ch in normalized if ch.isalnum() or ch in {"_", "-"}).strip("_-")
def parse_supported_currency_codes(value: Any) -> tuple[str, ...]:
if value is None:
return ()
if isinstance(value, str):
raw_items = value.replace(";", ",").split(",")
else:
raw_items = list(value)
currencies: list[str] = []
seen: set[str] = set()
for item in raw_items:
code = normalize_payment_currency_code(item, default="")
if not code or code in seen:
continue
seen.add(code)
currencies.append(code)
return tuple(currencies)
@dataclass(frozen=True)
@@ -158,6 +189,10 @@ class PaymentProviderSpec:
admin_only_manifest_key: Optional[str] = None
admin_only_config_attr: str = "ADMIN_ONLY_ENABLED"
admin_only_enabled: Optional[EnabledPredicate] = None
supported_currencies: Optional[Sequence[str]] = ("RUB",)
supported_currencies_resolver: Optional[CurrencySupportResolver] = None
currency_support_note: str = ""
currency_support_url: Optional[str] = None
@property
def settings_key(self) -> str:
@@ -237,6 +272,39 @@ class PaymentProviderSpec:
service = app.get(self.service_key) if hasattr(app, "get") else None
return bool(service and getattr(service, "configured", False))
def _currency_source(self, source: Any) -> Any:
if self.config_class is not None and self.service_key:
from .registry import get_provider_bundle
bundle = get_provider_bundle(self.service_key)
if bundle and bundle.config is not None:
return bundle.config
return source
def supported_currency_codes(self, source: Any = None) -> Optional[tuple[str, ...]]:
if self.price_source == "stars":
return ("XTR",)
source_for_currency = self._currency_source(source)
if self.supported_currencies_resolver is not None:
resolved = self.supported_currencies_resolver(source_for_currency)
if resolved is None:
return None
return parse_supported_currency_codes(resolved)
if self.supported_currencies is None:
return None
return parse_supported_currency_codes(self.supported_currencies)
def supports_currency(self, source: Any, currency: Any) -> bool:
supported = self.supported_currency_codes(source)
if supported is None:
return True
return normalize_payment_currency_code(currency) in supported
def is_usable_for_payment_currency(self, source: Any, currency: Any) -> bool:
if self.price_source == "stars":
return True
return self.supports_currency(source, currency)
def is_visible(self, source: Any, app: Any) -> bool:
return self.is_enabled(source) and self.is_service_configured(app)
+58 -5
View File
@@ -17,6 +17,10 @@ from bot.middlewares.i18n import JsonI18n
from bot.services.referral_service import ReferralService
from bot.services.subscription_service import SubscriptionService
from config.settings import Settings
from config.tariffs_config import (
default_currency_key_for_settings,
default_payment_currency_code_for_settings,
)
from db.dal import payment_dal
from .base import (
@@ -25,6 +29,7 @@ from .base import (
ProviderManifestField,
ServiceFactoryContext,
WebAppPaymentContext,
normalize_payment_currency_code,
provider_env_file,
provider_runtime_enabled,
)
@@ -49,6 +54,34 @@ from .shared import (
logger = logging.getLogger(__name__)
_LOG = "cryptopay"
CRYPTOPAY_FIAT_CURRENCIES = (
"USD",
"EUR",
"RUB",
"BYN",
"UAH",
"GBP",
"CNY",
"KZT",
"UZS",
"GEL",
"TRY",
"AMD",
"THB",
"INR",
"BRL",
"IDR",
"AZN",
"AED",
"PLN",
"ILS",
)
CRYPTOPAY_CRYPTO_ASSETS = ("USDT", "TON", "BTC", "ETH", "LTC", "BNB", "TRX", "USDC")
def _cryptopay_supported_currencies(config) -> tuple[str, ...]:
currency_type = str(getattr(config, "CURRENCY_TYPE", "fiat") or "fiat").strip().lower()
return CRYPTOPAY_CRYPTO_ASSETS if currency_type == "crypto" else CRYPTOPAY_FIAT_CURRENCIES
class CryptoPayConfig(ProviderEnvConfig):
@@ -159,11 +192,23 @@ class CryptoPayService:
sale_mode: str = "subscription",
url_kind: str = "bot",
hwid_quote: Optional[dict] = None,
currency: Optional[str] = None,
) -> Optional[str]:
if not self.configured or not self.client:
logging.error("CryptoPayService not configured")
return None
currency_code = normalize_payment_currency_code(currency or self.config.ASSET)
currency_type = str(self.config.CURRENCY_TYPE or "fiat").strip().lower()
supported = _cryptopay_supported_currencies(self.config)
if currency_code not in supported:
logging.error(
"CryptoPay currency %s is not supported for currency_type=%s",
currency_code,
currency_type,
)
return None
sale_base = sale_mode_base(sale_mode)
amounts = payment_record_amounts(months=months, sale_mode=sale_mode)
try:
@@ -172,7 +217,7 @@ class CryptoPayService:
{
"user_id": user_id,
"amount": float(amount),
"currency": self.config.ASSET,
"currency": currency_code,
"status": "pending_cryptopay",
"description": description,
"subscription_duration_months": (
@@ -212,9 +257,9 @@ class CryptoPayService:
try:
invoice = await self.client.create_invoice(
amount=amount,
currency_type=self.config.CURRENCY_TYPE,
fiat=self.config.ASSET if self.config.CURRENCY_TYPE == "fiat" else None,
asset=self.config.ASSET if self.config.CURRENCY_TYPE == "crypto" else None,
currency_type=currency_type,
fiat=currency_code if currency_type == "fiat" else None,
asset=currency_code if currency_type == "crypto" else None,
description=description,
payload=payload,
)
@@ -393,7 +438,7 @@ async def pay_crypto_callback_handler(
user_id=callback.from_user.id,
parts=parts,
subscription_service=cryptopay_service.subscription_service,
currency="rub",
currency=default_currency_key_for_settings(settings),
)
if not parts:
await notify_callback_parse_error(callback, translator)
@@ -408,6 +453,7 @@ async def pay_crypto_callback_handler(
description=payment_description,
sale_mode=parts.sale_mode,
hwid_quote=hwid_quote,
currency=default_payment_currency_code_for_settings(settings),
)
if invoice_url:
@@ -457,6 +503,7 @@ async def create_webapp_payment(ctx: WebAppPaymentContext) -> web.Response:
description=ctx.description,
sale_mode=ctx.sale_mode,
url_kind="web",
currency=ctx.currency,
hwid_quote={
"valid_from": ctx.hwid_valid_from,
"valid_until": ctx.hwid_valid_until,
@@ -592,4 +639,10 @@ SPEC = PaymentProviderSpec(
config_class=CryptoPayConfig,
presentation_class=CryptoPayPresentation,
manifest_fields=_CONFIG_MANIFEST + _PRESENTATION_MANIFEST,
supported_currencies_resolver=_cryptopay_supported_currencies,
currency_support_note=(
"Crypto Pay supports different sets for fiat invoices and crypto invoices; "
"CURRENCY_TYPE selects which set is active."
),
currency_support_url="https://help.crypt.bot/crypto-pay-api/",
)
+24 -6
View File
@@ -20,6 +20,10 @@ from bot.services.referral_service import ReferralService
from bot.services.subscription_service import SubscriptionService
from bot.utils.request_security import ip_in_allowlist, request_client_ip
from config.settings import Settings
from config.tariffs_config import (
default_currency_key_for_settings,
default_payment_currency_code_for_settings,
)
from db.dal import payment_dal
from .base import (
@@ -28,6 +32,7 @@ from .base import (
ProviderManifestField,
ServiceFactoryContext,
WebAppPaymentContext,
normalize_payment_currency_code,
provider_env_file,
provider_runtime_enabled,
)
@@ -56,6 +61,7 @@ from .shared import (
)
_LOG = "freekassa"
FREEKASSA_SUPPORTED_CURRENCIES = ("RUB", "USD", "EUR", "UAH", "KZT")
class FreeKassaConfig(ProviderEnvConfig):
@@ -144,7 +150,7 @@ class FreeKassaService(HttpClientMixin):
self.subscription_service = subscription_service
self.referral_service = referral_service
self.default_currency: str = (settings.DEFAULT_CURRENCY_SYMBOL or "RUB").upper()
self.default_currency: str = default_payment_currency_code_for_settings(settings).upper()
self.api_base_url: str = "https://api.fk.life/v1"
self._init_http_client(total_timeout=15)
@@ -207,7 +213,13 @@ class FreeKassaService(HttpClientMixin):
return False, {"message": "missing_ip"}
email = email or f"{user_id}@telegram.org"
currency_code = (currency or self.default_currency or "RUB").upper()
currency_code = normalize_payment_currency_code(currency or self.default_currency or "RUB")
if currency_code not in FREEKASSA_SUPPORTED_CURRENCIES:
return False, {
"message": "unsupported_currency",
"currency": currency_code,
"supported_currencies": list(FREEKASSA_SUPPORTED_CURRENCIES),
}
payload: Dict[str, Any] = {
"shopId": int(self.shop_id),
@@ -477,7 +489,7 @@ async def pay_fk_callback_handler(
user_id=callback.from_user.id,
parts=parts,
subscription_service=freekassa_service.subscription_service,
currency="rub",
currency=default_currency_key_for_settings(settings),
)
if not parts:
await notify_callback_parse_error(callback, translator)
@@ -485,7 +497,7 @@ async def pay_fk_callback_handler(
currency_code = (
getattr(freekassa_service, "default_currency", None)
or settings.DEFAULT_CURRENCY_SYMBOL
or default_payment_currency_code_for_settings(settings)
or "RUB"
)
payment_description = describe_payment(translator, parts)
@@ -578,12 +590,13 @@ async def create_webapp_payment(ctx: WebAppPaymentContext) -> web.Response:
service: FreeKassaService = ctx.request.app["freekassa_service"]
if not service or not service.configured or not service.payment_method_id:
return payment_unavailable()
currency = ctx.currency or service.default_currency
try:
payment = await create_webapp_payment_record(
ctx,
amount=ctx.price,
currency=service.default_currency,
currency=currency,
status="pending_freekassa",
provider="freekassa",
)
@@ -592,7 +605,7 @@ async def create_webapp_payment(ctx: WebAppPaymentContext) -> web.Response:
user_id=ctx.user_id,
months=ctx.months,
amount=ctx.price,
currency=service.default_currency,
currency=currency,
payment_method_id=service.payment_method_id,
ip_address=service.server_ip,
extra_params={"us_method": service.payment_method_id},
@@ -762,4 +775,9 @@ SPEC = PaymentProviderSpec(
config_class=FreeKassaConfig,
presentation_class=FreeKassaPresentation,
manifest_fields=_CONFIG_MANIFEST + _PRESENTATION_MANIFEST,
supported_currencies=FREEKASSA_SUPPORTED_CURRENCIES,
currency_support_note=(
"FreeKassa SCI documents the payment currency parameter as RUB, USD, EUR, UAH or KZT."
),
currency_support_url="https://docs.freekassa.net/",
)
+44 -4
View File
@@ -18,6 +18,10 @@ from bot.services.referral_service import ReferralService
from bot.services.subscription_service import SubscriptionService
from bot.utils.request_security import ip_in_allowlist, request_client_ip
from config.settings import Settings
from config.tariffs_config import (
default_currency_key_for_settings,
default_payment_currency_code_for_settings,
)
from db.dal import payment_dal
from .base import (
@@ -26,6 +30,8 @@ from .base import (
ProviderManifestField,
ServiceFactoryContext,
WebAppPaymentContext,
normalize_payment_currency_code,
parse_supported_currency_codes,
provider_env_file,
provider_runtime_enabled,
)
@@ -59,6 +65,10 @@ _LOG = "heleket"
_SUCCESS_STATUSES = {"paid", "paid_over"}
_FAILED_STATUSES = {"fail", "wrong_amount", "cancel", "system_fail"}
HELEKET_DEFAULT_SUPPORTED_CURRENCIES = (
"RUB,USD,EUR,USDT,USDC,BTC,ETH,LTC,TON,TRX,BNB,BCH,DASH,DAI,DOGE,"
"MATIC,SHIB,SOL,XMR,AVAX,BUSD,VERSE"
)
class HeleketConfig(ProviderEnvConfig):
@@ -83,6 +93,7 @@ class HeleketConfig(ProviderEnvConfig):
LIFETIME_SECONDS: int = Field(default=3600)
VERIFY_WEBHOOK_SIGNATURE: bool = Field(default=True)
TRUSTED_IPS: str = Field(default="31.133.220.8")
SUPPORTED_CURRENCIES: str = Field(default=HELEKET_DEFAULT_SUPPORTED_CURRENCIES)
@field_validator("LIFETIME_SECONDS", mode="before")
@classmethod
@@ -299,9 +310,18 @@ class HeleketService(HttpClientMixin):
logging.error("HeleketService is not configured. Cannot create payment link.")
return False, {"message": "service_not_configured"}
currency_code = normalize_payment_currency_code(currency or self.currency)
supported = parse_supported_currency_codes(self.config.SUPPORTED_CURRENCIES)
if supported and currency_code not in supported:
return False, {
"message": "unsupported_currency",
"currency": currency_code,
"supported_currencies": list(supported),
}
body: Dict[str, Any] = {
"amount": str(format_decimal_amount(amount)),
"currency": (currency or self.currency).upper(),
"currency": currency_code,
"order_id": str(payment_db_id),
"url_return": self.return_url,
"url_success": self.success_url,
@@ -572,13 +592,13 @@ async def pay_heleket_callback_handler(
user_id=callback.from_user.id,
parts=parts,
subscription_service=heleket_service.subscription_service,
currency="rub",
currency=default_currency_key_for_settings(settings),
)
if not parts:
await notify_callback_parse_error(callback, translator)
return
currency_code = (heleket_service.currency or settings.DEFAULT_CURRENCY_SYMBOL or "RUB").upper()
currency_code = default_payment_currency_code_for_settings(settings)
payment_description = describe_payment(translator, parts)
record_payload = build_payment_record_payload(
user_id=callback.from_user.id,
@@ -632,7 +652,7 @@ async def create_webapp_payment(ctx: WebAppPaymentContext) -> web.Response:
if not service or not service.configured:
return payment_unavailable()
currency = (service.currency or settings.DEFAULT_CURRENCY_SYMBOL or "RUB").upper()
currency = ctx.currency or default_payment_currency_code_for_settings(settings)
try:
payment = await create_webapp_payment_record(
ctx,
@@ -787,6 +807,18 @@ _CONFIG_MANIFEST = (
subsection="Heleket",
attr="CURRENCY",
),
ProviderManifestField(
"HELEKET_SUPPORTED_CURRENCIES",
"string",
"Supported currencies",
description=(
"Comma-separated invoice currencies allowed for Heleket in this shop. "
"Heleket can reject unsupported codes per account/service."
),
placeholder=HELEKET_DEFAULT_SUPPORTED_CURRENCIES,
subsection="Heleket",
attr="SUPPORTED_CURRENCIES",
),
ProviderManifestField(
"HELEKET_TO_CURRENCY",
"string",
@@ -859,4 +891,12 @@ SPEC = PaymentProviderSpec(
config_class=HeleketConfig,
presentation_class=HeleketPresentation,
manifest_fields=_CONFIG_MANIFEST + _PRESENTATION_MANIFEST,
supported_currencies_resolver=lambda config: getattr(
config, "SUPPORTED_CURRENCIES", HELEKET_DEFAULT_SUPPORTED_CURRENCIES
),
currency_support_note=(
"Heleket supports crypto and fiat invoice currencies, but exact availability "
"can depend on service/account settings."
),
currency_support_url="https://doc.heleket.com/methods/payments/creating-invoice",
)
+46 -5
View File
@@ -14,6 +14,10 @@ from bot.middlewares.i18n import JsonI18n
from bot.services.referral_service import ReferralService
from bot.services.subscription_service import SubscriptionService
from config.settings import Settings
from config.tariffs_config import (
default_currency_key_for_settings,
default_payment_currency_code_for_settings,
)
from db.dal import payment_dal
from .base import (
@@ -22,6 +26,8 @@ from .base import (
ProviderManifestField,
ServiceFactoryContext,
WebAppPaymentContext,
normalize_payment_currency_code,
parse_supported_currency_codes,
provider_env_file,
provider_runtime_enabled,
)
@@ -76,6 +82,7 @@ class PlategaConfig(ProviderEnvConfig):
CRYPTO_METHOD: int = Field(default=13)
RETURN_URL: Optional[str] = None
FAILED_URL: Optional[str] = None
SUPPORTED_CURRENCIES: str = Field(default="RUB")
@field_validator("MERCHANT_ID", "SECRET", "RETURN_URL", "FAILED_URL", mode="before")
@classmethod
@@ -229,9 +236,19 @@ class PlategaService(HttpClientMixin):
logging.error("PlategaService is not configured. Cannot create transaction.")
return False, {"message": "service_not_configured"}
currency_code = normalize_payment_currency_code(
currency or self.settings.DEFAULT_CURRENCY_SYMBOL or "RUB"
)
supported = parse_supported_currency_codes(self.config.SUPPORTED_CURRENCIES)
if supported and currency_code not in supported:
return False, {
"message": "unsupported_currency",
"currency": currency_code,
"supported_currencies": list(supported),
}
session = await self._get_session()
url = f"{self.base_url}/transaction/process"
currency_code = (currency or self.settings.DEFAULT_CURRENCY_SYMBOL or "RUB").upper()
method_id = int(payment_method if payment_method is not None else self.payment_method)
body: Dict[str, Any] = {
@@ -482,13 +499,13 @@ async def pay_platega_callback_handler(
user_id=callback.from_user.id,
parts=parts,
subscription_service=platega_service.subscription_service,
currency="rub",
currency=default_currency_key_for_settings(settings),
)
if not parts:
await notify_callback_parse_error(callback, translator)
return
currency_code = settings.DEFAULT_CURRENCY_SYMBOL or "RUB"
currency_code = default_payment_currency_code_for_settings(settings)
payment_description = describe_payment(translator, parts)
record_payload = build_payment_record_payload(
user_id=callback.from_user.id,
@@ -596,7 +613,7 @@ async def _create_webapp_payment(ctx: WebAppPaymentContext, variant: str) -> web
payment = await create_webapp_payment_record(
ctx,
amount=ctx.price,
currency=settings.DEFAULT_CURRENCY_SYMBOL or "RUB",
currency=ctx.currency or settings.DEFAULT_CURRENCY_SYMBOL or "RUB",
status="pending_platega",
provider="platega",
)
@@ -616,7 +633,7 @@ async def _create_webapp_payment(ctx: WebAppPaymentContext, variant: str) -> web
)
success, response_data = await service.create_transaction(
amount=ctx.price,
currency=settings.DEFAULT_CURRENCY_SYMBOL or "RUB",
currency=ctx.currency or settings.DEFAULT_CURRENCY_SYMBOL or "RUB",
description=ctx.description,
payload=payload,
payment_method=platega_method_id,
@@ -757,6 +774,18 @@ _CONFIG_MANIFEST = (
subsection="Platega",
attr="CRYPTO_METHOD",
),
ProviderManifestField(
"PLATEGA_SUPPORTED_CURRENCIES",
"string",
"Supported currencies",
description=(
"Comma-separated payment currencies enabled for your Platega merchant. "
"Public docs expose currency per method/limits but do not publish a fixed global list."
),
placeholder="RUB",
subsection="Platega",
attr="SUPPORTED_CURRENCIES",
),
ProviderManifestField(
"PLATEGA_RETURN_URL", "url", "Return URL", subsection="Platega", attr="RETURN_URL"
),
@@ -793,6 +822,12 @@ SBP_SPEC = PaymentProviderSpec(
presentation_class=PlategaSbpPresentation,
manifest_fields=_CONFIG_MANIFEST
+ _platega_presentation_manifest("Platega", "CreditCard", "PLATEGA_SBP"),
supported_currencies_resolver=lambda config: getattr(config, "SUPPORTED_CURRENCIES", "RUB"),
currency_support_note=(
"Platega currencies are merchant/method-specific; configure the codes "
"enabled for your account."
),
currency_support_url="https://docs.platega.io/",
)
CRYPTO_SPEC = PaymentProviderSpec(
@@ -818,6 +853,12 @@ CRYPTO_SPEC = PaymentProviderSpec(
config_class=PlategaConfig,
presentation_class=PlategaCryptoPresentation,
manifest_fields=_platega_presentation_manifest("Platega", "Bitcoin", "PLATEGA_CRYPTO"),
supported_currencies_resolver=lambda config: getattr(config, "SUPPORTED_CURRENCIES", "RUB"),
currency_support_note=(
"Platega currencies are merchant/method-specific; configure the codes "
"enabled for your account."
),
currency_support_url="https://docs.platega.io/",
)
SPECS = (SBP_SPEC, CRYPTO_SPEC)
+38 -4
View File
@@ -16,6 +16,10 @@ from bot.middlewares.i18n import JsonI18n
from bot.services.referral_service import ReferralService
from bot.services.subscription_service import SubscriptionService
from config.settings import Settings
from config.tariffs_config import (
default_currency_key_for_settings,
default_payment_currency_code_for_settings,
)
from db.dal import payment_dal
from .base import (
@@ -24,6 +28,8 @@ from .base import (
ProviderManifestField,
ServiceFactoryContext,
WebAppPaymentContext,
normalize_payment_currency_code,
parse_supported_currency_codes,
provider_env_file,
provider_runtime_enabled,
)
@@ -69,6 +75,7 @@ class SeverPayConfig(ProviderEnvConfig):
RETURN_URL: Optional[str] = None
BASE_URL: str = Field(default="https://severpay.io/api/merchant")
LIFETIME_MINUTES: Optional[int] = None
SUPPORTED_CURRENCIES: str = Field(default="RUB,USD")
@field_validator("MID", "LIFETIME_MINUTES", mode="before")
@classmethod
@@ -201,9 +208,19 @@ class SeverPayService(HttpClientMixin):
logging.error("SeverPayService is not configured. Cannot create payment.")
return False, {"message": "service_not_configured"}
currency_code = normalize_payment_currency_code(
currency or self.settings.DEFAULT_CURRENCY_SYMBOL or "RUB"
)
supported = parse_supported_currency_codes(self.config.SUPPORTED_CURRENCIES)
if supported and currency_code not in supported:
return False, {
"message": "unsupported_currency",
"currency": currency_code,
"supported_currencies": list(supported),
}
session = await self._get_session()
url = f"{self.base_url}/payin/create"
currency_code = (currency or self.settings.DEFAULT_CURRENCY_SYMBOL or "RUB").upper()
body = {
"order_id": str(payment_db_id),
@@ -428,13 +445,13 @@ async def pay_severpay_callback_handler(
user_id=callback.from_user.id,
parts=parts,
subscription_service=severpay_service.subscription_service,
currency="rub",
currency=default_currency_key_for_settings(settings),
)
if not parts:
await notify_callback_parse_error(callback, translator)
return
currency_code = settings.DEFAULT_CURRENCY_SYMBOL or "RUB"
currency_code = default_payment_currency_code_for_settings(settings)
payment_description = describe_payment(translator, parts)
record_payload = build_payment_record_payload(
user_id=callback.from_user.id,
@@ -503,7 +520,7 @@ async def create_webapp_payment(ctx: WebAppPaymentContext) -> web.Response:
if not service or not service.configured:
return payment_unavailable()
currency = settings.DEFAULT_CURRENCY_SYMBOL or "RUB"
currency = ctx.currency or settings.DEFAULT_CURRENCY_SYMBOL or "RUB"
try:
payment = await create_webapp_payment_record(
ctx,
@@ -627,6 +644,18 @@ _CONFIG_MANIFEST = (
max=4320,
attr="LIFETIME_MINUTES",
),
ProviderManifestField(
"SEVERPAY_SUPPORTED_CURRENCIES",
"string",
"Supported currencies",
description=(
"Comma-separated currencies enabled for your SeverPay merchant. "
"The public PayIn docs show USD examples but do not publish a fixed global list."
),
placeholder="RUB,USD",
subsection="SeverPay",
attr="SUPPORTED_CURRENCIES",
),
)
@@ -651,4 +680,9 @@ SPEC = PaymentProviderSpec(
config_class=SeverPayConfig,
presentation_class=SeverPayPresentation,
manifest_fields=_CONFIG_MANIFEST + _PRESENTATION_MANIFEST,
supported_currencies_resolver=lambda config: getattr(config, "SUPPORTED_CURRENCIES", "RUB,USD"),
currency_support_note=(
"SeverPay PayIn requires a currency; keep this list aligned with your merchant account."
),
currency_support_url="https://docs.severpay.io/ru/payin/create",
)
+2
View File
@@ -484,4 +484,6 @@ SPEC = PaymentProviderSpec(
telegram_emoji="",
presentation_class=StarsPresentation,
manifest_fields=_PRESENTATION_MANIFEST,
supported_currencies=("XTR",),
currency_support_note="Telegram Stars use Telegram's XTR currency and separate Stars prices.",
)
+25 -4
View File
@@ -19,6 +19,10 @@ from bot.services.referral_service import ReferralService
from bot.services.subscription_service import SubscriptionService
from bot.utils.request_security import ip_in_allowlist, request_client_ip
from config.settings import Settings
from config.tariffs_config import (
default_currency_key_for_settings,
default_payment_currency_code_for_settings,
)
from db.dal import payment_dal
from .base import (
@@ -27,6 +31,7 @@ from .base import (
ProviderManifestField,
ServiceFactoryContext,
WebAppPaymentContext,
normalize_payment_currency_code,
provider_env_file,
provider_runtime_enabled,
)
@@ -63,6 +68,7 @@ from .shared import (
router = Router(name="user_subscription_payments_wata_router")
_LOG = "wata"
WATA_SUPPORTED_CURRENCIES = ("RUB", "USD", "EUR")
_WATA_IN_PROGRESS_STATUSES = {"created", "pending"}
_WATA_LINK_OPENED_STATUSES = {"opened", "open"}
_WATA_LINK_DEFAULT_TTL_MINUTES = 15
@@ -258,13 +264,23 @@ class WataService(HttpClientMixin):
logging.error("WataService is not configured. Cannot create payment link.")
return False, {"message": "service_not_configured"}
currency_code = normalize_payment_currency_code(
currency or self.settings.DEFAULT_CURRENCY_SYMBOL or "RUB"
)
if currency_code not in WATA_SUPPORTED_CURRENCIES:
return False, {
"message": "unsupported_currency",
"currency": currency_code,
"supported_currencies": list(WATA_SUPPORTED_CURRENCIES),
}
session = await self._get_session()
expires_at = (
datetime.now(timezone.utc) + timedelta(minutes=self.payment_link_ttl_minutes)
).replace(microsecond=0)
body: Dict[str, Any] = {
"amount": float(format_decimal_amount(amount)),
"currency": (currency or self.settings.DEFAULT_CURRENCY_SYMBOL or "RUB").upper(),
"currency": currency_code,
"description": description,
"orderId": str(payment_db_id),
"successRedirectUrl": self.return_url,
@@ -863,13 +879,13 @@ async def pay_wata_callback_handler(
user_id=callback.from_user.id,
parts=parts,
subscription_service=wata_service.subscription_service,
currency="rub",
currency=default_currency_key_for_settings(settings),
)
if not parts:
await notify_callback_parse_error(callback, translator)
return
currency_code = settings.DEFAULT_CURRENCY_SYMBOL or "RUB"
currency_code = default_payment_currency_code_for_settings(settings)
payment_description = describe_payment(translator, parts)
reuse_amounts = payment_record_amounts(months=parts.months, sale_mode=parts.sale_mode)
@@ -956,7 +972,7 @@ async def create_webapp_payment(ctx: WebAppPaymentContext) -> web.Response:
if not service or not service.configured:
return payment_unavailable()
currency = settings.DEFAULT_CURRENCY_SYMBOL or "RUB"
currency = ctx.currency or settings.DEFAULT_CURRENCY_SYMBOL or "RUB"
reuse_amounts = payment_record_amounts(
months=ctx.months,
@@ -1190,4 +1206,9 @@ SPEC = PaymentProviderSpec(
config_class=WataConfig,
presentation_class=WataPresentation,
manifest_fields=_CONFIG_MANIFEST + _PRESENTATION_MANIFEST,
supported_currencies=WATA_SUPPORTED_CURRENCIES,
currency_support_note=(
"WATA H2H payment links and widget document RUB, USD and EUR as payment currencies."
),
currency_support_url="https://wata.pro/api",
)
+28 -8
View File
@@ -40,6 +40,10 @@ from bot.utils.config_link import prepare_config_links
from bot.utils.install_links import ensure_user_install_guide_links
from bot.utils.request_security import ip_in_allowlist, request_client_ip
from config.settings import Settings
from config.tariffs_config import (
default_currency_key_for_settings,
default_payment_currency_code_for_settings,
)
from db.dal import payment_dal, user_billing_dal, user_dal
from db.models import Payment
@@ -49,6 +53,7 @@ from .base import (
ProviderManifestField,
ServiceFactoryContext,
WebAppPaymentContext,
normalize_payment_currency_code,
provider_env_file,
provider_runtime_enabled,
)
@@ -234,6 +239,11 @@ class YooKassaService:
"internal_message": "Service settings (Settings object) not initialized.",
}
currency = normalize_payment_currency_code(currency)
if currency != "RUB":
logging.error("YooKassa currency %s is not supported by this integration", currency)
return None
customer_contact_for_receipt = {}
if receipt_email:
customer_contact_for_receipt["email"] = receipt_email
@@ -885,7 +895,10 @@ async def process_successful_payment(
i18n=i18n,
user_id=user_id,
amount=payment_value,
currency=settings.DEFAULT_CURRENCY_SYMBOL,
currency=amount_data.get(
"currency",
default_payment_currency_code_for_settings(settings),
),
months_for_admin=int(subscription_months) if sale_mode_base == "subscription" else 0,
traffic_gb_for_admin=(
traffic_amount_gb if is_traffic_sale_base(sale_mode_base) else None
@@ -1702,7 +1715,7 @@ async def pay_yk_callback_handler(
user_id=callback.from_user.id,
parts=PaymentCallbackParts(months=months, price=price_rub, sale_mode=sale_mode),
subscription_service=yookassa_service.subscription_service,
currency="rub",
currency=default_currency_key_for_settings(settings),
)
if not quoted_parts:
try:
@@ -1713,7 +1726,7 @@ async def pay_yk_callback_handler(
months = quoted_parts.months
price_rub = quoted_parts.price
user_id = callback.from_user.id
currency_code_for_yk = "RUB"
currency_code_for_yk = default_payment_currency_code_for_settings(settings)
autopay_enabled = bool(
settings.yookassa_autopayments_active
and _sale_mode_base(sale_mode) == "subscription"
@@ -1851,7 +1864,7 @@ async def pay_yk_new_card_handler(
months, price_rub, sale_mode = parsed
user_id = callback.from_user.id
currency_code_for_yk = "RUB"
currency_code_for_yk = default_payment_currency_code_for_settings(settings)
autopay_enabled = bool(
settings.yookassa_autopayments_active
and _sale_mode_base(sale_mode) == "subscription"
@@ -2154,7 +2167,7 @@ async def pay_yk_use_saved_handler(
pass
return
currency_code_for_yk = "RUB"
currency_code_for_yk = default_payment_currency_code_for_settings(settings)
await _initiate_yk_payment(
callback,
@@ -2260,7 +2273,7 @@ async def payment_method_bind(
metadata = {"user_id": str(callback.from_user.id), "bind_only": "1"}
resp = await yookassa_service.create_payment(
amount=1.00,
currency="RUB",
currency=default_payment_currency_code_for_settings(settings),
description="Bind card",
metadata=metadata,
receipt_email=yookassa_service.config.DEFAULT_RECEIPT_EMAIL,
@@ -2734,6 +2747,7 @@ async def create_webapp_payment(ctx: WebAppPaymentContext) -> web.Response:
service: YooKassaService = ctx.request.app["yookassa_service"]
if not service or not service.configured:
return payment_unavailable()
currency = (ctx.currency or "RUB").upper()
try:
amounts = payment_record_amounts(
@@ -2744,7 +2758,7 @@ async def create_webapp_payment(ctx: WebAppPaymentContext) -> web.Response:
payment = await create_webapp_payment_record(
ctx,
amount=ctx.price,
currency="RUB",
currency=currency,
status="pending_yookassa",
provider="yookassa",
)
@@ -2767,7 +2781,7 @@ async def create_webapp_payment(ctx: WebAppPaymentContext) -> web.Response:
metadata["tariff_key"] = amounts.tariff_key
response = await service.create_payment(
amount=ctx.price,
currency="RUB",
currency=currency,
description=ctx.description,
metadata=metadata,
receipt_email=service.config.DEFAULT_RECEIPT_EMAIL,
@@ -2929,4 +2943,10 @@ SPEC = PaymentProviderSpec(
config_class=YooKassaConfig,
presentation_class=YooKassaPresentation,
manifest_fields=_CONFIG_MANIFEST + _PRESENTATION_MANIFEST,
supported_currencies=("RUB",),
currency_support_note=(
"YooKassa public payment API examples and limits are RUB-based; "
"treat non-RUB as unsupported unless your YooKassa contract confirms otherwise."
),
currency_support_url="https://yookassa.ru/developers/payment-acceptance/integration-scenarios/smart-payment",
)
@@ -9,11 +9,16 @@ import shutil
import subprocess
import tempfile
import zipfile
from concurrent.futures import ThreadPoolExecutor
from dataclasses import dataclass, field
from datetime import datetime, timezone
from pathlib import Path, PurePosixPath
from typing import Any, Optional
from sqlalchemy import inspect, text
from sqlalchemy.engine import Connection
from sqlalchemy.ext.asyncio import create_async_engine
from bot.services.backup_archive import (
BACKUP_APP_ID,
BACKUP_FILENAME_PREFIX,
@@ -29,6 +34,8 @@ from bot.services.backup_worker import (
DEFAULT_COMPOSE_EXCLUDED_DIRS,
)
from config.settings import Settings
from db.migrator import MIGRATIONS, run_database_migrations
from db.models import Base
logger = logging.getLogger(__name__)
@@ -42,6 +49,23 @@ BACKUP_MAX_COMPRESSION_RATIO = 200
BACKUP_ZIP_BOMB_MIN_BYTES = 100 * 1024 * 1024
COMPOSE_PRE_RESTORE_PREFIX = "minishop-pre-restore-"
SAFE_ARCHIVE_NAME_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.@+-]{0,220}\.zip$")
DB_RESTORE_MIGRATION_ADVISORY_LOCK_ID = 817512404897421337
def _applied_migration_ids(connection: Connection) -> set[str]:
inspector = inspect(connection)
if "schema_migrations" not in inspector.get_table_names():
return set()
return {row[0] for row in connection.execute(text("SELECT id FROM schema_migrations"))}
def _create_missing_tables_and_migrate(connection: Connection) -> list[str]:
before = _applied_migration_ids(connection)
Base.metadata.create_all(connection)
run_database_migrations(connection)
after = _applied_migration_ids(connection)
newly_applied = after - before
return [migration.id for migration in MIGRATIONS if migration.id in newly_applied]
class BackupArchiveError(ValueError):
@@ -92,6 +116,7 @@ class BackupRestoreResult:
compose_files_restored: int = 0
compose_target_dir: Optional[str] = None
compose_pre_restore_archive: Optional[str] = None
database_migrations_applied: list[str] = field(default_factory=list)
warnings: list[str] = field(default_factory=list)
def to_payload(self) -> dict[str, Any]:
@@ -103,6 +128,7 @@ class BackupRestoreResult:
"compose_files_restored": self.compose_files_restored,
"compose_target_dir": self.compose_target_dir,
"compose_pre_restore_archive": self.compose_pre_restore_archive,
"database_migrations_applied": self.database_migrations_applied,
"warnings": self.warnings,
}
@@ -242,9 +268,11 @@ class BackupRestoreService:
compose_pre_restore_archive = self._snapshot_current_compose(compose_target_dir)
database_restored = False
database_migrations_applied: list[str] = []
if db_member is not None:
dump_path = self._extract_database_dump(archive, db_member, temp_dir)
self._run_pg_restore(dump_path)
database_migrations_applied = self._run_post_restore_migrations()
database_restored = True
compose_files_restored = 0
@@ -265,9 +293,50 @@ class BackupRestoreService:
compose_pre_restore_archive=str(compose_pre_restore_archive)
if compose_pre_restore_archive
else None,
database_migrations_applied=database_migrations_applied,
warnings=warnings,
)
def _run_post_restore_migrations(self) -> list[str]:
try:
asyncio.get_running_loop()
except RuntimeError:
run_migrations = lambda: asyncio.run(self._run_post_restore_migrations_async())
else:
run_migrations = self._run_post_restore_migrations_in_thread
try:
return run_migrations()
except BackupRestoreError:
raise
except Exception as exc:
raise BackupRestoreError(
f"Database restore completed, but post-restore migrations failed: {str(exc)[:500]}"
) from exc
def _run_post_restore_migrations_in_thread(self) -> list[str]:
with ThreadPoolExecutor(max_workers=1, thread_name_prefix="backup-restore-migrate") as pool:
return pool.submit(
lambda: asyncio.run(self._run_post_restore_migrations_async())
).result()
async def _run_post_restore_migrations_async(self) -> list[str]:
engine = create_async_engine(
self.settings.DATABASE_URL,
echo=False,
pool_pre_ping=True,
pool_size=1,
max_overflow=0,
)
try:
async with engine.begin() as connection:
await connection.execute(
text(f"SELECT pg_advisory_xact_lock({DB_RESTORE_MIGRATION_ADVISORY_LOCK_ID})")
)
return await connection.run_sync(_create_missing_tables_and_migrate)
finally:
await engine.dispose()
def _run_pg_restore(self, dump_path: Path) -> None:
pg_restore_path = str(getattr(self.settings, "BACKUP_PG_RESTORE_PATH", "pg_restore") or "")
pg_restore_path = pg_restore_path or "pg_restore"
+24 -1
View File
@@ -17,8 +17,9 @@ from sqlalchemy.ext.asyncio import AsyncSession
from bot.middlewares.i18n import JsonI18n
from bot.services.email_templates import EmailContent, render_login_code
from bot.services.message_audit import log_user_message_delivery
from config.settings import Settings
from db.dal import security_dal
from db.dal import security_dal, user_dal
from db.models import EmailVerificationCode
logger = logging.getLogger(__name__)
@@ -222,6 +223,28 @@ class EmailAuthService:
magic_link=magic_link,
purpose=purpose,
)
resolved_target_user_id = target_user_id
if resolved_target_user_id is None:
try:
existing_user = await user_dal.get_user_by_email(session, normalized_email)
resolved_target_user_id = (
int(existing_user.user_id) if existing_user is not None else None
)
except Exception:
logger.exception(
"Failed to resolve email auth target user for audit log: %s",
normalized_email,
)
await log_user_message_delivery(
session,
target_user_id=resolved_target_user_id,
event_type="email_login_code_sent"
if purpose == "login"
else "email_verification_code_sent",
channel="email",
recipient=normalized_email,
content=f"purpose={purpose} magic_link={bool(magic_link)}",
)
return EmailCodeRequestResult(ok=True)
async def verify_code(
+82 -41
View File
@@ -12,11 +12,12 @@ from __future__ import annotations
import html
import re
from dataclasses import dataclass
from typing import Optional, Sequence, Tuple
from typing import TYPE_CHECKING, Optional, Sequence, Tuple
from urllib.parse import urlsplit
from bot.middlewares.i18n import JsonI18n, get_i18n_instance, normalize_locale_language_code
from config.settings import Settings
if TYPE_CHECKING:
from bot.middlewares.i18n import JsonI18n
from config.settings import Settings
_BG = "#05070a"
_CARD_BG = "#0e1116"
@@ -64,14 +65,16 @@ def _brand_title(settings: Settings) -> str:
def _normalize_lang(language_code: Optional[str], settings: Settings) -> str:
return normalize_locale_language_code(
language_code or settings.DEFAULT_LANGUAGE or "ru",
prefer_known_base=False,
)
value = str(language_code or settings.DEFAULT_LANGUAGE or "ru").strip().lower()
return value.replace("_", "-") or "ru"
def _resolve_i18n(i18n: Optional[JsonI18n]) -> JsonI18n:
return i18n or get_i18n_instance()
if i18n is not None:
return i18n
from bot.middlewares.i18n import get_i18n_instance
return get_i18n_instance()
def _t_html(i18n: JsonI18n, lang: str, key: str, **kwargs) -> str:
@@ -89,6 +92,7 @@ def _t_text(i18n: JsonI18n, lang: str, key: str, **kwargs) -> str:
def _layout(
*,
settings: Settings,
language_code: str,
preheader: str,
heading: str,
intro_html: str,
@@ -98,6 +102,7 @@ def _layout(
accent = _safe_color(settings.WEBAPP_PRIMARY_COLOR)
brand_title = html.escape(_brand_title(settings))
logo_url = _public_logo_url(settings)
html_lang = html.escape((language_code or "en").replace("_", "-"), quote=True)
logo_block = ""
if logo_url:
logo_block = (
@@ -107,7 +112,7 @@ def _layout(
)
return f"""<!DOCTYPE html>
<html lang="en" xmlns="http://www.w3.org/1999/xhtml">
<html lang="{html_lang}" xmlns="http://www.w3.org/1999/xhtml">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
@@ -307,6 +312,7 @@ def render_login_code(
rendered = _layout(
settings=settings,
language_code=lang,
preheader=preheader,
heading=heading,
intro_html=html.escape(intro),
@@ -359,6 +365,7 @@ def render_account_merged(
rendered = _layout(
settings=settings,
language_code=lang,
preheader=preheader,
heading=heading,
intro_html=html.escape(intro),
@@ -492,6 +499,7 @@ def render_payment_success(
rendered = _layout(
settings=settings,
language_code=lang,
preheader=preheader,
heading=heading,
intro_html=html.escape(intro),
@@ -542,6 +550,7 @@ def render_user_notification(
rendered = _layout(
settings=settings,
language_code=lang,
preheader=final_subject,
heading=final_heading,
intro_html=html.escape(final_intro),
@@ -615,6 +624,7 @@ def render_subscription_expiring(
rendered = _layout(
settings=settings,
language_code=lang,
preheader=preheader,
heading=heading,
intro_html=html.escape(intro),
@@ -702,6 +712,7 @@ def render_subscription_lifecycle_notification(
rendered = _layout(
settings=settings,
language_code=lang,
preheader=subject,
heading=subject,
intro_html=html.escape(intro),
@@ -738,28 +749,40 @@ def _support_email(
ticket_url: Optional[str],
cta_label: str,
) -> EmailContent:
i18n = _resolve_i18n(i18n)
lang = _normalize_lang(language, settings)
brand = _brand_title(settings)
accent = _safe_color(settings.WEBAPP_PRIMARY_COLOR)
safe_url = (ticket_url or "").strip()
footer = _t_html(_resolve_i18n(i18n), lang, "email_footer_auto", brand=brand)
footer = _t_html(i18n, lang, "email_footer_auto", brand=brand)
localized_rows = [
(_t_text(i18n, lang, label) if str(label).startswith("email_") else str(label), value)
for label, value in rows
]
preview_block = (
f'<div style="margin:0 0 16px 0;background:{_BG};border:1px solid {_BORDER};'
f"border-radius:14px;padding:14px 16px;font-size:14px;line-height:1.55;color:{_TEXT};"
f'white-space:pre-wrap;">{html.escape(body_preview or "")}</div>'
)
body_parts = [_info_rows_html(rows), preview_block]
body_parts = [_info_rows_html(localized_rows), preview_block]
if safe_url:
body_parts.append(_cta_button_html(label=cta_label, url=safe_url, accent=accent))
rendered = _layout(
settings=settings,
language_code=lang,
preheader=intro,
heading=heading,
intro_html=html.escape(intro),
body_html="".join(body_parts),
footer_html=footer,
)
text_lines = [intro, "", *[f"{label}: {value}" for label, value in rows], "", body_preview]
text_lines = [
intro,
"",
*[f"{label}: {value}" for label, value in localized_rows],
"",
body_preview,
]
if safe_url:
text_lines.extend(["", safe_url])
return EmailContent(subject=subject, text="\n".join(text_lines), html=rendered)
@@ -777,23 +800,25 @@ def render_support_new_ticket_admin(
snapshot_rows: Sequence[Tuple[str, str]],
ticket_url: Optional[str],
) -> EmailContent:
i18n = _resolve_i18n(i18n)
lang = _normalize_lang(language, settings)
rows = [
("Ticket", f"#{ticket_id}"),
("User", user_display),
("Subject", subject),
("email_support_row_ticket", f"#{ticket_id}"),
("email_support_row_user", user_display),
("email_support_row_subject", subject),
*snapshot_rows,
]
return _support_email(
settings,
i18n,
language,
subject=f"New support ticket #{ticket_id}",
heading=f"New support ticket #{ticket_id}",
intro="A user opened a new support ticket.",
lang,
subject=_t_text(i18n, lang, "email_support_new_ticket_admin_subject", ticket_id=ticket_id),
heading=_t_text(i18n, lang, "email_support_new_ticket_admin_heading", ticket_id=ticket_id),
intro=_t_text(i18n, lang, "email_support_new_ticket_admin_intro"),
rows=rows,
body_preview=body_preview,
ticket_url=ticket_url,
cta_label="Open ticket",
cta_label=_t_text(i18n, lang, "email_support_cta_open_ticket"),
)
@@ -809,23 +834,25 @@ def render_support_user_reply_admin(
snapshot_rows: Sequence[Tuple[str, str]],
ticket_url: Optional[str],
) -> EmailContent:
i18n = _resolve_i18n(i18n)
lang = _normalize_lang(language, settings)
rows = [
("Ticket", f"#{ticket_id}"),
("User", user_display),
("Subject", subject),
("email_support_row_ticket", f"#{ticket_id}"),
("email_support_row_user", user_display),
("email_support_row_subject", subject),
*snapshot_rows,
]
return _support_email(
settings,
i18n,
language,
subject=f"New user reply in ticket #{ticket_id}",
heading=f"User replied in ticket #{ticket_id}",
intro="A user sent a new support message.",
lang,
subject=_t_text(i18n, lang, "email_support_user_reply_admin_subject", ticket_id=ticket_id),
heading=_t_text(i18n, lang, "email_support_user_reply_admin_heading", ticket_id=ticket_id),
intro=_t_text(i18n, lang, "email_support_user_reply_admin_intro"),
rows=rows,
body_preview=body_preview,
ticket_url=ticket_url,
cta_label="Open ticket",
cta_label=_t_text(i18n, lang, "email_support_cta_open_ticket"),
)
@@ -839,17 +866,22 @@ def render_support_admin_reply_user(
body_preview: str,
ticket_url: Optional[str],
) -> EmailContent:
i18n = _resolve_i18n(i18n)
lang = _normalize_lang(language, settings)
return _support_email(
settings,
i18n,
language,
subject=f"New reply for ticket #{ticket_id}",
heading=f"New reply for ticket #{ticket_id}",
intro="Support has replied to your ticket.",
rows=[("Ticket", f"#{ticket_id}"), ("Subject", subject)],
lang,
subject=_t_text(i18n, lang, "email_support_admin_reply_user_subject", ticket_id=ticket_id),
heading=_t_text(i18n, lang, "email_support_admin_reply_user_heading", ticket_id=ticket_id),
intro=_t_text(i18n, lang, "email_support_admin_reply_user_intro"),
rows=[
("email_support_row_ticket", f"#{ticket_id}"),
("email_support_row_subject", subject),
],
body_preview=body_preview,
ticket_url=ticket_url,
cta_label="Open in Mini App",
cta_label=_t_text(i18n, lang, "email_support_cta_open_mini_app"),
)
@@ -863,15 +895,24 @@ def render_support_ticket_closed_user(
body_preview: str = "",
ticket_url: Optional[str],
) -> EmailContent:
i18n = _resolve_i18n(i18n)
lang = _normalize_lang(language, settings)
return _support_email(
settings,
i18n,
language,
subject=f"Ticket #{ticket_id} was closed",
heading=f"Ticket #{ticket_id} was closed",
intro="Your support ticket has been closed.",
rows=[("Ticket", f"#{ticket_id}"), ("Subject", subject)],
body_preview=body_preview or "The ticket is closed.",
lang,
subject=_t_text(
i18n, lang, "email_support_ticket_closed_user_subject", ticket_id=ticket_id
),
heading=_t_text(
i18n, lang, "email_support_ticket_closed_user_heading", ticket_id=ticket_id
),
intro=_t_text(i18n, lang, "email_support_ticket_closed_user_intro"),
rows=[
("email_support_row_ticket", f"#{ticket_id}"),
("email_support_row_subject", subject),
],
body_preview=body_preview or _t_text(i18n, lang, "email_support_ticket_closed_user_body"),
ticket_url=ticket_url,
cta_label="Open in Mini App",
cta_label=_t_text(i18n, lang, "email_support_cta_open_mini_app"),
)
+57
View File
@@ -0,0 +1,57 @@
import logging
from datetime import datetime, timezone
from typing import Optional
from sqlalchemy.ext.asyncio import AsyncSession
from db.dal import message_log_dal
logger = logging.getLogger(__name__)
def _clean_piece(value: Optional[object]) -> str:
return str(value or "").strip()
async def log_user_message_delivery(
session: AsyncSession,
*,
target_user_id: Optional[int],
event_type: str,
channel: str,
content: str,
recipient: Optional[str] = None,
timestamp: Optional[datetime] = None,
) -> None:
"""Add a best-effort user log entry for important outbound messages."""
clean_event = _clean_piece(event_type)
clean_channel = _clean_piece(channel)
if not clean_event or not clean_channel:
return
parts = [f"channel={clean_channel}"]
clean_recipient = _clean_piece(recipient)
if clean_recipient:
parts.append(f"recipient={clean_recipient}")
clean_content = _clean_piece(content)
if clean_content:
parts.append(clean_content)
try:
await message_log_dal.create_message_log_no_commit(
session,
{
"user_id": None,
"event_type": clean_event,
"content": " | ".join(parts)[:4000],
"is_admin_event": False,
"target_user_id": int(target_user_id) if target_user_id is not None else None,
"timestamp": timestamp or datetime.now(timezone.utc),
},
)
except Exception:
logger.exception(
"Failed to add outbound message audit log for user %s event %s",
target_user_id,
clean_event,
)
+4 -4
View File
@@ -273,10 +273,10 @@ class NotificationService:
return []
rows = []
for key, label in (
("tariff", "Tariff"),
("end_date", "End date"),
("remaining", "Remaining"),
("panel_status", "Panel status"),
("tariff", "email_support_row_tariff"),
("end_date", "email_support_row_end_date"),
("remaining", "email_support_row_remaining"),
("panel_status", "email_support_row_panel_status"),
):
value = snapshot.get(key)
if value:
@@ -0,0 +1,622 @@
import json
import logging
import re
import uuid
from dataclasses import dataclass, field
from datetime import datetime
from typing import Any, Dict, List, Optional
from config.settings import Settings
from .panel_api_service import PanelApiService
logger = logging.getLogger(__name__)
_USER_ACTION_RE = re.compile(
r"^/users/(?P<user_uuid>[^/]+)/actions/(?P<action>enable|disable|reset-traffic)$"
)
_INTERNAL_SQUAD_BULK_RE = re.compile(
r"^/internal-squads/(?P<squad_uuid>[^/]+)/bulk-actions/"
r"(?P<action>add-users|remove-users)$"
)
_LIVE_POST_ENDPOINTS = frozenset({"/system/tools/happ/encrypt"})
_KNOWN_TRAFFIC_STRATEGIES = frozenset({"NO_RESET", "DAY", "WEEK", "MONTH"})
# Constant path templates for intercepted endpoints. The logged path is rebuilt
# from these literals (never from the raw endpoint) so user/squad UUIDs and any
# other id-like segment can never reach the log as clear text.
_USER_ACTION_TEMPLATES = {
"enable": "/users/<id>/actions/enable",
"disable": "/users/<id>/actions/disable",
"reset-traffic": "/users/<id>/actions/reset-traffic",
}
_SQUAD_BULK_TEMPLATES = {
"add-users": "/internal-squads/<id>/bulk-actions/add-users",
"remove-users": "/internal-squads/<id>/bulk-actions/remove-users",
}
# Exact intercepted endpoints that carry no id and are safe to log verbatim.
# Mapped to themselves so the logged value comes from this literal table, not
# from the (tainted) request endpoint.
_SAFE_LITERAL_ENDPOINTS = {
"/users": "/users",
"/hwid/devices/delete": "/hwid/devices/delete",
}
# Panel payloads can carry proxy credentials (e.g. trojanPassword, ssPassword,
# vless/vmess uuids) and PII (email, telegramId). Redact such values before they
# reach the dry-run log so secrets are never written in clear text.
_SENSITIVE_KEY_RE = re.compile(
r"pass|pwd|secret|token|key|credential|auth|cookie|session|"
r"email|mail|phone|telegram|mnemonic",
re.IGNORECASE,
)
# Field names the dry-run validator understands. Keys are echoed into the log
# only via this table (value == name), so the logged key is always a literal and
# never the raw, source-derived dict key. Unknown keys collapse to "<field>".
_FIELD_LABELS = {
name: name
for name in (
"uuid",
"username",
"status",
"expireAt",
"trafficLimitBytes",
"trafficLimitStrategy",
"hwidDeviceLimit",
"telegramId",
"email",
"description",
"tag",
"activeInternalSquads",
"activeUserInbounds",
"externalSquadUuid",
"userUuid",
"userUuids",
"users",
"hwid",
)
}
_REDACTED = "***"
_UNKNOWN_FIELD = "<field>"
_UNKNOWN_ENDPOINT = "<other>"
@dataclass
class _DryRunValidation:
errors: List[str] = field(default_factory=list)
@property
def ok(self) -> bool:
return not self.errors
def add(self, message: str) -> None:
self.errors.append(message)
class PanelDryRunApiService(PanelApiService):
"""Panel API client that reads live data but never mutates Remnawave users."""
def __init__(self, settings: Settings):
super().__init__(settings)
self._synthetic_users: Dict[str, Dict[str, Any]] = {}
async def _request(
self, method: str, endpoint: str, log_full_response: bool = False, **kwargs
) -> Optional[Dict[str, Any]]:
method_upper = method.upper()
normalized_endpoint = self._normalize_endpoint(endpoint)
if not self._should_intercept(method_upper, normalized_endpoint):
return await super()._request(
method_upper,
endpoint,
log_full_response=log_full_response,
**kwargs,
)
validation = await self._validate_dry_run_request(
method_upper,
normalized_endpoint,
kwargs.get("json"),
)
if not validation.ok:
self._log_dry_run(
"BLOCKED",
method_upper,
normalized_endpoint,
kwargs.get("json"),
errors=validation.errors,
)
return {
"error": True,
"status_code": 400,
"errorCode": "DRY_RUN_VALIDATION_FAILED",
"message": "Panel dry-run validation failed.",
"details": {"errors": validation.errors},
}
response = await self._dry_run_response(
method_upper,
normalized_endpoint,
kwargs.get("json"),
)
self._log_dry_run("OK", method_upper, normalized_endpoint, kwargs.get("json"))
return {"response": response, "dryRun": True}
@staticmethod
def _normalize_endpoint(endpoint: str) -> str:
return f"/{str(endpoint or '').lstrip('/')}"
@staticmethod
def _safe_endpoint(endpoint: str) -> str:
"""Map the request path to a constant log label.
Every return value comes from a literal template/table, never from the
(tainted) endpoint itself, so user/squad UUIDs and any other id-like
segment can never reach the log as clear text. The raw path is only
matched against, not echoed.
"""
raw = str(endpoint or "")
if match := _USER_ACTION_RE.match(raw):
return _USER_ACTION_TEMPLATES.get(match.group("action"), "/users/<id>/actions/<action>")
if match := _INTERNAL_SQUAD_BULK_RE.match(raw):
return _SQUAD_BULK_TEMPLATES.get(
match.group("action"), "/internal-squads/<id>/bulk-actions/<action>"
)
literal = _SAFE_LITERAL_ENDPOINTS.get(raw)
if literal is not None:
return literal
if raw.startswith("/users/"):
return "/users/<id>"
if raw.startswith("/internal-squads/"):
return "/internal-squads/<id>"
return _UNKNOWN_ENDPOINT
@staticmethod
def _summarize_leaf(value: Any) -> Any:
"""Reduce a scalar to a non-sensitive type token.
Leaf values can carry PII or proxy credentials, so the log never echoes
them only their JSON type. ``None`` is kept so absent fields stay
distinguishable from present ones.
"""
if value is None:
return None
if isinstance(value, bool):
return "<bool>"
if isinstance(value, int):
return "<int>"
if isinstance(value, float):
return "<float>"
if isinstance(value, str):
return "<str>"
return f"<{type(value).__name__}>"
@staticmethod
def _safe_key(key: Any) -> str:
"""Return a constant label for a payload key.
Known field names are echoed from the ``_FIELD_LABELS`` table (the value,
not the source-derived key); anything else collapses to ``<field>``. This
keeps the raw dict key out of the log entirely.
"""
if isinstance(key, str):
return _FIELD_LABELS.get(key, _UNKNOWN_FIELD)
return _UNKNOWN_FIELD
@classmethod
def _redact(cls, value: Any, _depth: int = 0) -> Any:
"""Recursively summarize values, keeping only the JSON shape.
Keys are replaced by constant labels, sensitive keys collapse to a
placeholder, and every scalar leaf becomes a type token. The result shows
which fields a mutation would touch without logging any source-derived
string (key or value).
"""
if _depth > 6:
return "..."
if isinstance(value, dict):
return {
cls._safe_key(k): (
_REDACTED
if isinstance(k, str) and _SENSITIVE_KEY_RE.search(k)
else cls._redact(v, _depth + 1)
)
for k, v in value.items()
}
if isinstance(value, (list, tuple)):
return [cls._redact(item, _depth + 1) for item in value]
return cls._summarize_leaf(value)
@classmethod
def _payload_preview(cls, payload: Any) -> str:
redacted = cls._redact(payload)
try:
text = json.dumps(redacted, ensure_ascii=False, default=str, sort_keys=True)
except Exception:
text = str(redacted)
if len(text) > 1200:
return f"{text[:1200]}..."
return text
def _log_dry_run(
self,
status: str,
method: str,
endpoint: str,
payload: Any,
*,
errors: Optional[List[str]] = None,
) -> None:
logger.info(
"[PANEL DRY-RUN %s] would %s %s payload=%s%s",
status,
method,
self._safe_endpoint(endpoint),
self._payload_preview(payload),
f" errors={errors}" if errors else "",
)
@staticmethod
def _should_intercept(method: str, endpoint: str) -> bool:
if method in PanelApiService._SAFE_METHODS:
return False
if method == "POST" and endpoint in _LIVE_POST_ENDPOINTS:
return False
return True
async def _validate_dry_run_request(
self,
method: str,
endpoint: str,
payload: Any,
) -> _DryRunValidation:
validation = _DryRunValidation()
data = payload if isinstance(payload, dict) else {}
if payload is not None and not isinstance(payload, dict):
validation.add("JSON payload must be an object.")
return validation
if method == "POST" and endpoint == "/users":
await self._validate_create_user_payload(data, validation)
return validation
if method == "PATCH" and endpoint == "/users":
await self._validate_update_user_payload(data, validation)
return validation
if method == "POST" and (match := _USER_ACTION_RE.match(endpoint)):
user_uuid = match.group("user_uuid")
self._validate_non_empty_string(user_uuid, "user uuid", validation)
await self._validate_remote_user(user_uuid, validation)
return validation
if method == "DELETE" and endpoint.startswith("/users/"):
user_uuid = endpoint.removeprefix("/users/").strip()
self._validate_non_empty_string(user_uuid, "user uuid", validation)
await self._validate_remote_user(user_uuid, validation)
return validation
if method == "POST" and endpoint == "/hwid/devices/delete":
user_uuid = self._validate_non_empty_string(
data.get("userUuid"),
"userUuid",
validation,
)
self._validate_non_empty_string(data.get("hwid"), "hwid", validation)
await self._validate_remote_user(user_uuid, validation)
return validation
if match := _INTERNAL_SQUAD_BULK_RE.match(endpoint):
squad_uuid = match.group("squad_uuid")
self._validate_non_empty_string(squad_uuid, "squad uuid", validation)
user_uuids = self._validate_string_list(data.get("userUuids"), "userUuids", validation)
if not user_uuids:
user_uuids = self._validate_string_list(data.get("users"), "users", validation)
await self._validate_remote_squads([squad_uuid], validation)
for user_uuid in user_uuids:
await self._validate_remote_user(user_uuid, validation)
return validation
if payload is None:
return validation
self._validate_json_serializable(payload, validation)
return validation
async def _validate_create_user_payload(
self,
payload: Dict[str, Any],
validation: _DryRunValidation,
) -> None:
username = self._validate_non_empty_string(payload.get("username"), "username", validation)
if username and (
not (3 <= len(username) <= 36) or not re.match(r"^[A-Za-z0-9_-]+$", username)
):
validation.add("username must be 3-36 chars and contain only A-Z, 0-9, _ or -.")
self._validate_user_mutation_payload(payload, validation, require_uuid=False)
await self._validate_remote_squads(
self._validate_string_list(
payload.get("activeInternalSquads"),
"activeInternalSquads",
validation,
required=False,
),
validation,
)
if not bool(getattr(self.settings, "PANEL_DRY_RUN_SYNTHETIC_CREATE", True)):
validation.add("PANEL_DRY_RUN_SYNTHETIC_CREATE is disabled.")
if self._remote_validation_enabled and username:
await self._validate_create_uniqueness(payload, validation)
async def _validate_update_user_payload(
self,
payload: Dict[str, Any],
validation: _DryRunValidation,
) -> None:
user_uuid = self._validate_non_empty_string(payload.get("uuid"), "uuid", validation)
self._validate_user_mutation_payload(payload, validation, require_uuid=True)
await self._validate_remote_user(user_uuid, validation)
await self._validate_remote_squads(
self._validate_string_list(
payload.get("activeInternalSquads"),
"activeInternalSquads",
validation,
required=False,
),
validation,
)
def _validate_user_mutation_payload(
self,
payload: Dict[str, Any],
validation: _DryRunValidation,
*,
require_uuid: bool,
) -> None:
if require_uuid:
self._validate_non_empty_string(payload.get("uuid"), "uuid", validation)
if "expireAt" in payload:
self._validate_datetime(payload.get("expireAt"), "expireAt", validation)
if "trafficLimitBytes" in payload:
self._validate_non_negative_int(
payload.get("trafficLimitBytes"),
"trafficLimitBytes",
validation,
)
if "trafficLimitStrategy" in payload:
strategy = self._validate_non_empty_string(
payload.get("trafficLimitStrategy"),
"trafficLimitStrategy",
validation,
)
if strategy and strategy.upper() not in _KNOWN_TRAFFIC_STRATEGIES:
validation.add(f"trafficLimitStrategy {strategy!r} is not supported.")
if "hwidDeviceLimit" in payload:
self._validate_non_negative_int(
payload.get("hwidDeviceLimit"),
"hwidDeviceLimit",
validation,
)
if "telegramId" in payload:
self._validate_positive_int(payload.get("telegramId"), "telegramId", validation)
if "email" in payload and payload.get("email") is not None:
self._validate_non_empty_string(payload.get("email"), "email", validation)
if "externalSquadUuid" in payload and payload.get("externalSquadUuid") is not None:
self._validate_non_empty_string(
payload.get("externalSquadUuid"),
"externalSquadUuid",
validation,
)
self._validate_json_serializable(payload, validation)
@property
def _remote_validation_enabled(self) -> bool:
return bool(getattr(self.settings, "PANEL_DRY_RUN_VALIDATE_REMOTE", True))
async def _validate_remote_user(
self,
user_uuid: Optional[str],
validation: _DryRunValidation,
) -> Optional[Dict[str, Any]]:
if not user_uuid or not self._remote_validation_enabled:
return self._synthetic_users.get(str(user_uuid or ""))
user = self._synthetic_users.get(str(user_uuid))
if user:
return user
try:
user = await super().get_user_by_uuid(str(user_uuid), log_response=False)
except Exception as exc:
validation.add(f"failed to validate panel user {user_uuid}: {type(exc).__name__}")
return None
if not user:
validation.add(f"panel user {user_uuid} was not found.")
return user
async def _validate_remote_squads(
self,
squad_uuids: List[str],
validation: _DryRunValidation,
) -> None:
if not squad_uuids or not self._remote_validation_enabled:
return
try:
squads = await super().get_internal_squads()
except Exception as exc:
validation.add(f"failed to validate panel squads: {type(exc).__name__}")
return
if squads is None:
validation.add("failed to validate panel squads: empty panel response.")
return
known = {
str(squad.get("uuid") or squad.get("id") or "").strip()
for squad in squads
if isinstance(squad, dict)
}
missing = sorted({squad_uuid for squad_uuid in squad_uuids if squad_uuid not in known})
if missing:
validation.add(f"panel squads were not found: {', '.join(missing)}.")
async def _validate_create_uniqueness(
self,
payload: Dict[str, Any],
validation: _DryRunValidation,
) -> None:
checks = (
("username", "username", payload.get("username")),
("telegramId", "telegram_id", payload.get("telegramId")),
("email", "email", payload.get("email")),
)
for label, argument_name, value in checks:
if value in (None, ""):
continue
try:
users = await super().get_users_by_filter(**{argument_name: value})
except Exception as exc:
validation.add(f"failed to validate unique {label}: {type(exc).__name__}")
continue
if users:
validation.add(f"panel user with {label} {value!r} already exists.")
@staticmethod
def _validate_non_empty_string(
value: Any,
name: str,
validation: _DryRunValidation,
) -> Optional[str]:
if not isinstance(value, str) or not value.strip():
validation.add(f"{name} must be a non-empty string.")
return None
return value.strip()
@staticmethod
def _validate_string_list(
value: Any,
name: str,
validation: _DryRunValidation,
*,
required: bool = True,
) -> List[str]:
if value is None:
if required:
validation.add(f"{name} must be a list of strings.")
return []
if not isinstance(value, list):
validation.add(f"{name} must be a list of strings.")
return []
result = []
for item in value:
if not isinstance(item, str) or not item.strip():
validation.add(f"{name} contains an empty or non-string value.")
continue
result.append(item.strip())
return result
@staticmethod
def _validate_non_negative_int(
value: Any,
name: str,
validation: _DryRunValidation,
) -> None:
try:
parsed = int(value)
except (TypeError, ValueError):
validation.add(f"{name} must be an integer.")
return
if parsed < 0:
validation.add(f"{name} must be >= 0.")
@staticmethod
def _validate_positive_int(value: Any, name: str, validation: _DryRunValidation) -> None:
try:
parsed = int(value)
except (TypeError, ValueError):
validation.add(f"{name} must be an integer.")
return
if parsed <= 0:
validation.add(f"{name} must be > 0.")
@staticmethod
def _validate_datetime(value: Any, name: str, validation: _DryRunValidation) -> None:
if not isinstance(value, str) or not value.strip():
validation.add(f"{name} must be an ISO datetime string.")
return
try:
datetime.fromisoformat(value.replace("Z", "+00:00"))
except ValueError:
validation.add(f"{name} must be a valid ISO datetime string.")
@staticmethod
def _validate_json_serializable(value: Any, validation: _DryRunValidation) -> None:
try:
json.dumps(value, default=str)
except (TypeError, ValueError):
validation.add("payload must be JSON serializable.")
async def _dry_run_response(
self,
method: str,
endpoint: str,
payload: Any,
) -> Dict[str, Any]:
data = payload if isinstance(payload, dict) else {}
if method == "POST" and endpoint == "/users":
return self._dry_run_create_user_response(data)
if method == "PATCH" and endpoint == "/users":
return await self._dry_run_patch_user_response(data)
if method == "POST" and (match := _USER_ACTION_RE.match(endpoint)):
return self._dry_run_user_action_response(
match.group("user_uuid"),
match.group("action"),
)
if method == "DELETE" and endpoint.startswith("/users/"):
return {"uuid": endpoint.removeprefix("/users/"), "deleted": True, "dryRun": True}
if method == "POST" and endpoint == "/hwid/devices/delete":
return {"userUuid": data.get("userUuid"), "hwid": data.get("hwid"), "dryRun": True}
if match := _INTERNAL_SQUAD_BULK_RE.match(endpoint):
return {
"squadUuid": match.group("squad_uuid"),
"action": match.group("action"),
"users": data.get("userUuids") or data.get("users") or [],
"dryRun": True,
}
return {"dryRun": True}
def _dry_run_create_user_response(self, payload: Dict[str, Any]) -> Dict[str, Any]:
identity = ":".join(
str(payload.get(key) or "") for key in ("username", "telegramId", "email")
)
user_uuid = str(uuid.uuid5(uuid.NAMESPACE_URL, f"remnawave-minishop:dry-run:{identity}"))
short_uuid = user_uuid.split("-")[0]
response = {
**payload,
"uuid": user_uuid,
"shortUuid": short_uuid,
"subscriptionUuid": short_uuid,
"subscriptionUrl": self._subscription_url(short_uuid),
"dryRun": True,
}
self._synthetic_users[user_uuid] = response
return response
async def _dry_run_patch_user_response(self, payload: Dict[str, Any]) -> Dict[str, Any]:
user_uuid = str(payload.get("uuid") or "")
existing = self._synthetic_users.get(user_uuid)
if not existing and self._remote_validation_enabled:
try:
existing = await super().get_user_by_uuid(user_uuid, log_response=False)
except Exception:
existing = None
response = {**(existing or {"uuid": user_uuid}), **payload, "dryRun": True}
if user_uuid in self._synthetic_users:
self._synthetic_users[user_uuid] = response
return response
@staticmethod
def _dry_run_user_action_response(user_uuid: str, action: str) -> Dict[str, Any]:
response: Dict[str, Any] = {"uuid": user_uuid, "action": action, "dryRun": True}
if action == "enable":
response["status"] = "ACTIVE"
elif action == "disable":
response["status"] = "DISABLED"
elif action == "reset-traffic":
response["userTraffic"] = {"usedTrafficBytes": 0}
return response
def _subscription_url(self, short_uuid: str) -> Optional[str]:
if not self.settings.PANEL_API_URL:
return None
return f"{self.settings.PANEL_API_URL.rstrip('/')}/sub/{short_uuid}"
@@ -13,6 +13,7 @@ from bot.keyboards.inline.user_keyboards import get_subscribe_only_markup
from bot.middlewares.i18n import JsonI18n
from bot.services.email_auth_service import EmailAuthService
from bot.services.email_templates import render_subscription_lifecycle_notification
from bot.services.message_audit import log_user_message_delivery
from bot.services.telegram_notifications import (
TELEGRAM_NOTIFICATIONS_BLOCKED,
TELEGRAM_NOTIFICATIONS_ENABLED,
@@ -188,6 +189,18 @@ class SubscriptionLifecycleNotificationService:
self._channel_key(stage.key, "telegram"),
sent_at=sent_at,
)
await log_user_message_delivery(
session,
target_user_id=getattr(sub, "user_id", None),
event_type="telegram_subscription_notification_sent",
channel="telegram",
recipient=str(chat_id),
content=(
f"stage={stage.key} message_key={stage.message_key} "
f"subscription_id={getattr(sub, 'subscription_id', '')}"
),
timestamp=sent_at,
)
if user:
status = normalize_telegram_notification_status(
getattr(user, "telegram_notifications_status", None)
@@ -253,6 +266,18 @@ class SubscriptionLifecycleNotificationService:
self._channel_key(stage.key, "email"),
sent_at=sent_at,
)
await log_user_message_delivery(
session,
target_user_id=getattr(sub, "user_id", None),
event_type="email_subscription_notification_sent",
channel="email",
recipient=recipient,
content=(
f"stage={stage.key} message_key={stage.message_key} "
f"subscription_id={getattr(sub, 'subscription_id', '')}"
),
timestamp=sent_at,
)
return True
async def _already_sent(
@@ -13,6 +13,7 @@ from sqlalchemy.orm import selectinload, sessionmaker
from bot.infra.redis import redis_lock
from bot.keyboards.inline.user_keyboards import get_subscribe_only_markup
from bot.middlewares.i18n import JsonI18n
from bot.services.message_audit import log_user_message_delivery
from bot.services.panel_api_service import PanelApiService
from bot.services.subscription_lifecycle_notifications import (
SubscriptionLifecycleNotificationService,
@@ -331,6 +332,17 @@ class SubscriptionNotificationWorker:
parse_mode="HTML",
)
telegram_sent = True
await log_user_message_delivery(
session,
target_user_id=user_id,
event_type="telegram_traffic_warning_sent",
channel="telegram",
recipient=str(telegram_chat_id),
content=(
"kind=trial warning_key=trial_traffic_depleted "
f"used_bytes={used} limit_bytes={limit}"
),
)
except Exception as exc:
status = telegram_notification_status_from_error(exc)
if status and user and user_id:
@@ -355,6 +367,12 @@ class SubscriptionNotificationWorker:
subject_key="email_trial_traffic_depleted_subject",
message_text=message_text,
dashboard_url=(getattr(self.settings, "SUBSCRIPTION_MINI_APP_URL", "") or None),
session=session,
audit_event_type="email_traffic_warning_sent",
audit_content=(
"kind=trial warning_key=trial_traffic_depleted "
f"used_bytes={used} limit_bytes={limit}"
),
)
return {"telegram": telegram_sent, "email": email_sent}
@@ -11,7 +11,11 @@ from bot.middlewares.i18n import JsonI18n
from bot.utils.config_link import prepare_config_links
from bot.utils.date_utils import add_months, month_start
from config.settings import Settings
from config.tariffs_config import Tariff
from config.tariffs_config import (
Tariff,
default_currency_key_for_settings,
default_payment_currency_code_for_settings,
)
from db.dal import (
payment_dal,
promo_code_dal,
@@ -70,7 +70,7 @@ class HwidDeviceMixin:
package_set = tariff.hwid_device_packages
if not package_set:
return None
packages = package_set.for_currency("stars" if currency == "stars" else "rub")
packages = package_set.for_currency(currency)
return next((pkg for pkg in packages if int(pkg.count) == int(device_count)), None)
def _quote_hwid_package_price(
@@ -173,7 +173,7 @@ class HwidDeviceMixin:
valid_from=valid_from,
valid_until=valid_until,
now=now,
currency="stars" if currency == "stars" else "rub",
currency=currency,
)
quote.update(
{
@@ -227,7 +227,11 @@ class HwidDeviceMixin:
)
return None
packages = (
[*tariff.hwid_device_packages.rub, *tariff.hwid_device_packages.stars]
[
package
for currency_packages in tariff.hwid_device_packages.root.values()
for package in currency_packages
]
if tariff.hwid_device_packages
else []
)
@@ -248,9 +248,9 @@ class SubscriptionLifecycleMixin:
traffic_used_bytes=used_sub,
)
update_data["period_start_at"] = None
update_data["effective_monthly_price_rub"] = (
target.period_price(1, "rub") or target.min_period_price_rub()
)
update_data["effective_monthly_price_rub"] = target.period_price(
1, default_currency_key_for_settings(self.settings)
) or target.min_period_price(default_currency_key_for_settings(self.settings))
if mode == "recalc_days" and options.get("recalc_days") is not None:
update_data["end_date"] = now + timedelta(days=int(options["recalc_days"]))
else:
@@ -62,6 +62,9 @@ class PaymentContextMixin:
async def has_had_any_subscription(self, session: AsyncSession, user_id: int) -> bool:
return await subscription_dal.has_any_subscription_for_user(session, user_id)
async def has_trial_blocking_subscription(self, session: AsyncSession, user_id: int) -> bool:
return await subscription_dal.has_trial_blocking_subscription_for_user(session, user_id)
async def has_active_subscription(self, session: AsyncSession, user_id: int) -> bool:
"""Return True if user currently has an active subscription (end_date in future)."""
try:
@@ -120,7 +123,7 @@ class PaymentContextMixin:
months=int(months or 0),
traffic_gb=traffic_gb,
amount=float(payment_amount or 0),
currency=self.settings.DEFAULT_CURRENCY_SYMBOL,
currency=default_payment_currency_code_for_settings(self.settings),
end_date_text=end_date_text,
dashboard_url=dashboard_url,
provider_label=provider_label,
@@ -41,7 +41,23 @@ class RenewalMixin:
return False
months = sub.duration_months or 1
amount = self.settings.subscription_options.get(months)
currency = default_payment_currency_code_for_settings(self.settings)
amount = None
tariffs_config = (
self._tariffs_config() if callable(getattr(self, "_tariffs_config", None)) else None
)
if tariffs_config and callable(getattr(self, "_resolve_tariff", None)):
try:
tariff = self._resolve_tariff(getattr(sub, "tariff_key", None))
except Exception:
tariff = None
if tariff and tariff.billing_model == "period":
amount = tariff.period_price(
months,
default_currency_key_for_settings(self.settings),
)
if amount is None:
amount = self.settings.subscription_options.get(months)
if not amount:
logging.error(f"Auto-renew price missing for {months} months")
return False
@@ -53,7 +69,7 @@ class RenewalMixin:
}
resp = await yk.create_payment(
amount=float(amount),
currency="RUB",
currency=currency,
description=f"Auto-renewal for {months} months",
metadata=metadata,
payment_method_id=default_pm.provider_payment_method_id,
@@ -112,15 +112,14 @@ class TariffMixin:
regular_unlimited_override: bool,
traffic_used_bytes: int,
) -> int:
"""Numeric cap sent to the panel; ``regular_unlimited_override`` uses a large practical ceiling.""" # noqa: E501
"""Numeric cap sent to the panel; Remnawave treats ``0`` as unlimited."""
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 0
return floor
async def premium_access_for_tariff(self, tariff: Optional[Tariff]) -> Dict[str, Any]:
@@ -331,11 +330,12 @@ class TariffMixin:
remaining_days = max(0, (sub.end_date - now).days) if sub.end_date else 0
effective = float(sub.effective_monthly_price_rub or 0)
current_model = current_tariff.billing_model if current_tariff else "period"
default_currency = default_currency_key_for_settings(self.settings)
if current_model == "period" and target_tariff.billing_model == "period":
target_monthly = (
target_tariff.period_price(1, "rub")
or target_tariff.min_period_price_rub()
target_tariff.period_price(1, default_currency)
or target_tariff.min_period_price(default_currency)
or effective
or 1
)
@@ -355,11 +355,14 @@ class TariffMixin:
"remaining_days": remaining_days,
"recalc_days": max(0, days_after),
"paid_diff_rub": paid_diff,
"paid_diff": paid_diff,
"target_monthly_rub": float(target_monthly),
"target_monthly_price": float(target_monthly),
"currency": default_currency,
}
if current_model == "period" and target_tariff.billing_model == "traffic":
rub_per_gb = target_tariff.rub_per_gb_for_conversion()
rub_per_gb = target_tariff.currency_per_gb_for_conversion(default_currency)
remaining_value = remaining_days * (effective / 30) if effective else 0
converted_gb = math.floor(remaining_value / rub_per_gb) if rub_per_gb else 0
return {
@@ -367,9 +370,15 @@ class TariffMixin:
"remaining_days": remaining_days,
"converted_gb": max(0, converted_gb),
"rub_per_gb": rub_per_gb,
"currency_per_gb": rub_per_gb,
"currency": default_currency,
}
return {"mode": "traffic_to_period", "remaining_days": remaining_days}
return {
"mode": "traffic_to_period",
"remaining_days": remaining_days,
"currency": default_currency,
}
@staticmethod
def _aware_utc(value: Optional[datetime]) -> Optional[datetime]:
@@ -434,6 +443,7 @@ class TariffMixin:
credit = await self._hwid_conversion_credit(session, sub, at=now)
value_rub = float(credit.get("value_rub") or 0)
options["converted_hwid_value_rub"] = round(value_rub, 2)
options["converted_hwid_value"] = round(value_rub, 2)
options["convertible_hwid_purchase_ids"] = list(credit.get("purchase_ids") or [])
options["nonconverted_hwid_devices"] = int(credit.get("skipped_devices") or 0)
if value_rub <= 0:
@@ -22,7 +22,7 @@ class TrialSubscriptionMixin:
"message_key": "user_not_found_for_trial",
}
if await self.has_had_any_subscription(session, user_id):
if await self.has_trial_blocking_subscription(session, user_id):
return {
"eligible": False,
"activated": False,
+55
View File
@@ -13,6 +13,7 @@ from sqlalchemy.orm import sessionmaker
from bot.infra.redis import redis_lock
from bot.middlewares.i18n import JsonI18n
from bot.services.message_audit import log_user_message_delivery
from bot.services.panel_api_service import PanelApiService
from bot.services.subscription_service import SubscriptionService
from bot.services.user_email_notifications import send_user_notification_email
@@ -111,6 +112,8 @@ class TariffTrafficWorker:
subject_key: str,
message_text: str,
kind: str,
warning_key: str,
audit_content: str,
) -> None:
try:
user = await user_dal.get_user_by_id(session, user_id)
@@ -131,6 +134,9 @@ class TariffTrafficWorker:
if kind == "premium"
else "email_traffic_warning_regular_cta"
),
session=session,
audit_event_type="email_traffic_warning_sent",
audit_content=f"{audit_content} subject_key={subject_key} warning_key={warning_key}",
)
async def run(self) -> None:
@@ -602,6 +608,15 @@ class TariffTrafficWorker:
**usage,
)
subject_key = "email_traffic_warning_regular_depleted_subject"
warning_key = (
"traffic_warning_regular_almost"
if level < 100
else "traffic_warning_regular_depleted"
)
audit_content = (
f"kind=regular warning_key={warning_key} level={level} "
f"used_bytes={used_val} limit_bytes={limit_val}"
)
if self.bot:
try:
markup = self._traffic_topup_markup(user_lang, "regular")
@@ -611,6 +626,14 @@ class TariffTrafficWorker:
reply_markup=markup,
parse_mode="HTML",
)
await log_user_message_delivery(
session,
target_user_id=sub.user_id,
event_type="telegram_traffic_warning_sent",
channel="telegram",
recipient=str(sub.user_id),
content=audit_content,
)
except Exception:
logging.exception("Failed to send traffic warning to user %s", sub.user_id)
await self._send_traffic_warning_email(
@@ -619,6 +642,8 @@ class TariffTrafficWorker:
subject_key=subject_key,
message_text=text,
kind="regular",
warning_key=warning_key,
audit_content=audit_content,
)
if ratio >= 1.0 and not sub.is_throttled:
logging.info(
@@ -1066,6 +1091,11 @@ class TariffTrafficWorker:
servers=servers,
**usage,
)
warning_key = "traffic_warning_premium_depleted"
audit_content = (
f"kind=premium warning_key={warning_key} "
f"used_bytes={used_val} limit_bytes={limit_val}"
)
if self.bot:
try:
markup = self._traffic_topup_markup(user_lang, "premium")
@@ -1075,6 +1105,14 @@ class TariffTrafficWorker:
reply_markup=markup,
parse_mode="HTML",
)
await log_user_message_delivery(
session,
target_user_id=sub.user_id,
event_type="telegram_traffic_warning_sent",
channel="telegram",
recipient=str(sub.user_id),
content=audit_content,
)
except Exception:
logging.exception(
"Failed to send premium traffic depleted warning to user %s", sub.user_id
@@ -1085,6 +1123,8 @@ class TariffTrafficWorker:
subject_key="email_traffic_warning_premium_depleted_subject",
message_text=text,
kind="premium",
warning_key=warning_key,
audit_content=audit_content,
)
return
@@ -1134,6 +1174,11 @@ class TariffTrafficWorker:
servers=servers,
**usage,
)
warning_key = "traffic_warning_premium_almost"
audit_content = (
f"kind=premium warning_key={warning_key} level={int(level)} "
f"used_bytes={used_val} limit_bytes={limit_val}"
)
if self.bot:
try:
markup = self._traffic_topup_markup(user_lang, "premium")
@@ -1143,6 +1188,14 @@ class TariffTrafficWorker:
reply_markup=markup,
parse_mode="HTML",
)
await log_user_message_delivery(
session,
target_user_id=sub.user_id,
event_type="telegram_traffic_warning_sent",
channel="telegram",
recipient=str(sub.user_id),
content=audit_content,
)
except Exception:
logging.exception(
"Failed to send premium traffic warning to user %s", sub.user_id
@@ -1153,6 +1206,8 @@ class TariffTrafficWorker:
subject_key="email_traffic_warning_premium_almost_subject",
message_text=text,
kind="premium",
warning_key=warning_key,
audit_content=audit_content,
)
async def _premium_node_uuids_for_tariff(self, tariff) -> list[str]:
+212
View File
@@ -0,0 +1,212 @@
"""Anonymous install telemetry beacon (self-hosted friendly, opt-out).
Once per ``TELEMETRY_INTERVAL_HOURS`` the worker sends a single obfuscation-free
but fully anonymous "heartbeat" to a PostHog ingestion endpoint so the project
maintainer can see how many installs are active and which versions/OSes are in
use. No personal data, bot tokens, domains or user identities are sent only
an opaque per-install UUID plus coarse environment facts.
Operators can opt out in three independent ways, any of which stops the beacon:
* ``TELEMETRY_ENABLED=false`` in ``.env``
* the *System Anonymous install analytics* toggle in the web admin (stored
as a DB override and re-read every tick, so no restart is required)
* leaving ``TELEMETRY_ENDPOINT`` / ``TELEMETRY_API_KEY`` empty in the image
Delivery is strictly fire-and-forget: every failure is swallowed so telemetry
can never delay, block or crash the worker.
"""
from __future__ import annotations
import asyncio
import logging
import platform
import uuid
from typing import Any, Dict, List
import aiohttp
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import sessionmaker
from bot.infra.redis import redis_lock
from bot.utils.app_version import resolve_app_version, resolve_app_version_tag
from config.settings import Settings
from db.dal import app_settings_dal, user_dal
logger = logging.getLogger(__name__)
INSTALLATION_ID_KEY = "TELEMETRY_INSTALLATION_ID"
TELEMETRY_ENABLED_KEY = "TELEMETRY_ENABLED"
HEARTBEAT_EVENT = "installation_heartbeat"
INITIAL_DELAY_SECONDS = 300
HTTP_TIMEOUT_SECONDS = 10
# Report the user count as a coarse range so individual installs stay anonymous
# and the property keeps a low cardinality for breakdowns.
_USER_BUCKETS = (
(0, "0"),
(10, "1-10"),
(50, "11-50"),
(200, "51-200"),
(1000, "201-1000"),
(5000, "1001-5000"),
)
def _bucket_users(count: int) -> str:
for upper, label in _USER_BUCKETS:
if count <= upper:
return label
return "5000+"
class TelemetryWorker:
def __init__(self, settings: Settings, session_factory: sessionmaker):
self.settings = settings
self.session_factory = session_factory
self._stopped = asyncio.Event()
def stop(self) -> None:
self._stopped.set()
def _delivery_configured(self) -> bool:
return bool(
str(self.settings.TELEMETRY_ENDPOINT or "").strip()
and str(self.settings.TELEMETRY_API_KEY or "").strip()
)
async def run(self) -> None:
if not self._delivery_configured():
logger.info("Telemetry endpoint/key not configured; anonymous beacon disabled")
return
logger.info(
"Anonymous install telemetry is ON (endpoint=%s, every %sh). "
"It sends an opaque install id, version, OS and a user-count range — "
"no personal data. Opt out via TELEMETRY_ENABLED=false or "
"Admin -> System -> Anonymous install analytics. "
"See docs/configuration/telemetry.md.",
self.settings.TELEMETRY_ENDPOINT,
self.settings.TELEMETRY_INTERVAL_HOURS,
)
await self._sleep(INITIAL_DELAY_SECONDS)
while not self._stopped.is_set():
try:
await self._beacon_tick()
except Exception:
logger.exception("Telemetry beacon tick failed")
await self._sleep(self._interval_seconds())
def _interval_seconds(self) -> int:
return max(1, int(self.settings.TELEMETRY_INTERVAL_HOURS or 24)) * 3600
async def _sleep(self, seconds: float) -> None:
try:
await asyncio.wait_for(self._stopped.wait(), timeout=seconds)
except asyncio.TimeoutError:
pass
async def _beacon_tick(self) -> None:
# A short-lived lock keeps a single beacon per interval even when the
# worker is scaled to several replicas. Without Redis the lock yields
# True, which is correct for the common single-worker deployment.
async with redis_lock(
self.settings,
"telemetry-beacon",
ttl_seconds=max(60, self._interval_seconds() // 2),
) as acquired:
if not acquired:
return
async with self.session_factory() as session:
if not await self._is_enabled(session):
return
installation_id = await self._get_or_create_installation_id(session)
payload = await self._build_payload(session, installation_id)
await session.commit()
await self._send(payload)
async def _is_enabled(self, session: AsyncSession) -> bool:
# The web admin writes the toggle as a DB override. The worker process
# does not apply overrides onto its in-memory Settings, so read it
# straight from the table; the env default applies when unset.
present, value = await app_settings_dal.get_override_value(session, TELEMETRY_ENABLED_KEY)
if present:
return bool(value)
return bool(self.settings.TELEMETRY_ENABLED)
async def _get_or_create_installation_id(self, session: AsyncSession) -> str:
present, value = await app_settings_dal.get_override_value(session, INSTALLATION_ID_KEY)
if present and value:
return str(value)
installation_id = str(uuid.uuid4())
await app_settings_dal.upsert_override(
session,
key=INSTALLATION_ID_KEY,
value=installation_id,
updated_by=None,
)
return installation_id
def _enabled_payment_providers(self) -> List[str]:
try:
from bot.payment_providers import iter_provider_specs
providers = [
str(spec.id)
for spec in iter_provider_specs()
if spec.is_effectively_enabled(self.settings)
]
return sorted(set(providers))
except Exception:
logger.debug("Telemetry: failed to enumerate payment providers", exc_info=True)
return []
async def _build_payload(self, session: AsyncSession, installation_id: str) -> Dict[str, Any]:
try:
user_count = await user_dal.count_all_users(session)
except Exception:
logger.debug("Telemetry: failed to count users", exc_info=True)
user_count = 0
version = resolve_app_version()
version_tag = resolve_app_version_tag()
# Person properties (``$set``) snapshot the latest state per install, so
# "version breakdown" in PostHog is a person-property breakdown.
person_props = {
"app_version": version,
"app_version_tag": version_tag,
"os": platform.system().lower() or "unknown",
"arch": platform.machine().lower() or "unknown",
"python_version": platform.python_version(),
"locale": str(self.settings.DEFAULT_LANGUAGE or ""),
"users_bucket": _bucket_users(int(user_count or 0)),
"webapp_enabled": bool(self.settings.WEBAPP_ENABLED),
"panel_configured": bool(str(self.settings.PANEL_API_URL or "").strip()),
"payment_providers": self._enabled_payment_providers(),
}
properties = {
**person_props,
"$lib": "remnawave-minishop",
"$lib_version": version,
"$set": person_props,
}
return {
"api_key": str(self.settings.TELEMETRY_API_KEY or "").strip(),
"event": HEARTBEAT_EVENT,
"distinct_id": installation_id,
"properties": properties,
}
async def _send(self, payload: Dict[str, Any]) -> None:
url = str(self.settings.TELEMETRY_ENDPOINT or "").strip().rstrip("/") + "/capture/"
timeout = aiohttp.ClientTimeout(total=HTTP_TIMEOUT_SECONDS)
try:
async with aiohttp.ClientSession(timeout=timeout) as http:
async with http.post(url, json=payload) as resp:
if resp.status >= 400:
body = (await resp.text())[:200]
logger.warning("Telemetry beacon rejected: HTTP %s %s", resp.status, body)
else:
logger.debug("Telemetry beacon delivered (HTTP %s)", resp.status)
except Exception:
# Never let telemetry surface as an error to operators.
logger.debug("Telemetry beacon delivery failed", exc_info=True)
@@ -1,9 +1,12 @@
import logging
from typing import Any, Optional
from sqlalchemy.ext.asyncio import AsyncSession
from bot.middlewares.i18n import JsonI18n
from bot.services.email_auth_service import EmailAuthService
from bot.services.email_templates import render_user_notification
from bot.services.message_audit import log_user_message_delivery
from config.settings import Settings
@@ -34,6 +37,9 @@ async def send_user_notification_email(
subject_kwargs: Optional[dict[str, Any]] = None,
heading_key: Optional[str] = None,
intro_key: Optional[str] = None,
session: Optional[AsyncSession] = None,
audit_event_type: Optional[str] = None,
audit_content: Optional[str] = None,
) -> bool:
if not getattr(settings, "email_auth_configured", False):
return False
@@ -78,6 +84,15 @@ async def send_user_notification_email(
email=recipient,
content=content,
)
if session is not None and audit_event_type:
await log_user_message_delivery(
session,
target_user_id=getattr(user, "user_id", None),
event_type=audit_event_type,
channel="email",
recipient=recipient,
content=audit_content or f"subject_key={subject_key}",
)
return True
except Exception:
logging.exception("Failed to send user notification email to %s.", recipient)
+126
View File
@@ -0,0 +1,126 @@
"""Single source of truth for the application version string.
Resolution order mirrors the Dockerfile build chain so the dev checkout and
the runtime container agree on the value:
REMNAWAVE_MINISHOP_VERSION env > .build-version file > live ``git describe``
> ``dev+unknown``
The same value powers the admin sidebar (web process) and the anonymous
telemetry beacon (worker process), so "active installs" and version
breakdowns line up across both.
"""
from __future__ import annotations
import os
import re
import subprocess
from pathlib import Path
from typing import Optional
# ``/app`` in the container (parent of ``/app/backend``); repo root in dev.
# Matches where the Dockerfile drops .build-version / .build-tag / .build-commit.
APP_ROOT = Path(__file__).resolve().parents[3]
_APP_VERSION_CACHE: Optional[str] = None
def _run_git_command(*args: str) -> str:
try:
result = subprocess.run(
["git", *args],
cwd=APP_ROOT,
check=True,
capture_output=True,
text=True,
timeout=1.5,
)
except (OSError, subprocess.SubprocessError):
return ""
return result.stdout.strip()
def _normalize_version_branch(raw_branch: str) -> str:
branch = str(raw_branch or "").strip()
for prefix in ("refs/heads/", "refs/remotes/origin/", "origin/"):
if branch.startswith(prefix):
branch = branch[len(prefix) :]
break
if branch in ("", "HEAD"):
return ""
return re.sub(r"[^A-Za-z0-9._-]+", "-", branch).strip("-")[:48]
def _resolve_version_branch() -> str:
for env_name in (
"REMNAWAVE_MINISHOP_BRANCH",
"GIT_BRANCH",
"BRANCH_NAME",
"GITHUB_REF_NAME",
"CI_COMMIT_REF_NAME",
):
branch = _normalize_version_branch(os.getenv(env_name, ""))
if branch:
return branch
return _normalize_version_branch(
_run_git_command("branch", "--show-current")
or _run_git_command("symbolic-ref", "--quiet", "--short", "HEAD")
)
def _format_app_version(tag: str, sha: str, branch: str) -> str:
branch_suffix = "" if not branch or branch == "main" else f"-{branch}"
if tag and sha:
return f"{tag}{branch_suffix}+g{sha}"
if sha:
return f"dev{branch_suffix}+g{sha}"
if tag:
return f"{tag}{branch_suffix}"
return f"dev{branch_suffix}+unknown"
def _read_build_file(name: str) -> str:
try:
return (APP_ROOT / name).read_text(encoding="utf-8").strip()
except OSError:
return ""
def resolve_app_version() -> str:
"""Full version string (cached), e.g. ``v3.4.6+gabc1234``."""
global _APP_VERSION_CACHE
if _APP_VERSION_CACHE:
return _APP_VERSION_CACHE
env_version = os.getenv("REMNAWAVE_MINISHOP_VERSION", "").strip()
if env_version:
_APP_VERSION_CACHE = env_version
return env_version
build_version = _read_build_file(".build-version")
if build_version:
_APP_VERSION_CACHE = build_version
return build_version
tag = _run_git_command("describe", "--tags", "--abbrev=0")
sha = _run_git_command("rev-parse", "--short", "HEAD")
branch = _resolve_version_branch()
version = _format_app_version(tag, sha, branch)
_APP_VERSION_CACHE = version
return version
def resolve_app_version_tag() -> str:
"""Clean release tag for low-cardinality breakdowns, e.g. ``v3.4.6``.
Prefers the build-time ``.build-tag`` artifact, then a live ``git
describe``; falls back to the full version string when no tag is known.
"""
build_tag = _read_build_file(".build-tag")
if build_tag and build_tag != "unknown":
return build_tag
tag = _run_git_command("describe", "--tags", "--abbrev=0")
if tag:
return tag
return resolve_app_version()
+40
View File
@@ -0,0 +1,40 @@
from typing import Optional
def normalize_required_channel_id(value: object) -> Optional[int]:
if value is None:
return None
raw = str(value).strip()
if not raw:
return None
try:
channel_id = int(raw)
except (TypeError, ValueError):
return None
if channel_id == 0:
return None
if channel_id > 0:
return int(f"-100{channel_id}")
raw_abs = str(abs(channel_id))
if raw.startswith("-100"):
return channel_id
if abs(channel_id) < 1_000_000_000:
return channel_id
return -int(f"100{raw_abs}")
def is_required_channel_access_error(error: BaseException) -> bool:
message = str(error).lower()
configuration_markers = (
"chat not found",
"bot is not a member",
"not enough rights",
"have no rights",
"kicked",
)
return any(marker in message for marker in configuration_markers)
+94
View File
@@ -287,6 +287,30 @@ class Settings(BaseSettings):
description="Allow legacy referral links like ref_<telegram_id> to continue working. Defaults to True when unset.", # noqa: E501
)
APP_RUNTIME_MODE: str = Field(
default="production",
description="Runtime profile: production, development, staging or test.",
)
PANEL_WRITE_MODE: str = Field(
default="auto",
description=(
"Panel write behavior: auto uses dry-run in development/test runtimes, "
"live always writes to Remnawave, dry_run validates and logs mutations only."
),
)
PANEL_DRY_RUN_VALIDATE_REMOTE: bool = Field(
default=True,
description=(
"When panel dry-run is enabled, validate referenced users and squads "
"via live GET requests."
),
)
PANEL_DRY_RUN_SYNTHETIC_CREATE: bool = Field(
default=True,
description=(
"When panel dry-run is enabled, return synthetic users for create-user attempts."
),
)
PANEL_API_URL: Optional[str] = None
PANEL_API_KEY: Optional[str] = None
USER_TRAFFIC_LIMIT_GB: Optional[float] = Field(default=0.0)
@@ -560,6 +584,17 @@ class Settings(BaseSettings):
ids = self.ADMIN_IDS
return ids[0] if ids else None
@computed_field
@property
def panel_dry_run_enabled(self) -> bool:
mode = str(self.PANEL_WRITE_MODE or "auto").strip().lower().replace("-", "_")
if mode == "dry_run":
return True
if mode == "live":
return False
runtime = str(self.APP_RUNTIME_MODE or "production").strip().lower()
return runtime in {"dev", "development", "local", "test", "testing"}
@computed_field
@property
def trial_traffic_limit_bytes(self) -> int:
@@ -983,6 +1018,7 @@ class Settings(BaseSettings):
"LOG_SUPPORT_THREAD_ID",
"BACKUP_CHAT_ID",
"BACKUP_THREAD_ID",
"REQUIRED_CHANNEL_ID",
mode="before",
)
@classmethod
@@ -1025,6 +1061,28 @@ class Settings(BaseSettings):
return None
return v
@field_validator("APP_RUNTIME_MODE", mode="before")
@classmethod
def normalize_app_runtime_mode(cls, v):
value = str(v or "production").strip().lower().replace("-", "_")
if not value:
return "production"
aliases = {
"prod": "production",
"dev": "development",
"local_dev": "development",
"testing": "test",
}
return aliases.get(value, value)
@field_validator("PANEL_WRITE_MODE", mode="before")
@classmethod
def validate_panel_write_mode(cls, v):
value = str(v or "auto").strip().lower().replace("-", "_")
if value not in {"auto", "live", "dry_run"}:
raise ValueError("PANEL_WRITE_MODE must be one of: auto, live, dry_run")
return value
# Notification types
LOG_NEW_USERS: bool = Field(
default=True, description="Send notifications for new user registrations"
@@ -1043,6 +1101,37 @@ class Settings(BaseSettings):
)
LOG_SUPPORT: bool = Field(default=True, description="Send support ticket notifications")
# Anonymous install telemetry (self-hosted friendly, opt-out).
TELEMETRY_ENABLED: bool = Field(
default=True,
description=(
"Send an anonymous daily install heartbeat (version, OS, locale, "
"user-count range). No personal data. Opt out here, via the web "
"admin, or by clearing TELEMETRY_ENDPOINT/TELEMETRY_API_KEY."
),
)
TELEMETRY_ENDPOINT: str = Field(
default="https://eu.i.posthog.com",
description="PostHog ingestion host. Empty disables telemetry.",
)
TELEMETRY_API_KEY: str = Field(
default="phc_sRiAbbrjhyYPfsgBwSZyLvujDXBLaDpmWKt6paGmCCMm",
description=(
"PostHog project API key (phc_...). Safe to ship in the image: it is "
"a write-only ingest key. Empty disables telemetry."
),
)
TELEMETRY_INTERVAL_HOURS: int = Field(default=24)
@property
def telemetry_configured(self) -> bool:
"""True when telemetry is enabled and has a delivery target."""
return bool(
self.TELEMETRY_ENABLED
and str(self.TELEMETRY_ENDPOINT or "").strip()
and str(self.TELEMETRY_API_KEY or "").strip()
)
model_config = SettingsConfigDict(
env_file=".env", env_file_encoding="utf-8", extra="ignore", populate_by_name=True
)
@@ -1066,6 +1155,11 @@ def get_settings() -> Settings:
logging.warning(
"CRITICAL: PANEL_API_URL is not set. Panel integration will not work."
)
if _settings_instance.panel_dry_run_enabled:
logging.warning(
"PANEL_WRITE_MODE dry-run is enabled: Remnawave write requests will be "
"validated and logged without changing panel users."
)
if not os.getenv("WEBAPP_SESSION_SECRET"):
logging.warning(
"WEBAPP_SESSION_SECRET is not set. A generated secret will be used for this process only." # noqa: E501
+196 -31
View File
@@ -1,14 +1,53 @@
import json
import logging
from pathlib import Path
from typing import Dict, List, Literal, Optional
from typing import Any, Dict, List, Literal, Optional
from pydantic import BaseModel, Field, ValidationError, model_validator
from pydantic import BaseModel, Field, RootModel, ValidationError, model_validator
Currency = Literal["rub", "stars"]
DEFAULT_TARIFF_CURRENCY = "rub"
STARS_TARIFF_CURRENCY = "stars"
Currency = str
BillingModel = Literal["period", "traffic"]
def normalize_currency_key(value: Any, default: str = DEFAULT_TARIFF_CURRENCY) -> str:
text = str(value or "").strip().lower()
if not text:
return default
aliases = {
"rur": "rub",
"xtr": STARS_TARIFF_CURRENCY,
"star": STARS_TARIFF_CURRENCY,
"stars": STARS_TARIFF_CURRENCY,
}
normalized = aliases.get(text, text)
cleaned = "".join(ch for ch in normalized if ch.isalnum() or ch in {"_", "-"}).strip("_-")
return cleaned or default
def payment_currency_code(currency: Any, default: str = "RUB") -> str:
key = normalize_currency_key(currency, default=normalize_currency_key(default))
if key == STARS_TARIFF_CURRENCY:
return "XTR"
return key.upper()
def default_currency_key_for_settings(settings: Any) -> str:
try:
config = getattr(settings, "tariffs_config", None)
except Exception:
config = None
if config is not None and getattr(config, "default_currency", None):
return normalize_currency_key(config.default_currency)
return normalize_currency_key(getattr(settings, "DEFAULT_CURRENCY_SYMBOL", None))
def default_payment_currency_code_for_settings(settings: Any) -> str:
return payment_currency_code(default_currency_key_for_settings(settings))
class TrafficPackage(BaseModel):
gb: float
price: float
@@ -61,26 +100,76 @@ class HwidDevicePackage(BaseModel):
return float(self.price) * months_int
class PackageSet(BaseModel):
rub: List[TrafficPackage] = Field(default_factory=list)
stars: List[TrafficPackage] = Field(default_factory=list)
class PackageSet(RootModel[Dict[str, List[TrafficPackage]]]):
root: Dict[str, List[TrafficPackage]] = Field(default_factory=dict)
@model_validator(mode="before")
@classmethod
def normalize_input(cls, data: Any) -> Any:
if data is None:
return {}
if not isinstance(data, dict):
return data
normalized: Dict[str, Any] = {}
for currency, packages in data.items():
key = normalize_currency_key(currency, default="")
if not key:
raise ValueError("package currency must not be empty")
normalized[key] = packages or []
return normalized
def for_currency(self, currency: Currency) -> List[TrafficPackage]:
return list(getattr(self, currency) or [])
return list(self.root.get(normalize_currency_key(currency), []) or [])
@property
def rub(self) -> List[TrafficPackage]:
return self.for_currency("rub")
@property
def stars(self) -> List[TrafficPackage]:
return self.for_currency("stars")
@property
def non_stars_currencies(self) -> List[str]:
return [
currency for currency, packages in self.root.items() if currency != "stars" and packages
]
def has_any(self) -> bool:
return bool(self.rub or self.stars)
return any(bool(packages) for packages in self.root.values())
class HwidDevicePackageSet(BaseModel):
rub: List[HwidDevicePackage] = Field(default_factory=list)
stars: List[HwidDevicePackage] = Field(default_factory=list)
class HwidDevicePackageSet(RootModel[Dict[str, List[HwidDevicePackage]]]):
root: Dict[str, List[HwidDevicePackage]] = Field(default_factory=dict)
@model_validator(mode="before")
@classmethod
def normalize_input(cls, data: Any) -> Any:
if data is None:
return {}
if not isinstance(data, dict):
return data
normalized: Dict[str, Any] = {}
for currency, packages in data.items():
key = normalize_currency_key(currency, default="")
if not key:
raise ValueError("device package currency must not be empty")
normalized[key] = packages or []
return normalized
def for_currency(self, currency: Currency) -> List[HwidDevicePackage]:
return list(getattr(self, currency) or [])
return list(self.root.get(normalize_currency_key(currency), []) or [])
@property
def rub(self) -> List[HwidDevicePackage]:
return self.for_currency("rub")
@property
def stars(self) -> List[HwidDevicePackage]:
return self.for_currency("stars")
def has_any(self) -> bool:
return bool(self.rub or self.stars)
return any(bool(packages) for packages in self.root.values())
class Tariff(BaseModel):
@@ -93,6 +182,7 @@ class Tariff(BaseModel):
enabled: bool = True
monthly_gb: Optional[float] = None
prices: Dict[str, Dict[str, float]] = Field(default_factory=dict)
prices_rub: Dict[str, float] = Field(default_factory=dict)
prices_stars: Dict[str, float] = Field(default_factory=dict)
referral_bonus_days_inviter: Dict[str, int] = Field(default_factory=dict)
@@ -101,6 +191,7 @@ class Tariff(BaseModel):
topup_packages: Optional[PackageSet] = None
traffic_packages: Optional[PackageSet] = None
conversion_rate_per_gb: Optional[float] = None
conversion_rate_rub_per_gb: Optional[float] = None
hwid_device_limit: Optional[int] = None
hwid_device_packages: Optional[HwidDevicePackageSet] = None
@@ -128,6 +219,26 @@ class Tariff(BaseModel):
if self.premium_monthly_gb and self.premium_monthly_gb > 0 and not self.premium_squad_uuids:
raise ValueError(f"tariff {self.key}: premium_monthly_gb requires premium_squad_uuids")
self.prices = self._normalize_prices_by_currency(self.prices)
self.prices_rub = self._normalize_period_price_map(self.prices_rub, "prices_rub")
self.prices_stars = self._normalize_period_price_map(
self.prices_stars,
"prices_stars",
)
if self.prices_rub:
self.prices["rub"] = dict(self.prices_rub)
elif self.prices.get("rub"):
self.prices_rub = dict(self.prices["rub"])
if self.prices_stars:
self.prices["stars"] = dict(self.prices_stars)
elif self.prices.get("stars"):
self.prices_stars = dict(self.prices["stars"])
if self.conversion_rate_per_gb is None and self.conversion_rate_rub_per_gb is not None:
self.conversion_rate_per_gb = float(self.conversion_rate_rub_per_gb)
if self.conversion_rate_per_gb is not None and self.conversion_rate_per_gb <= 0:
raise ValueError(f"traffic tariff {self.key}: conversion_rate_per_gb must be > 0")
if self.billing_model == "period":
if self.monthly_gb is None or self.monthly_gb < 0:
raise ValueError(f"period tariff {self.key}: monthly_gb must be >= 0")
@@ -144,24 +255,54 @@ class Tariff(BaseModel):
for months in self.enabled_periods:
if months <= 0:
raise ValueError(f"period tariff {self.key}: enabled periods must be positive")
rub_price = self.prices_rub.get(str(months), 0) or 0
stars_price = self.prices_stars.get(str(months), 0) or 0
if rub_price <= 0 and stars_price <= 0:
period_prices = [
float(prices.get(str(months), 0) or 0) for prices in self.prices.values()
]
if not any(price > 0 for price in period_prices):
raise ValueError(
f"period tariff {self.key}: period {months} needs a non-zero rub or stars price" # noqa: E501
f"period tariff {self.key}: period {months} needs a non-zero price"
)
return self
if not self.traffic_packages or not self.traffic_packages.has_any():
raise ValueError(f"traffic tariff {self.key}: traffic_packages is required")
if self.conversion_rate_rub_per_gb is not None and self.conversion_rate_rub_per_gb <= 0:
raise ValueError(f"traffic tariff {self.key}: conversion_rate_rub_per_gb must be > 0")
if not self.traffic_packages.rub and self.conversion_rate_rub_per_gb is None:
if not self.traffic_packages.non_stars_currencies and self.conversion_rate_per_gb is None:
raise ValueError(
f"traffic tariff {self.key}: conversion_rate_rub_per_gb is required without RUB packages" # noqa: E501
f"traffic tariff {self.key}: conversion_rate_per_gb is required without fiat packages" # noqa: E501
)
return self
def _normalize_period_price_map(
self,
values: Dict[str, float],
field_name: str,
) -> Dict[str, float]:
normalized: Dict[str, float] = {}
for period, value in (values or {}).items():
try:
months = int(float(str(period).strip()))
price = float(value)
except (TypeError, ValueError) as exc:
raise ValueError(f"tariff {self.key}: {field_name} contains invalid entry") from exc
if months <= 0:
raise ValueError(f"tariff {self.key}: {field_name} periods must be positive")
if price < 0:
raise ValueError(f"tariff {self.key}: {field_name} prices must be >= 0")
normalized[str(months)] = price
return normalized
def _normalize_prices_by_currency(
self,
values: Dict[str, Dict[str, float]],
) -> Dict[str, Dict[str, float]]:
normalized: Dict[str, Dict[str, float]] = {}
for currency, price_map in (values or {}).items():
key = normalize_currency_key(currency, default="")
if not key:
raise ValueError(f"tariff {self.key}: price currency must not be empty")
normalized[key] = self._normalize_period_price_map(price_map or {}, f"prices.{key}")
return normalized
def _normalize_referral_bonus_map(
self, values: Dict[str, int], field_name: str
) -> Dict[str, int]:
@@ -196,7 +337,7 @@ class Tariff(BaseModel):
return int(float(self.monthly_gb) * (1024**3))
def period_price(self, months: int, currency: Currency = "rub") -> Optional[float]:
source = self.prices_rub if currency == "rub" else self.prices_stars
source = self.prices.get(normalize_currency_key(currency), {})
value = source.get(str(months))
return float(value) if value is not None else None
@@ -208,24 +349,40 @@ class Tariff(BaseModel):
value = self.referral_bonus_days_referee.get(str(int(months)))
return int(value) if value is not None else None
def min_period_price_rub(self) -> Optional[float]:
def min_period_price(self, currency: Currency = "rub") -> Optional[float]:
key = normalize_currency_key(currency)
source = self.prices.get(key, {})
prices = [
float(self.prices_rub[str(months)])
float(source[str(months)])
for months in self.enabled_periods
if self.prices_rub.get(str(months), 0) and self.prices_rub.get(str(months), 0) > 0
if source.get(str(months), 0) and source.get(str(months), 0) > 0
]
return min(prices) if prices else None
def min_traffic_package_rub(self) -> Optional[TrafficPackage]:
packages = self.traffic_packages.rub if self.traffic_packages else []
def min_period_price_rub(self) -> Optional[float]:
return self.min_period_price("rub")
def min_traffic_package(self, currency: Currency = "rub") -> Optional[TrafficPackage]:
packages = self.traffic_packages.for_currency(currency) if self.traffic_packages else []
return min(packages, key=lambda pkg: pkg.price) if packages else None
def rub_per_gb_for_conversion(self) -> float:
if self.conversion_rate_rub_per_gb:
return float(self.conversion_rate_rub_per_gb)
packages = self.traffic_packages.rub if self.traffic_packages else []
def min_traffic_package_rub(self) -> Optional[TrafficPackage]:
return self.min_traffic_package("rub")
def currency_per_gb_for_conversion(self, currency: Currency = "rub") -> float:
if self.conversion_rate_per_gb:
return float(self.conversion_rate_per_gb)
packages = self.traffic_packages.for_currency(currency) if self.traffic_packages else []
if not packages and self.traffic_packages:
for key in self.traffic_packages.non_stars_currencies:
packages = self.traffic_packages.for_currency(key)
if packages:
break
return min(float(pkg.price) / float(pkg.gb) for pkg in packages)
def rub_per_gb_for_conversion(self) -> float:
return self.currency_per_gb_for_conversion("rub")
def has_hwid_device_packages(self) -> bool:
return bool(self.hwid_device_packages and self.hwid_device_packages.has_any())
@@ -244,11 +401,15 @@ class Tariff(BaseModel):
class TariffsConfig(BaseModel):
default_tariff: str
default_currency: str = DEFAULT_TARIFF_CURRENCY
topup_packages_default: Optional[PackageSet] = None
tariffs: List[Tariff]
@model_validator(mode="after")
def validate_config(self) -> "TariffsConfig":
self.default_currency = normalize_currency_key(self.default_currency)
if self.default_currency == STARS_TARIFF_CURRENCY:
raise ValueError("default_currency must be a non-Stars payment currency")
keys = [tariff.key for tariff in self.tariffs]
if len(keys) != len(set(keys)):
raise ValueError("tariff keys must be unique")
@@ -277,6 +438,10 @@ class TariffsConfig(BaseModel):
def default(self) -> Tariff:
return self.require(self.default_tariff)
@property
def default_payment_currency_code(self) -> str:
return payment_currency_code(self.default_currency)
def topup_packages_for(self, tariff: Tariff) -> Optional[PackageSet]:
if tariff.billing_model == "traffic":
return tariff.traffic_packages
+25 -3
View File
@@ -4,12 +4,12 @@ import secrets
from datetime import datetime, timedelta, timezone
from typing import Any, Dict, List, Optional
from sqlalchemy import delete, func, or_, update
from sqlalchemy import and_, delete, func, or_, update
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.future import select
from sqlalchemy.orm import selectinload
from db.models import Subscription, SubscriptionNotification
from db.models import Subscription, SubscriptionNotification, User
INSTALL_SHARE_TOKEN_BYTES = 16
@@ -252,7 +252,7 @@ async def deactivate_all_user_subscriptions(session: AsyncSession, user_id: int)
async def delete_all_user_subscriptions(session: AsyncSession, user_id: int) -> int:
"""Completely delete all user subscriptions (for trial reset)"""
"""Completely delete all user subscriptions."""
stmt = delete(Subscription).where(Subscription.user_id == user_id)
result = await session.execute(stmt)
if result.rowcount > 0:
@@ -284,6 +284,28 @@ async def has_any_subscription_for_user(session: AsyncSession, user_id: int) ->
return result.scalar_one_or_none() is not None
async def has_trial_blocking_subscription_for_user(session: AsyncSession, user_id: int) -> bool:
now_utc = datetime.now(timezone.utc)
reset_at = (
select(User.trial_eligibility_reset_at).where(User.user_id == user_id).scalar_subquery()
)
subscription_anchor = func.coalesce(Subscription.start_date, Subscription.end_date)
stmt = (
select(Subscription.subscription_id)
.where(
Subscription.user_id == user_id,
or_(
reset_at.is_(None),
and_(Subscription.is_active == True, Subscription.end_date > now_utc),
subscription_anchor > reset_at,
),
)
.limit(1)
)
result = await session.execute(stmt)
return result.scalar_one_or_none() is not None
async def get_subscriptions_near_expiration(
session: AsyncSession, days_threshold: int
) -> List[Subscription]:
+109
View File
@@ -95,6 +95,39 @@ async def get_user_by_id(session: AsyncSession, user_id: int) -> Optional[User]:
return result.scalar_one_or_none()
async def get_referrer_for_user(session: AsyncSession, user: User) -> Optional[User]:
referred_by_id = getattr(user, "referred_by_id", None)
if referred_by_id is None:
return None
return await get_user_by_id(session, int(referred_by_id))
async def get_users_referred_by(
session: AsyncSession,
user_id: int,
*,
limit: int = 50,
offset: int = 0,
) -> List[User]:
safe_limit = max(1, min(500, int(limit or 50)))
safe_offset = max(0, int(offset or 0))
stmt = (
select(User)
.where(User.referred_by_id == user_id)
.order_by(User.registration_date.desc().nullslast(), User.user_id.desc())
.offset(safe_offset)
.limit(safe_limit)
)
result = await session.execute(stmt)
return result.scalars().all()
async def count_users_referred_by(session: AsyncSession, user_id: int) -> int:
stmt = select(func.count(User.user_id)).where(User.referred_by_id == user_id)
result = await session.execute(stmt)
return int(result.scalar_one() or 0)
async def get_user_by_username(session: AsyncSession, username: str) -> Optional[User]:
clean_username = username.lstrip("@").lower()
stmt = select(User).where(func.lower(User.username) == clean_username)
@@ -228,6 +261,20 @@ async def create_email_user(
)
async def mark_trial_eligibility_reset(
session: AsyncSession,
user_id: int,
*,
reset_at: Optional[datetime] = None,
) -> Optional[datetime]:
reset_at = reset_at or datetime.now(timezone.utc)
stmt = update(User).where(User.user_id == user_id).values(trial_eligibility_reset_at=reset_at)
result = await session.execute(stmt)
if result.rowcount <= 0:
return None
return reset_at
async def _has_active_panel_subscription(
session: AsyncSession, user_id: int, panel_user_uuid: str
) -> bool:
@@ -744,6 +791,7 @@ async def get_enhanced_user_statistics(session: AsyncSession) -> Dict[str, Any]:
free_subscription_users = int(subscription_counts[3] or 0)
inactive_users = total_users - active_subscription_users
expired_subscription_users = await count_users_with_expired_subscription(session)
return {
"total_users": total_users,
@@ -754,6 +802,7 @@ async def get_enhanced_user_statistics(session: AsyncSession) -> Dict[str, Any]:
"trial_users": trial_users,
"free_subscription_users": free_subscription_users,
"inactive_users": max(0, inactive_users),
"expired_subscription_users": expired_subscription_users,
"referral_users": referral_users,
}
@@ -808,6 +857,66 @@ async def get_user_ids_without_active_subscription(session: AsyncSession) -> Lis
return result.scalars().all()
def _expired_subscription_exists_for_user(now: datetime):
expired_subs = aliased(Subscription)
normalized_status = func.lower(func.coalesce(expired_subs.status_from_panel, ""))
blank_status = or_(
expired_subs.status_from_panel.is_(None),
expired_subs.status_from_panel == "",
)
expired_condition = or_(
normalized_status == "expired",
blank_status & expired_subs.is_active.is_(False),
expired_subs.end_date <= now,
)
return (
select(expired_subs.subscription_id)
.where(expired_subs.user_id == User.user_id, expired_condition)
.exists()
)
def _active_subscription_exists_for_user(now: datetime):
active_subs = aliased(Subscription)
return (
select(active_subs.subscription_id)
.where(
active_subs.user_id == User.user_id,
active_subs.is_active == True,
active_subs.end_date > now,
)
.exists()
)
async def count_users_with_expired_subscription(session: AsyncSession) -> int:
"""Count users who have an expired subscription and no currently active subscription."""
from datetime import datetime, timezone
now = datetime.now(timezone.utc)
stmt = select(func.count(User.user_id)).where(
_expired_subscription_exists_for_user(now),
~_active_subscription_exists_for_user(now),
)
result = await session.execute(stmt)
return int(result.scalar_one() or 0)
async def get_user_ids_with_expired_subscription(session: AsyncSession) -> List[int]:
"""Return non-banned user IDs with an expired subscription and no active one."""
from datetime import datetime, timezone
now = datetime.now(timezone.utc)
stmt = select(User.user_id).where(
User.is_banned == False,
_expired_subscription_exists_for_user(now),
~_active_subscription_exists_for_user(now),
)
result = await session.execute(stmt)
return result.scalars().all()
async def delete_user_and_relations(session: AsyncSession, user_id: int) -> bool:
"""Completely remove a user and all dependent records from the database.
+14
View File
@@ -1061,6 +1061,15 @@ def _migration_0032_add_telegram_notification_status(connection: Connection) ->
connection.execute(text(f"ALTER TABLE users ADD COLUMN {column} {ddl_type}"))
def _migration_0033_add_trial_eligibility_reset_marker(connection: Connection) -> None:
inspector = inspect(connection)
columns: Set[str] = {col["name"] for col in inspector.get_columns("users")}
if "trial_eligibility_reset_at" not in columns:
connection.execute(
text("ALTER TABLE users ADD COLUMN trial_eligibility_reset_at TIMESTAMPTZ")
)
MIGRATIONS: List[Migration] = [
Migration(
id="0001_add_channel_subscription_fields",
@@ -1233,6 +1242,11 @@ MIGRATIONS: List[Migration] = [
description="Track whether the bot can message Telegram-linked users",
upgrade=_migration_0032_add_telegram_notification_status,
),
Migration(
id="0033_add_trial_eligibility_reset_marker",
description="Track admin resets of per-user trial eligibility without deleting history",
upgrade=_migration_0033_add_trial_eligibility_reset_marker,
),
]
+1
View File
@@ -47,6 +47,7 @@ class User(Base):
referred_by_id = Column(BigInteger, ForeignKey("users.user_id"), nullable=True)
lifetime_used_traffic_bytes = Column(BigInteger, nullable=True)
lifetime_used_traffic_synced_at = Column(DateTime(timezone=True), nullable=True)
trial_eligibility_reset_at = Column(DateTime(timezone=True), nullable=True)
channel_subscription_verified = Column(Boolean, nullable=True)
channel_subscription_checked_at = Column(DateTime(timezone=True), nullable=True)
channel_subscription_verified_for = Column(BigInteger, nullable=True)
+3
View File
@@ -27,6 +27,7 @@ from bot.services.backup_worker import BackupWorker
from bot.services.locale_override_service import load_locale_overrides
from bot.services.subscription_notification_worker import SubscriptionNotificationWorker
from bot.services.tariff_worker import TariffTrafficWorker
from bot.services.telemetry_worker import TelemetryWorker
from bot.utils.message_queue import init_queue_manager
from config.settings import get_settings
@@ -209,6 +210,8 @@ async def main() -> None:
)
backup_worker = BackupWorker(settings, bot, session_factory=session_factory)
tasks.append(asyncio.create_task(backup_worker.run(), name="BackupWorker"))
telemetry_worker = TelemetryWorker(settings, session_factory)
tasks.append(asyncio.create_task(telemetry_worker.run(), name="TelemetryWorker"))
tasks.append(asyncio.create_task(_panel_sync_loop(settings, session_factory, i18n, services)))
for idx in range(max(1, settings.WEBHOOK_QUEUE_CONCURRENCY)):
tasks.append(
+1
View File
@@ -1,5 +1,6 @@
{
"default_tariff": "standard",
"default_currency": "rub",
"tariffs": [
{
"key": "standard",
+1
View File
@@ -71,6 +71,7 @@ export default defineConfig({
items: [
{ label: 'Переменные окружения', slug: 'configuration/env-vars' },
{ label: 'Безопасность', slug: 'configuration/security' },
{ label: 'Телеметрия', slug: 'configuration/telemetry' },
],
},
{
+30 -10
View File
@@ -11,6 +11,7 @@ const stateMocks = new Set([
"devices",
"notifications",
"auth",
"emails",
]);
const routeMocks = new Set([...stateMocks, "guides", "install"]);
const params = new URLSearchParams(window.location.search);
@@ -83,6 +84,7 @@ const routeFromParams = () => {
let initialRoute = routeFromParams();
const mockForRoute = (route) => {
const normalized = normalizePath(route);
if (normalized === "/emails") return "emails";
if (normalized === "/devices") return "devices";
if (normalized === "/login" || normalized.startsWith("/login/"))
return "auth";
@@ -122,7 +124,10 @@ const canonicalizeInitialPublicUrl = (route) => {
);
};
if (initialMock === "trial" && initialRoute === "/trial") {
if (initialMock === "emails") {
initialRoute = "/emails";
canonicalizeInitialPublicUrl(initialRoute);
} else if (initialMock === "trial" && initialRoute === "/trial") {
initialRoute = "/home";
const normalizedUrl = new URL(window.location.href);
normalizedUrl.pathname = publicPathFromRoute(initialRoute);
@@ -134,12 +139,14 @@ if (initialMock === "trial" && initialRoute === "/trial") {
} else {
canonicalizeInitialPublicUrl(initialRoute);
}
params.set("mock", initialMock);
params.delete("path");
params.delete("screen");
params.delete("admin_section");
params.set("path", initialRoute);
frame.src = `${runtimeBase}/app/?${params.toString()}${window.location.hash || ""}`;
if (initialMock !== "emails") {
params.set("mock", initialMock);
params.delete("path");
params.delete("screen");
params.delete("admin_section");
params.set("path", initialRoute);
frame.src = `${runtimeBase}/app/?${params.toString()}${window.location.hash || ""}`;
}
const routeFromRuntimeUrl = (url) => {
if (url.origin !== window.location.origin) return "";
@@ -154,6 +161,7 @@ const routeFromRuntimeUrl = (url) => {
return runtimePath;
};
const routeForStateMock = (mock) => {
if (mock === "emails") return "/emails";
if (mock === "devices") return "/devices";
if (mock === "auth") return "/login";
return "/home";
@@ -169,8 +177,18 @@ const topbar = document.querySelector(".demo-topbar");
const toggle = document.querySelector(".demo-topbar__toggle");
const hide = document.querySelector(".demo-topbar__hide");
const stateSelect = document.querySelector(".demo-topbar__state-select");
const emailPreviews = document.getElementById("email-previews");
const setDemoMode = (mock) => {
const emailMode = mock === "emails";
frame.hidden = emailMode;
if (emailPreviews) emailPreviews.hidden = !emailMode;
if (emailMode) document.body.setAttribute("data-demo-mode", "emails");
else document.body.removeAttribute("data-demo-mode");
};
const syncParentUrlFromFrame = () => {
if (frame.hidden) return;
try {
const frameUrl = new URL(frame.contentWindow.location.href);
const route = routeFromRuntimeUrl(frameUrl);
@@ -208,19 +226,21 @@ const setCollapsed = (collapsed) => {
toggle?.addEventListener("click", () => setCollapsed(false));
hide?.addEventListener("click", () => setCollapsed(true));
if (stateSelect) stateSelect.value = normalizeStateMock(params.get("mock"));
if (stateSelect) stateSelect.value = normalizeStateMock(params.get("mock") || initialMock);
setDemoMode(initialMock);
stateSelect?.addEventListener("change", () => {
const mock = normalizeStateMock(stateSelect.value);
const nextParams = new URLSearchParams(window.location.search);
nextParams.delete("path");
nextParams.delete("screen");
nextParams.delete("admin_section");
if (mock === defaultMock) nextParams.delete("mock");
if (mock === defaultMock || mock === "emails") nextParams.delete("mock");
else nextParams.set("mock", mock);
const query = nextParams.toString();
const stateRoute = routeForStateMock(mock);
const publicUrl = `${demoBase}${stateRoute}${query ? `?${query}` : ""}`;
window.history.replaceState(null, "", publicUrl);
frame.src = runtimeSrc(stateRoute, nextParams);
setDemoMode(mock);
if (mock !== "emails") frame.src = runtimeSrc(stateRoute, nextParams);
});
@@ -0,0 +1,439 @@
import json
import sys
from pathlib import Path
from types import SimpleNamespace
REPO_ROOT = Path(__file__).resolve().parents[2]
BACKEND_ROOT = REPO_ROOT / "backend"
sys.path.insert(0, str(BACKEND_ROOT))
if hasattr(sys.stdout, "reconfigure"):
sys.stdout.reconfigure(encoding="utf-8")
from bot.services.email_templates import ( # noqa: E402
render_account_merged,
render_login_code,
render_payment_success,
render_subscription_expiring,
render_subscription_lifecycle_notification,
render_support_admin_reply_user,
render_support_new_ticket_admin,
render_support_ticket_closed_user,
render_support_user_reply_admin,
render_user_notification,
)
LANGUAGE = "ru"
class PreviewI18n:
def __init__(self, path: Path, default: str = "ru"):
self.default_lang = default
self.locales_data = {}
for item in path.glob("*.json"):
try:
data = json.loads(item.read_text(encoding="utf-8"))
except json.JSONDecodeError:
continue
if isinstance(data, dict):
self.locales_data[item.stem] = {
str(key): str(value) for key, value in data.items() if isinstance(value, str)
}
def gettext(self, lang_code: str | None, key: str, **kwargs) -> str:
requested = str(lang_code or "").strip().lower().replace("_", "-")
requested_base = requested.split("-", 1)[0]
if requested in self.locales_data:
messages = self.locales_data[requested]
elif requested_base in self.locales_data:
messages = self.locales_data[requested_base]
else:
messages = self.locales_data.get(self.default_lang) or self.locales_data.get("en", {})
template = messages.get(key)
if template is None and self.default_lang in self.locales_data:
template = self.locales_data[self.default_lang].get(key)
if template is None:
template = key
try:
return template.format(**kwargs) if kwargs else template
except Exception:
return template
def settings():
return SimpleNamespace(
DEFAULT_LANGUAGE=LANGUAGE,
EMAIL_CODE_TTL_SECONDS=600,
WEBAPP_LOGO_URL="",
WEBAPP_LOGO_USE_EMOJI=False,
WEBAPP_PRIMARY_COLOR="#00fe7a",
WEBAPP_TITLE="remnawave-minishop",
)
I18N = PreviewI18n(REPO_ROOT / "locales", default=LANGUAGE)
SETTINGS = settings()
SAMPLE = {
"amount": 390,
"code": "483921",
"currency": "RUB",
"dashboard_url": "https://mini.example.com/app",
"end_date": "21.06.2026, 18:00",
"magic_url": "https://mini.example.com/app/auth/magic/preview",
"premium_traffic": 25,
"regular_traffic": 100,
"ticket_url": "https://mini.example.com/app/support/42",
}
def t(key: str, **kwargs) -> str:
return I18N.gettext(LANGUAGE, key, **kwargs)
def preview(item_id: str, category: str, title: str, content):
return {
"id": item_id,
"category": category,
"title": title,
"subject": content.subject,
"html": content.html,
}
def payment_preview(
item_id: str,
title: str,
sale_mode: str,
*,
months: int = 0,
traffic_gb: float | None = None,
):
return preview(
item_id,
"Платежи",
title,
render_payment_success(
SETTINGS,
language_code=LANGUAGE,
sale_mode=sale_mode,
months=months,
traffic_gb=traffic_gb,
amount=SAMPLE["amount"],
currency=SAMPLE["currency"],
end_date_text=SAMPLE["end_date"],
dashboard_url=SAMPLE["dashboard_url"],
provider_label="YooKassa",
i18n=I18N,
),
)
def user_notification_preview(
item_id: str,
title: str,
subject_key: str,
message_text: str,
*,
cta_label_key: str = "email_user_notification_cta",
):
subject = t(subject_key)
return preview(
item_id,
"Уведомления",
title,
render_user_notification(
SETTINGS,
language_code=LANGUAGE,
subject=subject,
heading=subject,
intro=t("email_user_notification_intro"),
message_text=message_text,
dashboard_url=SAMPLE["dashboard_url"],
cta_label=t(cta_label_key),
i18n=I18N,
),
)
def expiring_preview(item_id: str, title: str, days_left: int):
return preview(
item_id,
"Подписка",
title,
render_subscription_expiring(
SETTINGS,
language_code=LANGUAGE,
days_left=days_left,
end_date_text=SAMPLE["end_date"],
dashboard_url=SAMPLE["dashboard_url"],
i18n=I18N,
),
)
def lifecycle_preview(
item_id: str,
title: str,
notification_key: str,
message_text: str,
*,
mirrored_from_telegram: bool = False,
days_left: int | None = None,
hours_before: int | None = None,
):
return preview(
item_id,
"Подписка",
title,
render_subscription_lifecycle_notification(
SETTINGS,
language_code=LANGUAGE,
notification_key=notification_key,
message_text=message_text,
end_date_text=SAMPLE["end_date"],
dashboard_url=SAMPLE["dashboard_url"],
mirrored_from_telegram=mirrored_from_telegram,
days_left=days_left,
hours_before=hours_before,
i18n=I18N,
),
)
def support_snapshot_rows():
return [
("email_support_row_tariff", "Premium"),
("email_support_row_remaining", "3 д. 4 ч."),
]
EMAIL_PREVIEWS = [
preview(
"login-code",
"Доступ",
"Код для входа",
render_login_code(
SETTINGS,
code=SAMPLE["code"],
language_code=LANGUAGE,
magic_link=SAMPLE["magic_url"],
purpose="login",
i18n=I18N,
),
),
preview(
"set-password-code",
"Доступ",
"Код для создания пароля",
render_login_code(
SETTINGS,
code=SAMPLE["code"],
language_code=LANGUAGE,
purpose="set_password",
i18n=I18N,
),
),
preview(
"account-merged",
"Аккаунт",
"Аккаунты объединены",
render_account_merged(
SETTINGS,
language_code=LANGUAGE,
primary_user_id=100200300,
removed_user_id=-42,
final_end_date_text=SAMPLE["end_date"],
i18n=I18N,
),
),
payment_preview(
"payment-subscription",
"Оплата подписки",
"subscription",
months=1,
),
payment_preview(
"payment-traffic",
"Покупка трафика",
"traffic",
traffic_gb=SAMPLE["regular_traffic"],
),
payment_preview(
"payment-premium-traffic",
"Покупка premium-трафика",
"premium_topup",
traffic_gb=SAMPLE["premium_traffic"],
),
payment_preview("payment-hwid", "Покупка HWID-устройств", "hwid_device", months=2),
payment_preview("payment-tariff-upgrade", "Платное повышение тарифа", "tariff_upgrade"),
user_notification_preview(
"payment-failed",
"Неуспешная оплата",
"email_payment_failed_subject",
"Платеж не был завершен. Можно попробовать еще раз из личного кабинета.",
),
user_notification_preview(
"payment-method-bound",
"Способ оплаты привязан",
"email_payment_method_bound_subject",
"Автопродление подключено, следующий платеж пройдет автоматически.",
),
user_notification_preview(
"referral-bonus",
"Реферальный бонус",
"email_referral_bonus_subject",
"Друг активировал подписку, и бонусные дни уже добавлены к вашему аккаунту.",
),
user_notification_preview(
"trial-traffic-depleted",
"Трафик пробного периода закончился",
"email_trial_traffic_depleted_subject",
"Пробный трафик израсходован. Оформите подписку, чтобы продолжить пользоваться сервисом.",
),
user_notification_preview(
"regular-traffic-almost",
"Обычный трафик почти закончился",
"email_traffic_warning_regular_almost_subject",
"Использовано больше 85% трафика тарифа. Можно докупить пакет заранее.",
cta_label_key="email_traffic_warning_regular_cta",
),
user_notification_preview(
"regular-traffic-depleted",
"Обычный трафик закончился",
"email_traffic_warning_regular_depleted_subject",
"Трафик тарифа израсходован. Докупите пакет, чтобы восстановить доступ.",
cta_label_key="email_traffic_warning_regular_cta",
),
user_notification_preview(
"premium-traffic-almost",
"Premium-трафик почти закончился",
"email_traffic_warning_premium_almost_subject",
"Premium-трафика осталось мало. Можно докупить пакет до полного расхода.",
cta_label_key="email_traffic_warning_premium_cta",
),
user_notification_preview(
"premium-traffic-depleted",
"Premium-трафик закончился",
"email_traffic_warning_premium_depleted_subject",
(
"Premium-трафик израсходован. Докупите пакет, "
"чтобы продолжить использовать premium-маршруты."
),
cta_label_key="email_traffic_warning_premium_cta",
),
expiring_preview(
"subscription-expiring-today",
"Подписка заканчивается сегодня",
0,
),
expiring_preview(
"subscription-expiring-tomorrow",
"Подписка заканчивается завтра",
1,
),
expiring_preview(
"subscription-expiring-days",
"Подписка скоро закончится",
3,
),
lifecycle_preview(
"lifecycle-before-days",
"Lifecycle: осталось несколько дней",
"before_days",
"Подписка скоро закончится. Продлите ее заранее, чтобы доступ не прерывался.",
days_left=3,
),
lifecycle_preview(
"lifecycle-before-hours",
"Lifecycle: осталось несколько часов",
"before_hours",
"До окончания подписки осталось несколько часов.",
hours_before=6,
),
lifecycle_preview(
"lifecycle-expired",
"Lifecycle: подписка закончилась",
"expired",
"Подписка закончилась. Продлите доступ в личном кабинете.",
),
lifecycle_preview(
"lifecycle-expired-after",
"Lifecycle: подписка закончилась вчера",
"expired_24h_after",
"Вчера подписка была отключена. Вы можете восстановить доступ продлением.",
),
lifecycle_preview(
"lifecycle-autorenew",
"Lifecycle: автопродление завтра",
"before_2d_autorenew",
"Завтра будет выполнено автопродление подписки.",
),
lifecycle_preview(
"lifecycle-mirrored",
"Lifecycle: копия Telegram-уведомления",
"before_days",
"Это письмо дублирует важное уведомление, отправленное в Telegram.",
mirrored_from_telegram=True,
days_left=2,
),
preview(
"support-new-ticket-admin",
"Поддержка",
"Новый тикет для администратора",
render_support_new_ticket_admin(
SETTINGS,
I18N,
LANGUAGE,
ticket_id=42,
user_display="alex@example.com",
subject="Не работает подключение",
body_preview="Пользователь не может подключиться после продления.",
snapshot_rows=support_snapshot_rows(),
ticket_url="https://mini.example.com/app/admin/support/42",
),
),
preview(
"support-user-reply-admin",
"Поддержка",
"Ответ пользователя для администратора",
render_support_user_reply_admin(
SETTINGS,
I18N,
LANGUAGE,
ticket_id=42,
user_display="alex@example.com",
subject="Не работает подключение",
body_preview="Проблема повторилась на телефоне и ноутбуке.",
snapshot_rows=support_snapshot_rows(),
ticket_url="https://mini.example.com/app/admin/support/42",
),
),
preview(
"support-admin-reply-user",
"Поддержка",
"Ответ поддержки пользователю",
render_support_admin_reply_user(
SETTINGS,
I18N,
LANGUAGE,
ticket_id=42,
subject="Не работает подключение",
body_preview="Мы обновили конфигурацию. Попробуйте подключиться еще раз.",
ticket_url=SAMPLE["ticket_url"],
),
),
preview(
"support-ticket-closed-user",
"Поддержка",
"Тикет закрыт",
render_support_ticket_closed_user(
SETTINGS,
I18N,
LANGUAGE,
ticket_id=42,
subject="Не работает подключение",
ticket_url=SAMPLE["ticket_url"],
),
),
]
print(json.dumps(EMAIL_PREVIEWS, ensure_ascii=False))
+1
View File
@@ -31,6 +31,7 @@ export const demoPublicRouteAliases = ["app"];
export const demoPublicRoutes = [
...demoPublicRouteAliases,
...demoUserRoutes,
"emails",
"admin",
...demoAdminRoutes.map((route) => `admin/${route}`),
];
+55
View File
@@ -0,0 +1,55 @@
import { spawnSync } from "node:child_process";
import { existsSync } from "node:fs";
import { resolve } from "node:path";
const repoRootCandidates = [
resolve(process.cwd(), ".."),
resolve(process.cwd()),
];
const repoRoot =
repoRootCandidates.find(
(candidate) =>
existsSync(resolve(candidate, "backend")) &&
existsSync(resolve(candidate, "docs-site")),
) || repoRootCandidates[0];
const generatorPath = resolve(
repoRoot,
"docs-site",
"scripts",
"generate-email-previews.py",
);
const pythonCommands = [
process.env.PYTHON,
process.platform === "win32" ? "python" : "python3",
"python",
].filter(Boolean);
let lastError = "";
let generated = null;
for (const command of pythonCommands) {
const result = spawnSync(command, [generatorPath], {
cwd: repoRoot,
encoding: "utf8",
env: {
...process.env,
PYTHONIOENCODING: "utf-8",
},
});
if (result.status === 0 && result.stdout) {
generated = result.stdout;
break;
}
lastError = [result.error?.message, result.stderr, result.stdout]
.filter(Boolean)
.join("\n")
.trim();
}
if (!generated) {
throw new Error(
`Failed to generate email previews from backend templates.\n${lastError}`,
);
}
export const emailPreviews = JSON.parse(generated);
+226
View File
@@ -1,4 +1,6 @@
---
import { emailPreviews } from '../lib/emailPreviews.mjs';
const defaultDemoSrc = '/demo/runtime/app/?path=/home&mock=tariffs';
const docsHref = '/getting-started/demo/';
---
@@ -44,6 +46,10 @@ const docsHref = '/getting-started/demo/';
background: #05080f;
}
body[data-demo-mode='emails'] {
overflow: auto;
}
.demo-topbar {
display: flex;
gap: 1rem;
@@ -151,6 +157,152 @@ const docsHref = '/getting-started/demo/';
background: #05080f;
}
.demo-frame[hidden],
.email-previews[hidden] {
display: none;
}
.email-previews {
min-height: 0;
overflow: auto;
background:
linear-gradient(180deg, rgb(15 23 42 / 32%), rgb(5 8 15 / 0) 14rem),
#05080f;
}
.email-previews__inner {
width: min(72rem, calc(100% - 2rem));
margin: 0 auto;
padding: 2rem 0 4rem;
}
.email-previews__header {
display: grid;
gap: 0.45rem;
max-width: 44rem;
margin-bottom: 1.25rem;
}
.email-previews__eyebrow {
color: #00fe7a;
font-size: 0.78rem;
font-weight: 800;
line-height: 1;
text-transform: uppercase;
}
.email-previews__header h1 {
margin: 0;
color: #ffffff;
font-size: 1.65rem;
line-height: 1.15;
}
.email-previews__header p {
margin: 0;
color: #a7b1c2;
font-size: 0.95rem;
line-height: 1.55;
}
.email-previews__index {
display: flex;
flex-wrap: wrap;
gap: 0.45rem;
margin: 0 0 1.4rem;
}
.email-previews__index a {
border: 1px solid rgb(148 163 184 / 24%);
border-radius: 7px;
padding: 0.42rem 0.6rem;
background: rgb(15 23 42 / 62%);
color: #dbeafe;
font-size: 0.78rem;
font-weight: 650;
line-height: 1;
text-decoration: none;
}
.email-previews__index a:hover {
border-color: rgb(0 254 122 / 54%);
color: #00fe7a;
}
.email-preview {
border-top: 1px solid rgb(148 163 184 / 18%);
}
.email-preview:last-child {
border-bottom: 1px solid rgb(148 163 184 / 18%);
}
.email-preview summary {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 1rem;
align-items: center;
padding: 1rem 0;
cursor: pointer;
list-style: none;
}
.email-preview summary::-webkit-details-marker {
display: none;
}
.email-preview__title {
min-width: 0;
}
.email-preview__title strong {
display: block;
color: #f8fafc;
font-size: 0.98rem;
line-height: 1.3;
}
.email-preview__title span {
display: block;
margin-top: 0.22rem;
color: #94a3b8;
font-size: 0.78rem;
line-height: 1.35;
}
.email-preview__meta {
display: inline-flex;
align-items: center;
justify-content: center;
border: 1px solid rgb(14 165 233 / 34%);
border-radius: 999px;
padding: 0.28rem 0.55rem;
color: #bae6fd;
font-size: 0.72rem;
font-weight: 750;
line-height: 1;
white-space: nowrap;
}
.email-preview__body {
padding: 0.1rem 0 1.6rem;
}
.email-preview__frame-wrap {
overflow: hidden;
border: 1px solid rgb(148 163 184 / 20%);
border-radius: 8px;
background: #05070a;
}
.email-preview__frame {
display: block;
width: 100%;
height: 760px;
border: 0;
background: #05070a;
}
@media (max-width: 42rem) {
body {
display: block;
@@ -269,6 +421,28 @@ const docsHref = '/getting-started/demo/';
.demo-frame {
height: 100dvh;
}
.email-previews {
min-height: 100dvh;
}
.email-previews__inner {
width: min(calc(100% - 1rem), 72rem);
padding-top: 4rem;
}
.email-preview summary {
grid-template-columns: minmax(0, 1fr);
gap: 0.55rem;
}
.email-preview__meta {
width: fit-content;
}
.email-preview__frame {
height: 720px;
}
}
</style>
</head>
@@ -296,6 +470,7 @@ const docsHref = '/getting-started/demo/';
<option value="devices">Лимит и докупка устройств</option>
<option value="notifications">Telegram-уведомления</option>
<option value="auth">Вход и регистрация</option>
<option value="emails">Email-письма</option>
</select>
</label>
<div class="demo-topbar__actions">
@@ -313,6 +488,57 @@ const docsHref = '/getting-started/demo/';
src={defaultDemoSrc}
loading="eager"
></iframe>
<section
id="email-previews"
class="email-previews"
aria-labelledby="email-previews-title"
hidden
>
<div class="email-previews__inner">
<header class="email-previews__header">
<div class="email-previews__eyebrow">Email preview</div>
<h1 id="email-previews-title">Превью email-писем</h1>
<p>
Все транзакционные письма собраны на одной странице и подписаны по
сценарию отправки. HTML-превью генерируются теми же шаблонами,
которые отправляются пользователям.
</p>
</header>
<nav class="email-previews__index" aria-label="Навигация по email-письмам">
{
emailPreviews.map((preview) => (
<a href={`#${preview.id}`}>{preview.title}</a>
))
}
</nav>
<div class="email-previews__list">
{
emailPreviews.map((preview, index) => (
<details class="email-preview" id={preview.id} open={index === 0}>
<summary>
<span class="email-preview__title">
<strong>{preview.title}</strong>
<span>{preview.subject}</span>
</span>
<span class="email-preview__meta">{preview.category}</span>
</summary>
<div class="email-preview__body">
<div class="email-preview__frame-wrap">
<iframe
class="email-preview__frame"
title={`Email preview: ${preview.title}`}
srcdoc={preview.html}
loading="lazy"
sandbox=""
></iframe>
</div>
</div>
</details>
))
}
</div>
</div>
</section>
<script is:inline src="/demo/demo-shell.js"></script>
</body>
</html>
+4
View File
@@ -117,6 +117,10 @@
| --- | --- |
| `PANEL_API_URL` | URL API панели, например `https://panel.example.com/api`. |
| `PANEL_API_KEY` | API-ключ панели. |
| `APP_RUNTIME_MODE` | Профиль запуска: `production`, `development`, `staging`, `test`. |
| `PANEL_WRITE_MODE` | `auto`, `live` или `dry_run`. В `dry_run` приложение читает живую Remnawave Panel, но мутации пользователей только валидируются и логируются. `auto` включает dry-run для `development`/`test`, а в production остается live. |
| `PANEL_DRY_RUN_VALIDATE_REMOTE` | При dry-run проверять ссылки на panel users/internal squads через live `GET`. |
| `PANEL_DRY_RUN_SYNTHETIC_CREATE` | При dry-run возвращать синтетического panel user на попытку `POST /users`, чтобы dev-цепочки могли завершиться в локальной БД. |
| `PANEL_WEBHOOK_SECRET` | Секрет проверки Remnawave webhook. Задайте его в Remnawave Panel и вставьте то же значение сюда или в админку. |
| `USER_SQUAD_UUIDS` | Internal Squads по умолчанию для legacy-режима без JSON-каталога. |
| `USER_EXTERNAL_SQUAD_UUID` | Необязательный External Squad. |
+46
View File
@@ -0,0 +1,46 @@
# Анонимная телеметрия установок
Чтобы понимать, сколько инсталляций активно и какие версии используются, бот может раз в сутки отправлять один **полностью обезличенный** «heartbeat». Телеметрия задумана как self-hosted friendly: её легко выключить, она не содержит персональных данных и не мешает работе бота.
## Что отправляется
Каждый сигнал — это случайный непривязанный идентификатор установки плюс грубые факты об окружении:
| Поле | Пример | Назначение |
| --- | --- | --- |
| `installation_id` | `f47ac10b-...` (UUIDv4) | Случайный идентификатор установки. Генерируется один раз, хранится в БД. Не выводится из токена, домена или ID администраторов. |
| `app_version` | `v3.4.6+gabc1234` | Полная версия сборки. |
| `app_version_tag` | `v3.4.6` | Релизный тег для разбивки по версиям. |
| `os` / `arch` | `linux` / `x86_64` | Платформа. |
| `python_version` | `3.12.7` | Версия рантайма. |
| `locale` | `ru` | Язык по умолчанию. |
| `payment_providers` | `["stars", "yookassa"]` | Идентификаторы включённых платёжных провайдеров (без ключей и секретов). |
| `users_bucket` | `51-200` | Число пользователей в виде **диапазона**, не точное значение. |
| `webapp_enabled` / `panel_configured` | `true` | Флаги конфигурации. |
Время приёма (`last_seen`) проставляет коллектор. «Активные установки» = уникальные `installation_id`, от которых сигнал приходил за последние ~48 часов; «разбивка по версиям» = последняя версия на каждую установку.
## Чего там нет
Никогда не отправляются: токен бота, домены, URL вебхуков, ключи платёжных систем и Remnawave, ID или данные пользователей, точное число пользователей, какой-либо контент.
## Как выключить
Достаточно любого из способов:
- **`.env`**: `TELEMETRY_ENABLED=False`, затем перезапуск.
- **Веб-админка**: `Admin → System → «Анонимная статистика установки»`. Переключатель применяется без перезапуска (читается из БД на каждом тике).
- **Сборка/образ**: оставить пустыми `TELEMETRY_ENDPOINT` или `TELEMETRY_API_KEY` — без точки доставки беакон не запускается.
## Доставка
Беакон шлёт `POST {TELEMETRY_ENDPOINT}/capture/` в формате PostHog (`{api_key, event, distinct_id, properties}`). Доставка строго fire-and-forget: таймаут 10 секунд, любые ошибки проглатываются и логируются на уровне `debug` — телеметрия не может задержать или уронить воркер. При нескольких репликах воркера за интервал отправляет только одна (через Redis-lock).
## Переменные окружения
| Переменная | По умолчанию | Назначение |
| --- | --- | --- |
| `TELEMETRY_ENABLED` | `True` | Главный переключатель (opt-out). Дублируется тогглом в админке. |
| `TELEMETRY_ENDPOINT` | `https://eu.i.posthog.com` | Хост приёма PostHog. Пусто — телеметрия выключена. |
| `TELEMETRY_API_KEY` | пусто | Project API key PostHog (`phc_...`). Это write-only ключ ingest, его безопасно зашивать в образ. Пусто — телеметрия выключена. |
| `TELEMETRY_INTERVAL_HOURS` | `24` | Интервал между сигналами. |
+41 -12
View File
@@ -22,10 +22,10 @@ JSON-каталог может содержать несколько тариф
- добавление, редактирование и удаление тарифов;
- включение и выключение тарифа на витрине;
- выбор тарифа по умолчанию;
- настройка тарифов на срок (`period`): месячный лимит, периоды, RUB/Stars цены, реферальные бонусы и пакеты докупки трафика;
- настройка тарифов по трафику (`traffic`): пакеты GB, RUB/Stars цены, курс конвертации;
- настройка тарифов на срок (`period`): месячный лимит, периоды, цены в платежной валюте/Stars, реферальные бонусы и пакеты докупки трафика;
- настройка тарифов по трафику (`traffic`): пакеты GB, цены в платежной валюте/Stars, курс конвертации;
- настройка базовых Internal Squads из списка Remnawave;
- настройка premium-раздела: названия RU/EN, premium Internal Squads, месячный premium-лимит и RUB/Stars пакеты докупки premium-трафика;
- настройка premium-раздела: названия RU/EN, premium Internal Squads, месячный premium-лимит и пакеты докупки premium-трафика в платежной валюте/Stars;
- настройка базового HWID-лимита и пакетов докупки устройств.
После сохранения изменения применяются к новым запросам Web App сразу, потому что конфиг тарифов загружается из JSON при обращении. Уже созданные подписки сохраняют свой `tariff_key`; при удалении или отключении тарифа проверьте, что активные подписки с этим ключом не требуют дальнейшего продления или смены.
@@ -44,6 +44,31 @@ JSON-каталог может содержать несколько тариф
В режиме без JSON-каталога наличие `TRAFFIC_PACKAGES` или `STARS_TRAFFIC_PACKAGES` переключает витрину на продажу трафика вместо подписок на срок.
## Валюта каталога и ограничения провайдеров
JSON-каталог поддерживает `default_currency`. По умолчанию используется `rub`, поэтому существующие каталоги с `prices_rub`, `rub`-пакетами и `.env`-ценами продолжают работать без изменений. Для другой валюты укажите код в нижнем регистре, например `usd`, `eur` или `usdt`, и задайте цены в generic-полях:
- `prices`: `{ "usd": { "1": 4.99, "3": 12.99 } }`;
- `traffic_packages`, `topup_packages`, `premium_topup_packages`, `hwid_device_packages`: ключ валюты вместо `rub`, например `{ "usd": [{ "gb": 50, "price": 2.5 }] }`;
- `conversion_rate_per_gb`: курс конвертации оплаченной стоимости в GB для выбранной валюты.
Legacy-поля остаются алиасами: `prices_rub`, `conversion_rate_rub_per_gb` и ключ `rub` автоматически попадают в generic-модель. Telegram Stars остаются отдельной валютой `stars`/`XTR` и не могут быть `default_currency`.
Платежные провайдеры не принимают произвольный код валюты одинаково. Бот фильтрует способы оплаты и блокирует создание платежа, если текущая валюта каталога не поддерживается провайдером:
| Провайдер | Валюты по умолчанию |
| --- | --- |
| YooKassa | `RUB` |
| WATA | `RUB`, `USD`, `EUR` |
| FreeKassa | `RUB`, `USD`, `EUR`, `UAH`, `KZT` |
| CryptoPay | fiat: `USD`, `EUR`, `RUB`, `BYN`, `UAH`, `GBP`, `CNY`, `KZT`, `UZS`, `GEL`, `TRY`, `AMD`, `THB`, `INR`, `BRL`, `IDR`, `AZN`, `AED`, `PLN`, `ILS`; crypto: `USDT`, `TON`, `BTC`, `ETH`, `LTC`, `BNB`, `TRX`, `USDC` |
| Heleket | настраиваемый список `HELEKET_SUPPORTED_CURRENCIES` |
| Platega | настраиваемый список `PLATEGA_SUPPORTED_CURRENCIES` |
| SeverPay | настраиваемый список `SEVERPAY_SUPPORTED_CURRENCIES` |
| Telegram Stars | `XTR`, отдельные Stars-цены |
В админке раздел **Система → Тарифы** показывает текущую платежную валюту и матрицу провайдеров: включен ли метод, настроен ли сервис и будет ли он доступен при выбранной валюте. Для Platega, SeverPay и Heleket список валют нужно держать в соответствии с условиями вашего мерчанта.
## Структура JSON-каталога
Минимальная структура:
@@ -51,6 +76,7 @@ JSON-каталог может содержать несколько тариф
```json
{
"default_tariff": "standard",
"default_currency": "rub",
"tariffs": [
{
"key": "standard",
@@ -90,6 +116,7 @@ JSON-каталог может содержать несколько тариф
| Поле | Назначение |
| --- | --- |
| `default_tariff` | Тариф по умолчанию для первичного выбора и привязки активных подписок без `tariff_key`. |
| `default_currency` | Валюта цен по умолчанию для JSON-каталога. По умолчанию `rub`; `stars` запрещен, потому что Stars используют отдельные цены. |
| `tariffs[].key` | Стабильный ключ тарифа. Используется в платежах, подписках и смене тарифа. |
| `tariffs[].names` | Названия тарифа по языкам. |
| `tariffs[].descriptions` | Описания тарифа по языкам. |
@@ -108,7 +135,8 @@ JSON-каталог может содержать несколько тариф
| Поле | Назначение |
| --- | --- |
| `monthly_gb` | Базовый месячный лимит трафика тарифа. `0` означает безлимит. |
| `prices_rub` | Цены периодов в рублях, ключ - количество месяцев. |
| `prices` | Generic-цены периодов по валютам, например `{ "usd": { "1": 4.99 } }`. |
| `prices_rub` | Legacy-цены периодов в рублях, ключ - количество месяцев. Эквивалент `prices.rub`. |
| `prices_stars` | Цены периодов в Telegram Stars. |
| `referral_bonus_days_inviter` | Бонус пригласившему в днях для каждого периода. Ключ - количество месяцев, как в `enabled_periods`. |
| `referral_bonus_days_referee` | Бонус приглашенному в днях для каждого периода. Ключ - количество месяцев, как в `enabled_periods`. |
@@ -119,10 +147,11 @@ JSON-каталог может содержать несколько тариф
| Поле | Назначение |
| --- | --- |
| `traffic_packages` | Пакеты трафика в GB для рублей и Telegram Stars. |
| `conversion_rate_rub_per_gb` | Курс для конвертации оставшихся дней period-тарифа в GB при смене на traffic-тариф. |
| `traffic_packages` | Пакеты трафика в GB по валютам каталога и Telegram Stars. |
| `conversion_rate_per_gb` | Курс для конвертации оставшихся дней period-тарифа в GB при смене на traffic-тариф в валюте каталога. |
| `conversion_rate_rub_per_gb` | Legacy-алиас для рублевых каталогов. |
Если у traffic-тарифа нет RUB-пакетов, `conversion_rate_rub_per_gb` обязателен.
Если у traffic-тарифа нет пакетов в `default_currency`, `conversion_rate_per_gb` обязателен.
## Тарифы на срок (`period`)
@@ -248,10 +277,10 @@ limit_after = current_used + balance_after
- при безлимитном базовом лимите докупка устройств не применяется;
- полная цена HWID-пакета берется из `prices[duration_months]`; если периода нет, используется fallback `price * duration_months`;
- фактическая цена докупки считается пропорционально оплачиваемому окну `valid_from -> valid_until` относительно периода подписки и фиксируется в платежe;
- для Telegram Stars цена округляется вверх до целого Stars, для RUB — вверх до копеек; `min_price` защищает от микроплатежей в конце периода;
- для Telegram Stars цена округляется вверх до целого Stars, для платежной валюты — вверх до копеек; `min_price` защищает от микроплатежей в конце периода;
- при продлении подписки докупленные устройства не продлеваются автоматически: старая докупка действует до прежнего `end_date`, а для нового срока создается отдельная `hwid_devices_renewal`-покупка;
- `traffic`-тарифы не показывают и не принимают докупку HWID-устройств, потому что у них нет срока подписки;
- при смене тарифа базовый лимит берется из целевого тарифа, а неиспользованная RUB-стоимость HWID-докупок конвертируется в дни нового period-тарифа или GB traffic-тарифа; XTR/Stars-докупки не конвертируются без явного курса и продолжают жить по своему `valid_until`;
- при смене тарифа базовый лимит берется из целевого тарифа, а неиспользованная стоимость HWID-докупок в платежной валюте конвертируется в дни нового period-тарифа или GB traffic-тарифа; XTR/Stars-докупки не конвертируются без явного курса и продолжают жить по своему `valid_until`;
- история докупок пишется в `hwid_device_purchases`;
- платеж хранит количество устройств в `payments.purchased_hwid_devices`.
@@ -265,9 +294,9 @@ limit_after = current_used + balance_after
| Переход | Поведение |
| --- | --- |
| `period -> period` | Остаток оплаченных дней оценивается по `effective_monthly_price_rub`, затем пересчитывается в дни целевого тарифа через месячную цену целевого тарифа. Неиспользованная RUB-стоимость HWID-докупок добавляется к этому расчету как дополнительные дни. Количество дней округляется вниз. |
| `period -> period` с доплатой | Если целевой тариф дороже, может быть создан платеж `tariff_upgrade`; неиспользованная RUB-стоимость HWID-докупок уменьшает сумму доплаты. После оплаты применяется целевой тариф, а конвертированные HWID-окна закрываются. |
| `period -> traffic` | Остаток оплаченных дней и неиспользованная RUB-стоимость HWID-докупок конвертируются в GB по `conversion_rate_rub_per_gb` или минимальной RUB-цене GB из пакетов целевого тарифа. |
| `period -> period` | Остаток оплаченных дней оценивается по legacy-полю `effective_monthly_price_rub`, где хранится месячная цена в платежной валюте каталога, затем пересчитывается в дни целевого тарифа через месячную цену целевого тарифа. Неиспользованная стоимость HWID-докупок в платежной валюте добавляется к этому расчету как дополнительные дни. Количество дней округляется вниз. |
| `period -> period` с доплатой | Если целевой тариф дороже, может быть создан платеж `tariff_upgrade`; неиспользованная стоимость HWID-докупок в платежной валюте уменьшает сумму доплаты. После оплаты применяется целевой тариф, а конвертированные HWID-окна закрываются. |
| `period -> traffic` | Остаток оплаченных дней и неиспользованная стоимость HWID-докупок в платежной валюте конвертируются в GB по `conversion_rate_per_gb` или минимальной цене GB из пакетов целевого тарифа. |
| `traffic -> period` | Пользователь выбирает и оплачивает период целевого тарифа; остаток GB сохраняется как `topup_balance_bytes` поверх лимита period-тарифа. |
При смене тарифа бот меняет:
+34
View File
@@ -409,3 +409,37 @@ app.example.com {
```bash
APP_ENV_FILE=.env.staging docker compose --env-file .env.staging up -d --build
```
## Dev dry-run рядом с production
Для проверки фичей на той же Remnawave Panel поднимайте dev-стек с отдельным
env-файлом, отдельным Telegram-ботом и локальной БД.
В dev-режиме приложение продолжает читать пользователей, squads, devices и
статистику из живой панели, но записи в пользователей Remnawave не отправляет:
payload валидируется, а в логах появляется строка вида
`[PANEL DRY-RUN OK] would PATCH /users ...`.
Минимальный фрагмент `.env.dev`:
```env
APP_RUNTIME_MODE=development
PANEL_WRITE_MODE=dry_run
PANEL_DRY_RUN_VALIDATE_REMOTE=True
PANEL_DRY_RUN_SYNTHETIC_CREATE=True
REDIS_KEY_PREFIX=remnawave-tg-shop-dev
BACKUP_ENABLED=False
```
Запуск:
```bash
APP_ENV_FILE=.env.dev docker compose --env-file .env.dev up -d --build
```
`PANEL_WRITE_MODE=live` можно поставить только для отдельной тестовой Remnawave
Panel, потому что этот режим реально меняет пользователей панели.
Если второй стек запускается на том же хосте, дополнительно разведите
`WEB_SERVER_PORT` и `FRONTEND_PORT`. Если production на другом сервере, локальные
порты можно оставить стандартными.
+23 -2
View File
@@ -183,6 +183,7 @@
let languageClickGuardArmed = false;
let languageClickGuardTimer = null;
let languageClickGuardArmTimer = null;
let guestLanguage = "";
let emailAvatarUrl = "";
let avatarHashToken = "";
let token = MOCK ? "local-preview" : "";
@@ -213,12 +214,13 @@
const i18n = createI18n({
messages: I18N,
defaultLang: "ru",
getLang: () => user?.language_code || CFG.language || "ru",
getLang: () => user?.language_code || guestLanguage || CFG.language || "ru",
});
const normalizeLangCode = i18n.normalizeLangCode;
const t = i18n.t;
const termUnitLabel = i18n.termUnitLabel;
const languageName = i18n.languageName;
guestLanguage = normalizeLangCode(CFG.language || "ru");
const apiClient = createApiClient({
apiBase: CFG.apiBase,
csrfCookieName: CSRF_COOKIE_NAME,
@@ -460,7 +462,7 @@
activeTab = "settings";
}
$: referral = data?.referral || MOCK_SOURCE.data.referral;
$: currentLang = normalizeLangCode(user?.language_code || CFG.language || "ru");
$: currentLang = normalizeLangCode(user?.language_code || guestLanguage || CFG.language || "ru");
$: languageCodes = uniqueLanguageCodes(
WEBAPP_LANGUAGE_ORDER,
CFG.languages,
@@ -939,6 +941,13 @@
}, 260);
}
function updateGuestLanguage(nextValue) {
const language = normalizeLangCode(nextValue);
setLanguageMenuOpen(false);
if (!language || language === currentLang) return;
guestLanguage = language;
}
function readTelegramMiniAppInitDataFromLocation() {
return telegramSdk.readInitDataFromLocation();
}
@@ -1219,6 +1228,7 @@
const emailHint = readEmailCodeLoginDeeplink();
if (!emailHint) return;
emailLoginDeeplinkConsumed = true;
authStore.clearPendingEmailCode();
authStore.update((s) => ({
...s,
email: emailHint,
@@ -1670,6 +1680,9 @@
screen = "login";
activeTab = "home";
setPasswordLoginMode(isPasswordLoginPath(), true);
authStore.restorePendingEmailCode((nextScreen) => {
screen = nextScreen;
});
void startEmailCodeLoginFromDeeplink();
}
@@ -2208,7 +2221,15 @@
{telegramLoginUnavailableMessage}
{privacyPolicyUrl}
{userAgreementUrl}
{currentLang}
{currentLanguageOption}
{languageOptions}
{languageMenuOpen}
{languageClickGuard}
{languageClickGuardArmed}
{t}
{setLanguageMenuOpen}
updateLoginLanguage={updateGuestLanguage}
requestEmailCode={() => authStore.requestEmailCode((s) => (screen = s))}
loginWithEmailPassword={authStore.loginWithEmailPassword}
verifyEmailCode={authStore.verifyEmailCode}
+10 -5
View File
@@ -228,6 +228,7 @@
let adminLanguageClickGuardArmed = false;
let adminLanguageClickGuardTimer = null;
let adminLanguageClickGuardArmTimer = null;
$: adminLanguageGuardActive = isCompact && (adminLanguageMenuOpen || adminLanguageClickGuard);
function readReduceMotion() {
return (
@@ -295,6 +296,8 @@
}
function changeLanguage(value) {
adminLanguageMenuOpen = false;
clearAdminLanguageClickGuard();
onLanguageChange(value, { section: "admin", adminSection: active });
}
@@ -472,8 +475,6 @@
function setAdminLanguageMenuOpen(open) {
adminLanguageMenuOpen = Boolean(open);
clearAdminLanguageClickGuard();
// Desktop doesn't need the click-guard overlay and it can block
// option clicks in portaled select content.
if (!isCompact) return;
if (adminLanguageMenuOpen) {
adminLanguageClickGuard = true;
@@ -557,7 +558,11 @@
}
</script>
<div class="admin-screen-wrap" class:is-sidebar-open={sidebarOpen}>
<div
class="admin-screen-wrap"
class:is-sidebar-open={sidebarOpen}
class:is-admin-language-open={adminLanguageGuardActive}
>
{#if sidebarOpen}
<button
type="button"
@@ -568,7 +573,7 @@
on:click={() => (sidebarOpen = false)}
></button>
{/if}
{#if isCompact && (adminLanguageMenuOpen || adminLanguageClickGuard)}
{#if adminLanguageGuardActive}
<button
class="language-select-guard"
class:language-select-guard--armed={adminLanguageClickGuardArmed}
@@ -578,7 +583,6 @@
on:click={closeAdminLanguageFromGuard}
></button>
{/if}
<aside class="admin-sidebar" aria-label={at("sidebar_navigation", {}, "Навигация админки")}>
<div class="admin-sidebar-brand">
<BrandMark class="admin-brand-mark" {brand} />
@@ -785,6 +789,7 @@
<UsersSection
{at}
{fmtDateShort}
{fmtMoney}
{panelStatusBadge}
{resolvedAvatarUrl}
{userDisplayName}
@@ -347,6 +347,7 @@
support: "Поддержка",
devices: "Устройства",
subscription_guides: "Connection guides",
system: "Система",
};
return adminText(`settings_section_${id}`, {}, map[id] || id);
}
@@ -728,7 +728,11 @@
</Card.Header>
<Card.Footer class="admin-cn-card-footer--stack">
<div class="admin-cn-card-footer-primary">
{at("stats_trend_new_today", { count: users.active_today ?? 0 }, "")}
{at(
"stats_trend_expired_subscriptions",
{ count: users.expired_subscription_users ?? 0 },
""
)}
</div>
<div class="admin-cn-card-footer-muted">{at("stats_card_inactive_caption", {}, "")}</div>
</Card.Footer>
@@ -5,7 +5,7 @@
import { Plus, Save, Trash2, X } from "$components/ui/icons.js";
import { AdminButton, AdminSelect } from "$components/patterns/admin/index.js";
import { getContext } from "svelte";
import { normalizeUuidList } from "../../lib/admin/tariffDraft.js";
import { normalizeCurrencyKey, normalizeUuidList } from "../../lib/admin/tariffDraft.js";
export let at;
const tariffsStore = getContext("tariffsStore");
@@ -19,6 +19,7 @@
tariffDeleteTarget,
panelSquadsLoading,
panelSquads,
tariffsCatalog,
} = $tariffsStore);
$: billingModelOptions = [
@@ -29,6 +30,33 @@
value: squad.uuid,
label: squad.name,
}));
$: defaultCurrencyKey = normalizeCurrencyKey(tariffsCatalog?.default_currency || "rub");
$: defaultCurrencyCode = defaultCurrencyKey.toUpperCase();
$: currencyPackageLabel = at(
"tariff_btn_package_currency",
{ currency: defaultCurrencyCode },
`Пакет ${defaultCurrencyCode}`
);
$: currencyPaymentLabel = at(
"payment_default_currency",
{ currency: defaultCurrencyCode },
`Оплата ${defaultCurrencyCode}`
);
$: currencyPriceColumnLabel = at(
"tariff_col_price_currency",
{ currency: defaultCurrencyCode },
`Цена, ${defaultCurrencyCode}`
);
$: currencyPriceAriaLabel = at(
"tariff_label_price_currency",
{ currency: defaultCurrencyCode },
`Цена в ${defaultCurrencyCode}`
);
$: conversionCurrencyLabel = at(
"tariff_label_conversion_currency",
{ currency: defaultCurrencyCode },
`Курс конвертации, ${defaultCurrencyCode} за 1 GB`
);
</script>
<Dialog
@@ -241,7 +269,7 @@
</Label.Root>
{:else}
<Label.Root class="admin-field-label">
<span>{at("tariff_label_conversion", {}, "Курс конвертации, ₽ за 1 GB")}</span>
<span>{conversionCurrencyLabel}</span>
<small
>{at(
"tariff_hint_conversion",
@@ -388,7 +416,7 @@
<AdminButton
size="sm"
onclick={() => tariffsStore.addDraftRow("premiumTopupRubRows", { gb: 10, price: "" })}
><Plus size={12} /> {at("tariff_btn_package_rub", {}, "Пакет ₽")}</AdminButton
><Plus size={12} /> {currencyPackageLabel}</AdminButton
>
<AdminButton
size="sm"
@@ -400,11 +428,11 @@
</header>
<div class="admin-package-columns">
<div class="admin-row-editor">
<span class="admin-row-editor-caption">{at("payment_rub", {}, "Оплата рублями")}</span>
<span class="admin-row-editor-caption">{currencyPaymentLabel}</span>
{#if tariffDraft.premiumTopupRubRows.length}
<div class="admin-row-editor-line admin-row-editor-header">
<span>{at("tariff_col_volume_gb", {}, "Объём, GB")}</span>
<span>{at("tariff_col_price_rub", {}, "Цена, ₽")}</span>
<span>{currencyPriceColumnLabel}</span>
<span></span>
</div>
{/if}
@@ -426,7 +454,7 @@
step="0.01"
placeholder="199"
bind:value={row.price}
aria-label={at("tariff_label_price_rub", {}, "Цена premium-пакета в рублях")}
aria-label={currencyPriceAriaLabel}
/>
<AdminButton
size="sm"
@@ -526,7 +554,7 @@
<div class="admin-row-editor">
<div class="admin-row-editor-line admin-row-editor-6 admin-row-editor-header">
<span>{at("tariff_col_period_months", {}, "Срок, мес.")}</span>
<span>{at("tariff_col_price_rub", {}, "Цена, ₽")}</span>
<span>{currencyPriceColumnLabel}</span>
<span>{at("tariff_col_price_stars_full", {}, "Цена, ⭐ Stars")}</span>
<span>{at("tariff_col_ref_inviter", {}, "Бонус приглашающему")}</span>
<span>{at("tariff_col_ref_referee", {}, "Бонус приглашённому")}</span>
@@ -549,7 +577,7 @@
step="0.01"
placeholder="299"
bind:value={row.rub}
aria-label={at("tariff_label_price_rub", {}, "Цена в рублях")}
aria-label={currencyPriceAriaLabel}
/>
<Input
class="input"
@@ -608,7 +636,7 @@
<AdminButton
size="sm"
onclick={() => tariffsStore.addDraftRow("trafficRubRows", { gb: 10, price: "" })}
><Plus size={12} /> {at("tariff_btn_package_rub", {}, "Пакет ₽")}</AdminButton
><Plus size={12} /> {currencyPackageLabel}</AdminButton
>
<AdminButton
size="sm"
@@ -619,12 +647,11 @@
</header>
<div class="admin-package-columns">
<div class="admin-row-editor">
<span class="admin-row-editor-caption">{at("payment_rub", {}, "Оплата рублями")}</span
>
<span class="admin-row-editor-caption">{currencyPaymentLabel}</span>
{#if tariffDraft.trafficRubRows.length}
<div class="admin-row-editor-line admin-row-editor-header">
<span>{at("tariff_col_volume_gb", {}, "Объём, GB")}</span>
<span>{at("tariff_col_price_rub", {}, "Цена, ₽")}</span>
<span>{currencyPriceColumnLabel}</span>
<span></span>
</div>
{/if}
@@ -646,7 +673,7 @@
step="0.01"
placeholder="299"
bind:value={row.price}
aria-label={at("tariff_label_price_rub", {}, "Цена пакета в рублях")}
aria-label={currencyPriceAriaLabel}
/>
<AdminButton
size="sm"
@@ -722,7 +749,7 @@
<AdminButton
size="sm"
onclick={() => tariffsStore.addDraftRow("topupRubRows", { gb: 10, price: "" })}
><Plus size={12} /> {at("tariff_btn_package_rub", {}, "Пакет ₽")}</AdminButton
><Plus size={12} /> {currencyPackageLabel}</AdminButton
>
<AdminButton
size="sm"
@@ -733,12 +760,11 @@
</header>
<div class="admin-package-columns">
<div class="admin-row-editor">
<span class="admin-row-editor-caption">{at("payment_rub", {}, "Оплата рублями")}</span
>
<span class="admin-row-editor-caption">{currencyPaymentLabel}</span>
{#if tariffDraft.topupRubRows.length}
<div class="admin-row-editor-line admin-row-editor-header">
<span>{at("tariff_col_volume_gb", {}, "Объём, GB")}</span>
<span>{at("tariff_col_price_rub", {}, "Цена, ₽")}</span>
<span>{currencyPriceColumnLabel}</span>
<span></span>
</div>
{/if}
@@ -760,7 +786,7 @@
step="0.01"
placeholder="149"
bind:value={row.price}
aria-label={at("tariff_label_price_rub", {}, "Цена пакета в рублях")}
aria-label={currencyPriceAriaLabel}
/>
<AdminButton
size="sm"
@@ -847,7 +873,7 @@
<AdminButton
size="sm"
onclick={() => tariffsStore.addDraftRow("hwidRubRows", { count: 1, price: "" })}
><Plus size={12} /> {at("tariff_btn_package_rub", {}, "Пакет ₽")}</AdminButton
><Plus size={12} /> {currencyPackageLabel}</AdminButton
>
<AdminButton
size="sm"
@@ -858,11 +884,11 @@
</header>
<div class="admin-package-columns">
<div class="admin-row-editor">
<span class="admin-row-editor-caption">{at("payment_rub", {}, "Оплата рублями")}</span>
<span class="admin-row-editor-caption">{currencyPaymentLabel}</span>
{#if tariffDraft.hwidRubRows.length}
<div class="admin-row-editor-line admin-row-editor-header">
<span>{at("tariff_col_hwid_count", {}, "+ устройств")}</span>
<span>{at("tariff_col_price_rub", {}, "Цена, ₽")}</span>
<span>{currencyPriceColumnLabel}</span>
<span></span>
</div>
{/if}
@@ -888,7 +914,7 @@
step="0.01"
placeholder="99"
bind:value={row.price}
aria-label={at("tariff_label_price_rub", {}, "Цена пакета в рублях")}
aria-label={currencyPriceAriaLabel}
/>
<AdminButton
size="sm"
+306 -126
View File
@@ -17,6 +17,7 @@
AdminSelect,
} from "$components/patterns/admin/index.js";
import { Accordion, Switch } from "$components/ui/primitives.js";
import { normalizeCurrencyKey } from "$lib/admin/tariffDraft.js";
export let at;
export let fmtMoney;
@@ -88,6 +89,7 @@
tariffsPath,
tariffsSaving,
panelSquads,
providerCurrencySupport,
panelSquadsLoading,
} = $tariffsStore);
$: ({ settingsSections, settingsDirty, settingsSaving } = $settingsStore);
@@ -111,25 +113,30 @@
let selectedTrialSquad = "";
let trialSquadSelectKey = 0;
let tariffSettingsOpen = [];
let defaultCurrencyDraft = "RUB";
function tariffName(tariff) {
return tariff?.names?.ru || tariff?.names?.en || tariff?.key || "—";
}
function tariffPriceSummary(tariff) {
const currency = normalizeCurrencyKey(tariffsCatalog.default_currency || "rub");
const currencyCode = currency.toUpperCase();
if (tariff.billing_model === "traffic") {
const rub = tariff.traffic_packages?.rub || [];
const first = rub[0];
const packages = tariff.traffic_packages?.[currency] || [];
const first = packages[0];
return first
? `${first.gb} GB ${at("at", {}, "за")} ${fmtMoney(first.price, "RUB")}`
? `${first.gb} GB ${at("at", {}, "за")} ${fmtMoney(first.price, currencyCode)}`
: at("tariff_traffic_packages", {}, "Пакеты трафика");
}
const months = [...(tariff.enabled_periods || [])].sort((a, b) => a - b);
return months
.map((month) => {
const rub = tariff.prices_rub?.[String(month)];
const rub =
(currency === "rub" ? tariff.prices_rub?.[String(month)] : undefined) ??
tariff.prices?.[currency]?.[String(month)];
const stars = tariff.prices_stars?.[String(month)];
if (rub) return `${month} ${at("months_short", {}, "мес.")} ${fmtMoney(rub, "RUB")}`;
if (rub) return `${month} ${at("months_short", {}, "мес.")} ${fmtMoney(rub, currencyCode)}`;
if (stars) return `${month} ${at("months_short", {}, "мес.")} ${stars} ⭐`;
return `${month} ${at("months_short", {}, "мес.")}`;
})
@@ -203,6 +210,50 @@
trialSquadSelectKey += 1;
}
$: catalogCurrencyKey = normalizeCurrencyKey(tariffsCatalog.default_currency || "rub");
$: catalogCurrencyCode = catalogCurrencyKey.toUpperCase();
$: defaultCurrencyDraft = catalogCurrencyCode;
$: defaultCurrencyDraftKey = normalizeCurrencyKey(defaultCurrencyDraft || "rub");
$: defaultCurrencyDirty = defaultCurrencyDraftKey !== catalogCurrencyKey;
$: providerSupportSummary = (providerCurrencySupport || []).reduce(
(summary, provider) => {
const enabled = Boolean(provider.enabled);
const configured = Boolean(provider.configured);
const supportsDefault = Boolean(provider.supports_default_currency);
summary.total += 1;
if (enabled) summary.enabled += 1;
if (enabled && configured) summary.configured += 1;
if (enabled && configured && supportsDefault) summary.available += 1;
if (enabled && configured && !supportsDefault) summary.blocked += 1;
return summary;
},
{ total: 0, enabled: 0, configured: 0, available: 0, blocked: 0 }
);
async function saveDefaultCurrency() {
await tariffsStore.setDefaultCurrency(defaultCurrencyDraft);
}
function providerCurrencyLabel(provider) {
if (provider.accepts_any_currency) return at("tariff_provider_any_currency", {}, "Любая");
return (
(provider.currencies || []).map((currency) => String(currency).toUpperCase()).join(", ") ||
at("tariff_provider_not_declared", {}, "Не задано")
);
}
function providerCurrencyVariant(provider) {
if (!provider.enabled || !provider.configured) return "muted";
return provider.supports_default_currency ? "success" : "warning";
}
function providerCurrencyStatus(provider) {
if (!provider.enabled) return at("disabled", {}, "Отключен");
if (!provider.configured) return at("status_not_configured", {}, "Не настроен");
if (provider.supports_default_currency) return at("tariff_currency_supported", {}, "Доступен");
return at("tariff_currency_unsupported", {}, "Заблокирован");
}
function removeTrialSquad(uuid) {
setCsvList(
"TRIAL_SQUAD_UUIDS",
@@ -626,129 +677,258 @@
</div>
</article>
<article class="admin-card">
<header class="admin-card-head">
<div>
<h3>{at("tariffs_title", {}, "Каталог тарифов")}</h3>
<small>{tariffsPath || "data/tariffs.json"}</small>
</div>
<div class="admin-editor-section-actions">
<AdminButton
size="sm"
onclick={tariffsStore.loadTariffs}
disabled={tariffsLoading || tariffsSaving}
>
<RefreshCw size={13} />
{at("btn_refresh", {}, "Обновить")}
</AdminButton>
<AdminButton
size="sm"
variant="primary"
onclick={tariffsStore.openCreateTariff}
disabled={tariffsLoading || tariffsSaving}
>
<Plus size={13} />
{at("btn_create_tariff", {}, "Создать тариф")}
</AdminButton>
</div>
</header>
<div class="admin-card-body">
{#if !tariffsCatalog.tariffs.length}
<AdminEmptyState>
{at(
"tariffs_catalog_empty",
{},
"Каталог пуст. Добавьте первый тариф, после сохранения будет создан JSON-файл каталога."
)}
</AdminEmptyState>
{:else}
<div class="admin-tariff-grid">
{#each tariffsCatalog.tariffs as tariff}
<article class="admin-tariff-card" class:is-disabled={tariff.enabled === false}>
<div class="admin-tariff-top">
<div>
<div class="admin-tariff-title">
<strong>{tariffName(tariff)}</strong>
{#if tariff.key === tariffsCatalog.default_tariff}
<AdminBadge variant="success"
>{at("status_default", {}, "Default")}</AdminBadge
>
{/if}
</div>
<code>{tariff.key}</code>
</div>
{#if tariff.enabled === false}
<AdminBadge variant="muted">{at("status_disabled", {}, "Выключен")}</AdminBadge>
{:else}
<AdminBadge variant="success">{at("status_active", {}, "Активен")}</AdminBadge>
{/if}
</div>
<p>
{tariff.descriptions?.ru ||
tariff.descriptions?.en ||
at("no_description", {}, "Без описания")}
</p>
<div class="admin-tariff-facts">
<span
>{tariff.billing_model === "traffic"
? at("tariff_model_traffic", {}, "Трафик")
: at("tariff_model_periods", {}, "Периоды")}</span
>
<span>{tariffPriceSummary(tariff)}</span>
<span>{at("tariff_squads", {}, "Squads")}: {(tariff.squad_uuids || []).length}</span
>
<span
>{at("tariff_premium", {}, "Premium")}: {(tariff.premium_squad_uuids || []).length
? `${tariff.premium_monthly_gb || 0} GB`
: "—"}</span
>
<span
>{at("tariff_devices", {}, "Устройства")}: {tariff.hwid_device_limit ??
"env"}</span
>
</div>
<div class="admin-tariff-actions">
<AdminButton size="sm" onclick={() => tariffsStore.openEditTariff(tariff)}>
{at("btn_configure", {}, "Настроить")}
</AdminButton>
<AdminButton
size="sm"
onclick={() => tariffsStore.toggleTariffEnabled(tariff)}
disabled={tariffsSaving}
>
{tariff.enabled === false
? at("btn_enable", {}, "Включить")
: at("btn_disable", {}, "Выключить")}
</AdminButton>
<AdminButton
size="sm"
onclick={() => tariffsStore.setDefaultTariff(tariff.key)}
disabled={tariffsSaving ||
tariff.enabled === false ||
tariff.key === tariffsCatalog.default_tariff}
>
{at("btn_set_default", {}, "По умолчанию")}
</AdminButton>
<AdminButton
size="sm"
variant="danger"
onclick={() =>
tariffsStore.updateState({
tariffDeleteTarget: tariff,
tariffDeleteOpen: true,
})}
disabled={tariffsSaving}
aria-label={at("btn_delete_tariff", {}, "Удалить тариф")}
>
<Trash2 size={13} />
</AdminButton>
</div>
</article>
{/each}
<div class="admin-tariff-management">
<div class="admin-tariff-overview-grid">
<article class="admin-card admin-tariff-currency-card">
<header class="admin-card-head admin-tariff-panel-head">
<div>
<h3>{at("tariffs_currency_title", {}, "Валюта каталога")}</h3>
<small>
{at(
"tariffs_currency_subtitle",
{},
"Цены тарифов и платёжные провайдеры проверяются по этой валюте."
)}
</small>
</div>
<AdminBadge variant="muted">{catalogCurrencyCode}</AdminBadge>
</header>
<div class="admin-card-body admin-tariff-currency-body">
<div class="admin-tariff-currency-current">
<span>{at("tariffs_currency_current", {}, "Текущая валюта")}</span>
<strong>{catalogCurrencyCode}</strong>
</div>
<div class="admin-tariff-catalog-bar">
<label class="admin-field-label-compact admin-tariff-currency-field">
<span>{at("tariff_default_currency", {}, "Валюта оплаты")}</span>
<Input
class="input admin-currency-input"
type="text"
maxlength="12"
value={defaultCurrencyDraft}
oninput={(event) =>
(defaultCurrencyDraft = event.currentTarget.value.toUpperCase())}
onkeydown={(event) => {
if (event.key === "Enter" && defaultCurrencyDirty) saveDefaultCurrency();
}}
/>
</label>
{#if defaultCurrencyDirty}
<AdminButton
size="sm"
variant="primary"
onclick={saveDefaultCurrency}
disabled={tariffsSaving}
>
<Save size={13} />
{tariffsSaving
? at("btn_saving", {}, "Сохранение...")
: at("btn_save", {}, "Сохранить")}
</AdminButton>
{/if}
</div>
</div>
{/if}
</article>
<article class="admin-card admin-tariff-providers-card">
<header class="admin-card-head admin-tariff-panel-head">
<div>
<h3>{at("tariffs_provider_title", {}, "Платёжные провайдеры")}</h3>
<small>
{at(
"tariffs_provider_subtitle",
{},
"Здесь видно, какие провайдеры смогут принять текущую валюту каталога."
)}
</small>
</div>
<div class="admin-provider-summary">
<AdminBadge variant="success">
{at(
"tariffs_provider_available_count",
{ count: providerSupportSummary.available },
"Доступно: {count}"
)}
</AdminBadge>
<AdminBadge variant="muted">
{at(
"tariffs_provider_enabled_count",
{ count: providerSupportSummary.enabled },
"Включено: {count}"
)}
</AdminBadge>
{#if providerSupportSummary.blocked}
<AdminBadge variant="warning">
{at(
"tariffs_provider_blocked_count",
{ count: providerSupportSummary.blocked },
"Не подходят: {count}"
)}
</AdminBadge>
{/if}
</div>
</header>
<div class="admin-card-body">
{#if providerCurrencySupport?.length}
<div class="admin-provider-currency-grid">
{#each providerCurrencySupport as provider}
<div
class="admin-provider-currency"
class:is-supported={provider.supports_default_currency &&
provider.enabled &&
provider.configured}
class:is-unavailable={!provider.supports_default_currency ||
!provider.enabled ||
!provider.configured}
>
<div class="admin-provider-currency-main">
<strong>{provider.label}</strong>
<small>{providerCurrencyLabel(provider)}</small>
</div>
<AdminBadge variant={providerCurrencyVariant(provider)}>
{providerCurrencyStatus(provider)}
</AdminBadge>
</div>
{/each}
</div>
{:else}
<AdminEmptyState>
{at("tariffs_provider_empty", {}, "Данные по провайдерам пока не загружены.")}
</AdminEmptyState>
{/if}
</div>
</article>
</div>
</article>
<article class="admin-card admin-tariff-list-card">
<header class="admin-card-head admin-tariff-list-head">
<div>
<h3>{at("tariffs_title", {}, "Каталог тарифов")}</h3>
<small>
{at("tariffs_catalog_subtitle", {}, "Периоды, цены, трафик и доступы пользователей.")}
</small>
<code class="admin-tariff-path">{tariffsPath || "data/tariffs.json"}</code>
</div>
<div class="admin-editor-section-actions">
<AdminButton
size="sm"
onclick={tariffsStore.loadTariffs}
disabled={tariffsLoading || tariffsSaving}
>
<RefreshCw size={13} />
{at("btn_refresh", {}, "Обновить")}
</AdminButton>
<AdminButton
size="sm"
variant="primary"
onclick={tariffsStore.openCreateTariff}
disabled={tariffsLoading || tariffsSaving}
>
<Plus size={13} />
{at("btn_create_tariff", {}, "Создать тариф")}
</AdminButton>
</div>
</header>
<div class="admin-card-body">
{#if !tariffsCatalog.tariffs.length}
<AdminEmptyState>
{at(
"tariffs_catalog_empty",
{},
"Каталог пуст. Добавьте первый тариф, после сохранения будет создан JSON-файл каталога."
)}
</AdminEmptyState>
{:else}
<div class="admin-tariff-grid">
{#each tariffsCatalog.tariffs as tariff}
<article class="admin-tariff-card" class:is-disabled={tariff.enabled === false}>
<div class="admin-tariff-top">
<div>
<div class="admin-tariff-title">
<strong>{tariffName(tariff)}</strong>
{#if tariff.key === tariffsCatalog.default_tariff}
<AdminBadge variant="success"
>{at("status_default", {}, "Default")}</AdminBadge
>
{/if}
</div>
<code>{tariff.key}</code>
</div>
{#if tariff.enabled === false}
<AdminBadge variant="muted">{at("status_disabled", {}, "Выключен")}</AdminBadge>
{:else}
<AdminBadge variant="success">{at("status_active", {}, "Активен")}</AdminBadge>
{/if}
</div>
<p>
{tariff.descriptions?.ru ||
tariff.descriptions?.en ||
at("no_description", {}, "Без описания")}
</p>
<div class="admin-tariff-facts">
<span
>{tariff.billing_model === "traffic"
? at("tariff_model_traffic", {}, "Трафик")
: at("tariff_model_periods", {}, "Периоды")}</span
>
<span>{tariffPriceSummary(tariff)}</span>
<span
>{at("tariff_squads", {}, "Squads")}: {(tariff.squad_uuids || []).length}</span
>
<span
>{at("tariff_premium", {}, "Premium")}: {(tariff.premium_squad_uuids || [])
.length
? `${tariff.premium_monthly_gb || 0} GB`
: "—"}</span
>
<span
>{at("tariff_devices", {}, "Устройства")}: {tariff.hwid_device_limit ??
"env"}</span
>
</div>
<div class="admin-tariff-actions">
<AdminButton size="sm" onclick={() => tariffsStore.openEditTariff(tariff)}>
{at("btn_configure", {}, "Настроить")}
</AdminButton>
<AdminButton
size="sm"
onclick={() => tariffsStore.toggleTariffEnabled(tariff)}
disabled={tariffsSaving}
>
{tariff.enabled === false
? at("btn_enable", {}, "Включить")
: at("btn_disable", {}, "Выключить")}
</AdminButton>
<AdminButton
size="sm"
onclick={() => tariffsStore.setDefaultTariff(tariff.key)}
disabled={tariffsSaving ||
tariff.enabled === false ||
tariff.key === tariffsCatalog.default_tariff}
>
{at("btn_set_default", {}, "По умолчанию")}
</AdminButton>
<AdminButton
size="sm"
variant="danger"
onclick={() =>
tariffsStore.updateState({
tariffDeleteTarget: tariff,
tariffDeleteOpen: true,
})}
disabled={tariffsSaving}
aria-label={at("btn_delete_tariff", {}, "Удалить тариф")}
>
<Trash2 size={13} />
</AdminButton>
</div>
</article>
{/each}
</div>
{/if}
</div>
</article>
</div>
<Accordion.Root
type="multiple"
@@ -23,6 +23,7 @@
Trash2,
UserMinus,
UserPlus,
UsersRound,
} from "$components/ui/icons.js";
import { getContext } from "svelte";
@@ -63,6 +64,12 @@
userDeleteOpen,
userBanConfirmOpen,
userMessageConfirmOpen,
userReferralsOpen,
userReferralsLoading,
userReferrals,
userReferralsTotal,
userReferralsPage,
userReferralsPageSize,
premiumUnlimitedDraft,
userDetailTab,
userLogs,
@@ -75,8 +82,13 @@
$: userLogsHasMore =
Number(userLogsTotal || 0) > (Number(userLogsPage || 0) + 1) * Number(userLogsPageSize || 20);
$: userReferralsHasMore =
Number(userReferralsTotal || 0) >
(Number(userReferralsPage || 0) + 1) * Number(userReferralsPageSize || 25);
$: openedUserAvatarUrl = openedUser ? resolvedAvatarUrl(openedUser) : "";
$: referralInviter = openedUserDetail?.referral?.inviter || null;
$: referralInviteesTotal = Number(openedUserDetail?.referral?.invitees_total || 0);
$: openedUserTelegramProfileLink = openedUser ? userTelegramProfileLink(openedUser) : "";
$: openedUserTelegramProfileLinkKind = openedUser ? userTelegramProfileLinkKind(openedUser) : "";
$: openedUserTelegramProfileHint =
@@ -119,6 +131,12 @@
}
openTelegramProfileLink(openedUserTelegramProfileLink);
}
function openRelatedUser(user) {
if (!user?.user_id) return;
usersStore.closeUserReferrals();
usersStore.openUser(user);
}
</script>
<Dialog
@@ -225,6 +243,41 @@
"—"}</strong
>
</li>
<li class="admin-user-ref-row">
<span>{at("user_label_invited_by", {}, "Пригласил")}</span>
<strong class="admin-user-ref-value">
{#if referralInviter}
<span>{userDisplayName(referralInviter)}</span>
<small>ID {referralInviter.user_id}</small>
{:else}
<span>{at("user_invited_by_none", {}, "—")}</span>
{/if}
</strong>
{#if referralInviter}
<AdminButton
size="icon"
variant="icon"
title={at("user_open_related", {}, "Открыть карточку")}
aria-label={at("user_open_related", {}, "Открыть карточку")}
onclick={() => openRelatedUser(referralInviter)}
>
<ExternalLink size={14} />
</AdminButton>
{/if}
</li>
<li class="admin-user-ref-row">
<span>{at("user_label_invited_users", {}, "Приглашённые")}</span>
<strong>{referralInviteesTotal}</strong>
<AdminButton
size="sm"
variant="ghost"
disabled={referralInviteesTotal <= 0}
onclick={() => usersStore.openUserReferrals(0)}
>
<UsersRound size={14} />
{at("user_invitees_open", {}, "Показать")}
</AdminButton>
</li>
</ul>
{#if openedUserDetail.subscription_url || openedUserDetail.referral?.bot_link || openedUserDetail.referral?.webapp_link}
@@ -934,6 +987,101 @@
{/if}
</Dialog>
<Dialog
open={userReferralsOpen}
title={at("user_invitees_title", {}, "Приглашённые пользователи")}
description={openedUser
? at(
"user_invitees_description",
{ name: userDisplayName(openedUser), count: userReferralsTotal },
`${userDisplayName(openedUser)} · ${userReferralsTotal}`
)
: ""}
closeLabel={at("close", {}, "Закрыть")}
onclose={usersStore.closeUserReferrals}
class="admin-dialog admin-user-referrals-dialog"
>
<div class="admin-user-referrals-body">
{#if userReferralsLoading}
<AdminTableSkeleton
headers={[
at("user_col_user", {}, "Пользователь"),
"ID",
at("user_label_registration", {}, "Регистрация"),
"",
]}
rows={5}
widths={["42%", "18%", "26%", "14%"]}
/>
{:else if !userReferrals.length}
<AdminEmptyState tone="card">
<span class="admin-muted"
>{at("user_invitees_empty", {}, "Пользователь пока никого не пригласил")}</span
>
</AdminEmptyState>
{:else}
<ScrollArea class="admin-user-referrals-table-wrap" maxHeight="min(55vh, 460px)">
<AdminTable class="admin-user-referrals-table">
<thead>
<tr>
<th>{at("user_col_user", {}, "Пользователь")}</th>
<th>ID</th>
<th>{at("user_label_registration", {}, "Регистрация")}</th>
<th></th>
</tr>
</thead>
<tbody>
{#each userReferrals as invitee (invitee.user_id)}
<tr>
<td data-label={at("user_col_user", {}, "Пользователь")}>
<span class="admin-referral-user-cell">
<strong>{userDisplayName(invitee)}</strong>
<small>{userSecondaryName(invitee)}</small>
</span>
</td>
<td class="admin-cell-mono" data-label="ID">{invitee.user_id}</td>
<td data-label={at("user_label_registration", {}, "Регистрация")}>
{fmtDateShort(invitee.registration_date)}
</td>
<td class="admin-referral-user-actions">
<AdminButton
size="icon"
variant="icon"
title={at("user_open_related", {}, "Открыть карточку")}
aria-label={at("user_open_related", {}, "Открыть карточку")}
onclick={() => openRelatedUser(invitee)}
>
<ExternalLink size={14} />
</AdminButton>
</td>
</tr>
{/each}
</tbody>
</AdminTable>
</ScrollArea>
{/if}
{#if userReferralsTotal > userReferralsPageSize}
<AdminPagination
meta={at(
"pagination_meta",
{
current: userReferralsPage + 1,
total: Math.max(1, Math.ceil(userReferralsTotal / userReferralsPageSize)),
},
`${userReferralsPage + 1}/${Math.max(1, Math.ceil(userReferralsTotal / userReferralsPageSize))}`
)}
prevLabel={at("prev_page", {}, "Назад")}
nextLabel={at("next_page", {}, "Вперёд")}
prevDisabled={userReferralsLoading || userReferralsPage <= 0}
nextDisabled={userReferralsLoading || !userReferralsHasMore}
onPrev={() => usersStore.setUserReferralsPage(userReferralsPage - 1)}
onNext={() => usersStore.setUserReferralsPage(userReferralsPage + 1)}
/>
{/if}
</div>
</Dialog>
<Dialog
open={avatarPreviewOpen}
title={avatarPreviewName || at("user_avatar_title", {}, "Аватар")}
@@ -1159,6 +1307,62 @@
gap: 8px;
margin-top: 6px;
}
.admin-user-ref-row {
grid-template-columns: 130px minmax(0, 1fr) auto;
align-items: center;
}
.admin-user-ref-row :global(.admin-btn) {
flex: 0 0 auto;
}
.admin-user-ref-value {
display: grid;
gap: 2px;
min-width: 0;
}
.admin-user-ref-value small {
color: var(--admin-muted);
font-size: 11px;
font-weight: 500;
}
:global(.admin-user-referrals-dialog) {
width: min(760px, calc(100vw - 28px));
max-height: min(760px, calc(100dvh - 28px));
}
.admin-user-referrals-body {
display: grid;
gap: 12px;
min-width: 0;
}
:global(.admin-user-referrals-table-wrap) {
min-height: 120px;
}
.admin-referral-user-cell {
display: grid;
gap: 2px;
min-width: 0;
}
.admin-referral-user-cell strong {
color: var(--admin-text);
font-weight: 650;
word-break: break-word;
}
.admin-referral-user-cell small {
color: var(--admin-muted);
font-size: 12px;
word-break: break-word;
}
.admin-referral-user-actions {
text-align: right;
}
@media (max-width: 560px) {
.admin-user-ref-row {
grid-template-columns: minmax(0, 1fr) auto;
gap: 4px 8px;
}
.admin-user-ref-row > span {
grid-column: 1 / -1;
}
}
:global(.admin-avatar-dialog) {
display: grid;
grid-template-rows: auto minmax(0, 1fr);
+576 -36
View File
@@ -1,5 +1,15 @@
<script>
import { Input } from "$components/ui/index.js";
import {
ArrowDown,
ArrowUp,
ChevronsUpDown,
DollarSign,
Sliders,
X,
UsersRound,
} from "$components/ui/icons.js";
import Dialog from "$components/ui/dialog.svelte";
import { Label } from "$components/ui/primitives.js";
import {
AdminBadge,
@@ -15,6 +25,7 @@
export let at = (key) => key;
export let fmtDateShort = (value) => value;
export let fmtMoney = (value) => value;
export let panelStatusBadge = () => ({});
export let resolvedAvatarUrl = () => "";
export let userDisplayName = () => "";
@@ -36,6 +47,7 @@
} = $usersStore);
const USERS_PAGE_SIZE = 25;
let usersFilterSheetOpen = false;
$: usersHasMore = users.length === USERS_PAGE_SIZE;
const USERS_FILTER_OPTIONS = [
@@ -49,16 +61,31 @@
{ value: "panel_linked", label: at("filter_panel_linked", {}, "С панелью") },
];
const USERS_SORT_OPTIONS = [
{ value: "registered_desc", label: at("sort_registered_desc", {}, "Сначала новые") },
{ value: "registered_asc", label: at("sort_registered_asc", {}, "Сначала старые") },
{ value: "name_asc", label: at("sort_name_asc", {}, "Имя ↑") },
{ value: "name_desc", label: at("sort_name_desc", {}, "Имя ↓") },
{ value: "id_asc", label: at("sort_id_asc", {}, "ID ↑") },
{ value: "id_desc", label: at("sort_id_desc", {}, "ID ↓") },
{ value: "premium_ratio_asc", label: at("sort_premium_ratio_asc", {}, "Премиум % ↑") },
{ value: "premium_ratio_desc", label: at("sort_premium_ratio_desc", {}, "Премиум % ↓") },
];
const SORT_COLUMNS = {
user: { asc: "name_asc", desc: "name_desc", defaultDirection: "asc" },
premium: { asc: "premium_ratio_asc", desc: "premium_ratio_desc", defaultDirection: "desc" },
paymentsTotal: {
asc: "payments_total_asc",
desc: "payments_total_desc",
defaultDirection: "desc",
},
paymentsCount: {
asc: "payments_count_asc",
desc: "payments_count_desc",
defaultDirection: "desc",
},
invited: {
asc: "invited_users_count_asc",
desc: "invited_users_count_desc",
defaultDirection: "desc",
},
subscriptionExpires: {
asc: "subscription_expires_at_asc",
desc: "subscription_expires_at_desc",
defaultDirection: "asc",
},
registration: { asc: "registered_asc", desc: "registered_desc", defaultDirection: "desc" },
};
const USERS_PANEL_STATUS_OPTIONS = [
{ value: "all", label: at("panel_status_all", {}, "Все статусы") },
@@ -79,6 +106,29 @@
{ value: "critical", label: at("premium_traffic_filter_critical", {}, "Премиум: исчерпан") },
];
function optionLabel(options, value) {
return options.find((item) => item.value === value)?.label || value;
}
function updateUsersFilterState(patch) {
usersStore.updateState({ ...patch, usersPage: 0 });
usersStore.loadUsers();
}
function resetUsersFilters() {
updateUsersFilterState({
usersFilter: "all",
usersPanelStatus: "all",
usersPremiumTraffic: "all",
});
}
function clearUsersFilter(key) {
if (key === "usersFilter") updateUsersFilterState({ usersFilter: "all" });
if (key === "usersPanelStatus") updateUsersFilterState({ usersPanelStatus: "all" });
if (key === "usersPremiumTraffic") updateUsersFilterState({ usersPremiumTraffic: "all" });
}
/** @param {Record<string, unknown> | null | undefined} pt */
function premiumTrafficBadgeVariant(pt) {
if (!pt || pt.state === "none") return "muted";
@@ -94,18 +144,160 @@
return trafficOfLabel(pt.used_bytes, pt.limit_bytes);
}
$: userTableHeaders = [
at("user", {}, "Пользователь"),
at("premium_traffic_filter_label", {}, "Премиум трафик"),
at("status", {}, "Статус"),
at("users_col_registration", {}, "Регистрация"),
];
function userTableColumns() {
return [
{ key: "user", label: at("user", {}, "Пользователь"), sort: SORT_COLUMNS.user },
{
key: "premium",
label: at("premium_traffic_filter_label", {}, "Премиум трафик"),
sort: SORT_COLUMNS.premium,
},
{
key: "paymentsTotal",
label: at("users_col_payments_total", {}, "Сумма платежей"),
sort: SORT_COLUMNS.paymentsTotal,
},
{
key: "paymentsCount",
label: at("users_col_payments_count", {}, "Платежи"),
sort: SORT_COLUMNS.paymentsCount,
},
{
key: "invited",
label: at("users_col_invited", {}, "Приглашенные"),
sort: SORT_COLUMNS.invited,
},
{ key: "status", label: at("status", {}, "Статус") },
{
key: "subscriptionExpires",
label: at("users_col_subscription_expires", {}, "Истекает"),
sort: SORT_COLUMNS.subscriptionExpires,
},
{
key: "registration",
label: at("users_col_registration", {}, "Регистрация"),
sort: SORT_COLUMNS.registration,
},
];
}
function sortState(column) {
if (!column) return "none";
if (usersSort === column.asc) return "ascending";
if (usersSort === column.desc) return "descending";
return "none";
}
function nextSortValue(column) {
const state = sortState(column);
const defaultValue = column[column.defaultDirection] || column.asc;
if (state === "none") return defaultValue;
if (usersSort === defaultValue) {
return column.defaultDirection === "asc" ? column.desc : column.asc;
}
return "";
}
function toggleUsersSort(column) {
usersStore.updateState({ usersSort: nextSortValue(column), usersPage: 0 });
usersStore.loadUsers();
}
function sortTitle(column) {
const state = sortState(column);
if (state === "ascending") return at("sort_ascending", {}, "По возрастанию");
if (state === "descending") return at("sort_descending", {}, "По убыванию");
return at("sort_off", {}, "Без сортировки");
}
function rowPaymentsTotal(user) {
return fmtMoney(user?.payments_total_amount ?? 0, user?.payments_currency || "RUB");
}
$: activeUserFilterChips = [
usersFilter !== "all" && {
key: "usersFilter",
label: at("filter", {}, "Фильтр"),
value: optionLabel(USERS_FILTER_OPTIONS, usersFilter),
},
usersPanelStatus !== "all" && {
key: "usersPanelStatus",
label: at("panel_status", {}, "Статус панели"),
value: optionLabel(USERS_PANEL_STATUS_OPTIONS, usersPanelStatus),
},
usersPremiumTraffic !== "all" && {
key: "usersPremiumTraffic",
label: at("premium_traffic_filter_label", {}, "Премиум трафик"),
value: optionLabel(USERS_PREMIUM_TRAFFIC_OPTIONS, usersPremiumTraffic),
},
].filter(Boolean);
$: activeUsersFilterCount = activeUserFilterChips.length;
$: userTableHeaders = userTableColumns().map((column) => column.label);
onMount(() => {
usersStore.loadUsers();
});
</script>
{#snippet renderUserFilterControls()}
<Label.Root class="admin-toolbar-field admin-users-filter-field">
<span class="admin-toolbar-field-label">{at("filter", {}, "Фильтр")}</span>
<AdminSelect
value={usersFilter}
items={USERS_FILTER_OPTIONS}
class="admin-toolbar-select"
ariaLabel={at("filter", {}, "Фильтр")}
onValueChange={(value) => updateUsersFilterState({ usersFilter: value })}
/>
</Label.Root>
<Label.Root class="admin-toolbar-field admin-users-filter-field">
<span class="admin-toolbar-field-label">{at("panel_status", {}, "Статус панели")}</span>
<AdminSelect
value={usersPanelStatus}
items={USERS_PANEL_STATUS_OPTIONS}
class="admin-toolbar-select"
ariaLabel={at("panel_status", {}, "Статус панели")}
onValueChange={(value) => updateUsersFilterState({ usersPanelStatus: value })}
/>
</Label.Root>
<Label.Root class="admin-toolbar-field admin-users-filter-field">
<span class="admin-toolbar-field-label"
>{at("premium_traffic_filter_label", {}, "Премиум трафик")}</span
>
<AdminSelect
value={usersPremiumTraffic}
items={USERS_PREMIUM_TRAFFIC_OPTIONS}
class="admin-toolbar-select"
ariaLabel={at("premium_traffic_filter_label", {}, "Премиум трафик")}
onValueChange={(value) => updateUsersFilterState({ usersPremiumTraffic: value })}
/>
</Label.Root>
{/snippet}
{#snippet renderActiveUserFilterChips()}
{#if activeUsersFilterCount}
<div class="admin-users-filter-chips" aria-label={at("active_filters", {}, "Активные фильтры")}>
{#each activeUserFilterChips as chip (chip.key)}
<span class="admin-users-filter-chip">
<span class="admin-users-filter-chip-text">
<strong>{chip.label}</strong>
<span>{chip.value}</span>
</span>
<button
type="button"
aria-label={at("clear_filter", { label: chip.label }, "Сбросить фильтр")}
on:click={() => clearUsersFilter(chip.key)}
>
<X size={12} />
</button>
</span>
{/each}
</div>
{/if}
{/snippet}
<div class="admin-toolbar admin-toolbar-users">
<div class="admin-toolbar-search">
<Input
@@ -119,11 +311,28 @@
/>
<AdminButton
variant="primary"
class="admin-users-search-button"
onclick={() => {
usersStore.updateState({ usersPage: 0 });
usersStore.loadUsers();
}}>{at("find", {}, "Найти")}</AdminButton
>
<AdminButton
variant={activeUsersFilterCount ? "primary" : "default"}
class="admin-users-filter-toggle"
aria-label={at("users_filters_open", {}, "Открыть фильтры")}
aria-haspopup="dialog"
aria-expanded={usersFilterSheetOpen}
onclick={() => {
usersFilterSheetOpen = true;
}}
>
<Sliders size={15} />
<span class="admin-users-filter-toggle-label">{at("filters", {}, "Фильтры")}</span>
{#if activeUsersFilterCount}
<span class="admin-users-filter-count">{activeUsersFilterCount}</span>
{/if}
</AdminButton>
</div>
<div class="admin-toolbar-controls">
@@ -171,33 +380,56 @@
/>
</Label.Root>
<Label.Root class="admin-toolbar-field">
<span class="admin-toolbar-field-label">{at("sort", {}, "Сортировка")}</span>
<AdminSelect
value={usersSort}
items={USERS_SORT_OPTIONS}
class="admin-toolbar-select"
ariaLabel={at("sort", {}, "Сортировка")}
onValueChange={(value) => {
usersStore.updateState({ usersSort: value, usersPage: 0 });
usersStore.loadUsers();
}}
/>
</Label.Root>
<div class="admin-toolbar-summary">
<span class="admin-toolbar-field-label">{at("total", {}, "Всего")}</span>
<strong>{usersTotal}</strong>
</div>
</div>
{@render renderActiveUserFilterChips()}
</div>
<div class="admin-table-wrap admin-users-table-wrap">
<Dialog
open={usersFilterSheetOpen}
class="admin-dialog admin-users-filter-dialog"
title={at("users_filters_title", {}, "Фильтры пользователей")}
description={at("users_filters_description", {}, "Уточните список пользователей")}
closeLabel={at("close_menu", {}, "Закрыть меню")}
onclose={() => {
usersFilterSheetOpen = false;
}}
>
<div class="admin-users-filter-sheet-body">
<div class="admin-users-filter-fields admin-users-filter-fields-sheet">
{@render renderUserFilterControls()}
</div>
{@render renderActiveUserFilterChips()}
<div class="admin-users-filter-sheet-actions">
<AdminButton
variant="ghost"
disabled={activeUsersFilterCount === 0}
onclick={resetUsersFilters}
>
{at("reset", {}, "Сбросить")}
</AdminButton>
<AdminButton
variant="primary"
onclick={() => {
usersFilterSheetOpen = false;
}}
>
{at("done", {}, "Готово")}
</AdminButton>
</div>
</div>
</Dialog>
<div class="admin-users-table-wrap">
{#if usersLoading}
<AdminTableSkeleton
headers={userTableHeaders}
rows={USERS_PAGE_SIZE}
widths={["minmax(220px, 42%)", "minmax(140px, 28%)", "108px", "112px"]}
widths={["220px", "128px", "112px", "78px", "88px", "96px", "112px", "112px"]}
/>
{:else if !users.length}
<AdminEmptyState tone="card"
@@ -208,10 +440,35 @@
<AdminTable class="admin-users-table">
<thead>
<tr>
<th>{at("user", {}, "Пользователь")}</th>
<th>{at("premium_traffic_filter_label", {}, "Премиум трафик")}</th>
<th>{at("status", {}, "Статус")}</th>
<th>{at("users_col_registration", {}, "Регистрация")}</th>
{#each userTableColumns() as column (column.key)}
<th aria-sort={column.sort ? sortState(column.sort) : undefined}>
{#if column.sort}
<button
type="button"
class="admin-sort-header"
title={sortTitle(column.sort)}
on:click={() => toggleUsersSort(column.sort)}
>
<span>{column.label}</span>
<span
class="admin-sort-state"
data-state={sortState(column.sort)}
aria-hidden="true"
>
{#if sortState(column.sort) === "ascending"}
<ArrowUp size={13} />
{:else if sortState(column.sort) === "descending"}
<ArrowDown size={13} />
{:else}
<ChevronsUpDown size={13} />
{/if}
</span>
</button>
{:else}
{column.label}
{/if}
</th>
{/each}
</tr>
</thead>
<tbody>
@@ -264,9 +521,41 @@
>
{/if}
</td>
<td
class="admin-users-cell-money"
data-label={at("users_col_payments_total", {}, "Сумма платежей")}
>
<AdminBadge variant="success" class="admin-user-money-badge">
{rowPaymentsTotal(user)}
</AdminBadge>
</td>
<td
class="admin-users-cell-counter"
data-label={at("users_col_payments_count", {}, "Платежи")}
>
<span class="admin-user-counter">
<DollarSign size={12} />
<span>{user.payments_count ?? 0}</span>
</span>
</td>
<td
class="admin-users-cell-counter"
data-label={at("users_col_invited", {}, "Приглашенные")}
>
<span class="admin-user-counter">
<UsersRound size={13} />
<span>{user.invited_users_count ?? 0}</span>
</span>
</td>
<td data-label={at("status", {}, "Статус")}>
<AdminBadge variant={badge.variant}>{badge.label}</AdminBadge>
</td>
<td
class="admin-users-cell-date admin-cell-mono"
data-label={at("users_col_subscription_expires", {}, "Истекает")}
>
{fmtDateShort(user.subscription_expires_at || user.panel_status_expired_at)}
</td>
<td
class="admin-users-cell-date admin-cell-mono"
data-label={at("users_col_registration", {}, "Регистрация")}
@@ -297,6 +586,161 @@
/>
<style>
:global(.admin-toolbar-users .admin-toolbar-controls) {
grid-template-columns: repeat(3, minmax(150px, 1fr)) minmax(82px, auto);
gap: 10px;
}
:global(.admin-users-search-button) {
min-width: 82px;
}
:global(.admin-users-filter-toggle) {
display: none;
position: relative;
align-items: center;
gap: 7px;
min-width: 0;
}
.admin-users-filter-count {
display: inline-grid;
min-width: 18px;
height: 18px;
place-items: center;
padding: 0 5px;
border-radius: 999px;
background: color-mix(in srgb, var(--admin-bg) 74%, transparent);
color: inherit;
font-size: 11px;
font-weight: 750;
line-height: 1;
}
.admin-users-filter-chips {
display: flex;
flex-wrap: wrap;
gap: 6px;
min-width: 0;
}
.admin-users-filter-chip {
display: inline-flex;
align-items: center;
gap: 7px;
max-width: 100%;
min-height: 28px;
padding: 3px 5px 3px 10px;
border: 1px solid var(--admin-border);
border-radius: 999px;
background: color-mix(in srgb, var(--admin-muted) 8%, transparent);
color: var(--admin-text);
font-size: 12px;
}
.admin-users-filter-chip-text {
display: inline-flex;
align-items: center;
gap: 5px;
min-width: 0;
max-width: 260px;
}
.admin-users-filter-chip strong {
color: var(--admin-muted);
font-size: 11px;
font-weight: 650;
}
.admin-users-filter-chip-text > span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.admin-users-filter-chip button {
display: inline-grid;
width: 20px;
height: 20px;
place-items: center;
border: 0;
border-radius: 999px;
background: transparent;
color: var(--admin-muted);
cursor: pointer;
}
.admin-users-filter-chip button:hover,
.admin-users-filter-chip button:focus-visible {
background: color-mix(in srgb, var(--admin-muted) 14%, transparent);
color: var(--admin-text);
outline: none;
}
.admin-users-filter-fields-sheet,
.admin-users-filter-sheet-body {
display: grid;
gap: 12px;
}
.admin-users-filter-sheet-actions {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
gap: 8px;
padding-top: 2px;
}
:global(.admin-users-filter-dialog) {
width: min(100%, 420px);
}
.admin-users-table-wrap :global(.admin-table-wrap) {
overflow-x: auto;
}
.admin-users-table-wrap :global(.admin-users-table) {
min-width: 1080px;
}
.admin-sort-header {
display: inline-flex;
align-items: center;
gap: 5px;
max-width: 100%;
margin: -4px -6px;
padding: 4px 6px;
border: 0;
border-radius: 6px;
background: transparent;
color: inherit;
font: inherit;
letter-spacing: inherit;
text-transform: inherit;
cursor: pointer;
}
.admin-sort-header:hover,
.admin-sort-header:focus-visible {
color: var(--admin-text);
background: color-mix(in srgb, var(--admin-muted) 10%, transparent);
outline: none;
}
.admin-sort-header:focus-visible {
box-shadow: 0 0 0 2px var(--admin-ring);
}
.admin-sort-state {
display: inline-flex;
align-items: center;
color: var(--admin-dim);
}
.admin-sort-state[data-state="ascending"],
.admin-sort-state[data-state="descending"] {
color: color-mix(in srgb, var(--accent) 72%, var(--admin-muted));
}
.admin-users-cell-user-inner {
display: flex;
align-items: center;
@@ -353,6 +797,30 @@
font-variant-numeric: tabular-nums;
}
.admin-users-cell-money,
.admin-users-cell-counter {
white-space: nowrap;
}
.admin-users-cell-money :global(.admin-user-money-badge) {
font-variant-numeric: tabular-nums;
}
.admin-user-counter {
display: inline-flex;
align-items: center;
gap: 5px;
color: var(--admin-text);
font-size: 12px;
font-weight: 650;
font-variant-numeric: tabular-nums;
}
.admin-user-counter :global(svg) {
color: var(--admin-muted);
flex: 0 0 auto;
}
.admin-users-cell-date {
white-space: nowrap;
font-size: 12px;
@@ -363,4 +831,76 @@
outline: 2px solid var(--admin-ring);
outline-offset: -2px;
}
@media (max-width: 720px) {
:global(.admin-toolbar-users .admin-toolbar-search) {
grid-template-columns: minmax(0, 1fr) auto auto;
}
:global(.admin-toolbar-users .admin-toolbar-controls) {
display: none;
}
:global(.admin-users-search-button) {
min-width: 0;
padding-inline: 10px;
}
:global(.admin-users-filter-toggle) {
display: inline-flex;
min-width: 38px;
padding-inline: 10px;
}
.admin-users-filter-toggle-label {
display: none;
}
.admin-users-filter-chips {
gap: 5px;
}
.admin-users-filter-chip-text {
max-width: min(250px, calc(100vw - 96px));
}
:global(.dialog:has(.admin-users-filter-dialog)) {
align-items: end;
padding: max(12px, env(safe-area-inset-top)) 0 0;
}
:global(.admin-users-filter-dialog) {
width: 100%;
max-height: min(82dvh, 620px);
padding: 16px;
border-right: 0;
border-bottom: 0;
border-left: 0;
border-radius: 18px 18px 0 0;
}
.admin-users-table-wrap :global(.admin-users-table thead) {
display: table-header-group;
}
.admin-users-table-wrap :global(.admin-users-table tbody tr) {
display: table-row;
padding: 0;
border-bottom: 0;
}
.admin-users-table-wrap :global(.admin-users-table tbody tr:last-child td) {
border-bottom: 0;
}
.admin-users-table-wrap :global(.admin-users-table tbody td) {
display: table-cell;
padding: 12px 16px;
border-bottom: 1px solid var(--admin-border);
}
.admin-users-table-wrap :global(.admin-users-table tbody td::before) {
content: none;
}
}
</style>
@@ -12,6 +12,7 @@ export function createBroadcastStore({ api, onToast, at }) {
{ value: "all", label: at("broadcast_target_all", {}, "Все активные") },
{ value: "active", label: at("broadcast_target_active", {}, "С подпиской") },
{ value: "inactive", label: at("broadcast_target_inactive", {}, "Без подписки") },
{ value: "expired", label: at("broadcast_target_expired", {}, "Expired subscription") },
];
async function runBroadcast() {
+32 -4
View File
@@ -4,6 +4,7 @@ import {
cloneCatalog,
draftFromTariff,
tariffFromDraft as tariffFromDraftFn,
normalizeCurrencyKey,
normalizeUuidList,
} from "../tariffDraft.js";
@@ -11,6 +12,7 @@ export function createTariffsStore({ api, onTariffsSaved, flash, at }) {
const state = writable({
tariffsCatalog: {
default_tariff: "",
default_currency: "rub",
topup_packages_default: { rub: [], stars: [] },
tariffs: [],
},
@@ -23,13 +25,15 @@ export function createTariffsStore({ api, onTariffsSaved, flash, at }) {
tariffDeleteTarget: null,
tariffDraft: emptyTariffDraft(),
panelSquads: [],
providerCurrencySupport: [],
panelSquadsLoading: false,
selectedBaseSquad: "",
selectedPremiumSquad: "",
tariffEditorTab: "general",
});
const tariffFromDraft = (draft) => tariffFromDraftFn(draft);
const tariffFromDraft = (draft, defaultCurrency = "rub") =>
tariffFromDraftFn(draft, defaultCurrency);
async function loadTariffs() {
state.update((s) => ({ ...s, tariffsLoading: true }));
@@ -41,6 +45,7 @@ export function createTariffsStore({ api, onTariffsSaved, flash, at }) {
...s,
tariffsCatalog: cloneCatalog(data.catalog),
tariffsPath: data.path || "",
providerCurrencySupport: data.provider_currency_support || [],
}));
} else {
flash(data?.message || data?.error || at("load_failed", {}, "Не удалось загрузить тарифы"));
@@ -119,6 +124,7 @@ export function createTariffsStore({ api, onTariffsSaved, flash, at }) {
...s,
tariffsCatalog: cloneCatalog(res.catalog),
tariffsPath: res.path || currentPath,
providerCurrencySupport: res.provider_currency_support || s.providerCurrencySupport || [],
tariffEditorOpen: false,
tariffDeleteOpen: false,
tariffDeleteTarget: null,
@@ -139,7 +145,10 @@ export function createTariffsStore({ api, onTariffsSaved, flash, at }) {
state.update((s) => ({
...s,
tariffEditingKey: "",
tariffDraft: emptyTariffDraft(),
tariffDraft: {
...emptyTariffDraft(),
defaultCurrency: s.tariffsCatalog.default_currency || "rub",
},
tariffEditorTab: "general",
selectedBaseSquad: "",
selectedPremiumSquad: "",
@@ -151,7 +160,7 @@ export function createTariffsStore({ api, onTariffsSaved, flash, at }) {
state.update((s) => ({
...s,
tariffEditingKey: tariff.key,
tariffDraft: draftFromTariff(tariff),
tariffDraft: draftFromTariff(tariff, s.tariffsCatalog.default_currency || "rub"),
tariffEditorTab: "general",
selectedBaseSquad: "",
selectedPremiumSquad: "",
@@ -165,7 +174,7 @@ export function createTariffsStore({ api, onTariffsSaved, flash, at }) {
s = st;
return st;
});
const tariff = tariffFromDraft(s.tariffDraft);
const tariff = tariffFromDraft(s.tariffDraft, s.tariffsCatalog.default_currency || "rub");
if (!tariff.key) {
flash(at("tariff_error_key_required", {}, "Укажите ключ тарифа"));
return;
@@ -233,6 +242,24 @@ export function createTariffsStore({ api, onTariffsSaved, flash, at }) {
);
}
async function setDefaultCurrency(value) {
const currency = normalizeCurrencyKey(value || "rub");
if (!currency || currency === "stars") {
flash(at("tariff_currency_invalid", {}, "Укажите фиатную или криптовалюту, но не Stars"));
return;
}
let s;
state.update((st) => {
s = st;
return st;
});
if (currency === normalizeCurrencyKey(s.tariffsCatalog.default_currency || "rub")) return;
await persistTariffs(
{ ...cloneCatalog(s.tariffsCatalog), default_currency: currency },
at("tariff_currency_updated", {}, "Валюта оплаты обновлена")
);
}
async function deleteTariff() {
let s;
state.update((st) => {
@@ -295,6 +322,7 @@ export function createTariffsStore({ api, onTariffsSaved, flash, at }) {
saveTariffDraft,
toggleTariffEnabled,
setDefaultTariff,
setDefaultCurrency,
deleteTariff,
addDraftRow,
removeDraftRow,
+81 -4
View File
@@ -13,7 +13,7 @@ export function createUsersStore({ api, onToast, at, routePrefix = "" }) {
usersFilter: "all",
usersPanelStatus: "all",
usersPremiumTraffic: "all",
usersSort: "registered_desc",
usersSort: "",
usersLoading: false,
openedUser: null,
@@ -25,6 +25,13 @@ export function createUsersStore({ api, onToast, at, routePrefix = "" }) {
userDeleteOpen: false,
userBanConfirmOpen: false,
userMessageConfirmOpen: false,
userReferralsOpen: false,
userReferralsLoading: false,
userReferrals: [],
userReferralsTotal: 0,
userReferralsPage: 0,
userReferralsPageSize: USERS_PAGE_SIZE,
userReferralsInviter: null,
userDetailTab: "profile",
premiumUnlimitedDraft: false,
premiumBonusGbDraft: "",
@@ -96,7 +103,7 @@ export function createUsersStore({ api, onToast, at, routePrefix = "" }) {
if (s.usersPremiumTraffic && s.usersPremiumTraffic !== "all") {
params.set("premium_traffic", s.usersPremiumTraffic);
}
if (s.usersSort && s.usersSort !== "registered_desc") params.set("sort", s.usersSort);
if (s.usersSort) params.set("sort", s.usersSort);
const data = await api(`/admin/users?${params.toString()}`);
if (data?.ok) {
state.update((st) => ({
@@ -126,6 +133,12 @@ export function createUsersStore({ api, onToast, at, routePrefix = "" }) {
userExtendDays: 30,
userDetailLoading: true,
userDetailTab: "subscription",
userReferralsOpen: false,
userReferralsLoading: false,
userReferrals: [],
userReferralsTotal: 0,
userReferralsPage: 0,
userReferralsInviter: null,
userLogs: [],
userLogsTotal: 0,
userLogsPage: 0,
@@ -175,6 +188,12 @@ export function createUsersStore({ api, onToast, at, routePrefix = "" }) {
userDeleteOpen: false,
userBanConfirmOpen: false,
userMessageConfirmOpen: false,
userReferralsOpen: false,
userReferralsLoading: false,
userReferrals: [],
userReferralsTotal: 0,
userReferralsPage: 0,
userReferralsInviter: null,
userLogs: [],
userLogsTotal: 0,
userLogsPage: 0,
@@ -231,6 +250,58 @@ export function createUsersStore({ api, onToast, at, routePrefix = "" }) {
loadUserLogs(page);
}
async function openUserReferrals(page = 0) {
let s;
state.update((st) => {
s = st;
return st;
});
if (!s.openedUser) return;
const userId = s.openedUser.user_id;
const targetPage = Number.isFinite(page) ? Math.max(0, Math.floor(page)) : 0;
state.update((st) => ({
...st,
userReferralsOpen: true,
userReferralsLoading: true,
userReferralsPage: targetPage,
}));
try {
const params = new URLSearchParams({
page: String(targetPage),
page_size: String(s.userReferralsPageSize || USERS_PAGE_SIZE),
});
const data = await api(`/admin/users/${userId}/referrals?${params.toString()}`);
if (data?.ok) {
state.update((st) => {
if (!st.openedUser || st.openedUser.user_id !== userId) return st;
return {
...st,
userReferrals: data.invitees || [],
userReferralsTotal: Number(data.total || 0),
userReferralsPage: Number(data.page || 0),
userReferralsPageSize: Number(data.page_size || st.userReferralsPageSize),
userReferralsInviter: data.inviter || null,
};
});
} else if (data?.error) {
onToast(data.error);
}
} finally {
state.update((st) => ({ ...st, userReferralsLoading: false }));
}
}
function closeUserReferrals() {
state.update((s) => ({
...s,
userReferralsOpen: false,
}));
}
function setUserReferralsPage(page) {
openUserReferrals(page);
}
function copyToClipboard(text, successMessage = at("link_copied", {}, "Скопировано")) {
if (!text) return;
if (typeof navigator !== "undefined" && navigator?.clipboard?.writeText) {
@@ -398,8 +469,11 @@ export function createUsersStore({ api, onToast, at, routePrefix = "" }) {
state.update((st) => ({ ...st, userActionBusy: true }));
try {
const res = await api(`/admin/users/${s.openedUser.user_id}/reset-trial`, { method: "POST" });
if (res?.ok) onToast(at("trial_reset", {}, "Триал сброшен"));
else onToast(res?.error || at("error", {}, "Ошибка"));
if (res?.ok) {
onToast(at("trial_reset", {}, "Триал сброшен"));
await openUser(s.openedUser.user_id, { skipPush: true, pathContext: _pathContext });
if (_activeRef === "users") await loadUsers();
} else onToast(res?.error || at("error", {}, "Ошибка"));
} finally {
state.update((st) => ({ ...st, userActionBusy: false }));
}
@@ -564,5 +638,8 @@ export function createUsersStore({ api, onToast, at, routePrefix = "" }) {
grantTraffic,
loadUserLogs,
setUserLogsPage,
openUserReferrals,
closeUserReferrals,
setUserReferralsPage,
};
}
+62 -16
View File
@@ -2,6 +2,7 @@ import { structuredCloneSafe } from "./format.js";
export function emptyTariffDraft() {
return {
defaultCurrency: "rub",
key: "",
nameRu: "",
nameEn: "",
@@ -40,11 +41,22 @@ export function emptyTariffDraft() {
export function cloneCatalog(catalog) {
return structuredCloneSafe({
default_tariff: catalog?.default_tariff || "",
default_currency: normalizeCurrencyKey(catalog?.default_currency || "rub"),
topup_packages_default: catalog?.topup_packages_default || { rub: [], stars: [] },
tariffs: catalog?.tariffs || [],
});
}
export function normalizeCurrencyKey(value, fallback = "rub") {
const text = String(value || "")
.trim()
.toLowerCase();
if (!text) return fallback;
if (text === "rur") return "rub";
if (["xtr", "star", "stars"].includes(text)) return "stars";
return text.replace(/[^a-z0-9_-]/g, "") || fallback;
}
export function rowsFromPackages(packageSet, currency, valueKey) {
return (packageSet?.[currency] || []).map((pkg) => ({
[valueKey]: pkg[valueKey],
@@ -54,10 +66,13 @@ export function rowsFromPackages(packageSet, currency, valueKey) {
}));
}
export function draftFromTariff(tariff) {
export function draftFromTariff(tariff, defaultCurrency = "rub") {
const currency = normalizeCurrencyKey(defaultCurrency);
const defaultPrices = tariff.prices?.[currency] || {};
const months = new Set([
...(tariff.enabled_periods || []),
...Object.keys(tariff.prices_rub || {}).map(Number),
...Object.keys(defaultPrices).map(Number),
...(currency === "rub" ? Object.keys(tariff.prices_rub || {}).map(Number) : []),
...Object.keys(tariff.prices_stars || {}).map(Number),
]);
const periodRows = [...months]
@@ -65,7 +80,10 @@ export function draftFromTariff(tariff) {
.sort((a, b) => a - b)
.map((month) => ({
months: month,
rub: tariff.prices_rub?.[String(month)] ?? "",
rub:
(currency === "rub" ? tariff.prices_rub?.[String(month)] : undefined) ??
defaultPrices?.[String(month)] ??
"",
stars: tariff.prices_stars?.[String(month)] ?? "",
referral_inviter: tariff.referral_bonus_days_inviter?.[String(month)] ?? "",
referral_referee: tariff.referral_bonus_days_referee?.[String(month)] ?? "",
@@ -73,6 +91,7 @@ export function draftFromTariff(tariff) {
return {
...emptyTariffDraft(),
defaultCurrency: currency,
key: tariff.key || "",
nameRu: tariff.names?.ru || "",
nameEn: tariff.names?.en || "",
@@ -89,13 +108,13 @@ export function draftFromTariff(tariff) {
hwid_device_limit: tariff.hwid_device_limit ?? "",
conversion_rate_rub_per_gb: tariff.conversion_rate_rub_per_gb ?? "",
periodRows: periodRows.length ? periodRows : emptyTariffDraft().periodRows,
topupRubRows: rowsFromPackages(tariff.topup_packages, "rub", "gb"),
topupRubRows: rowsFromPackages(tariff.topup_packages, currency, "gb"),
topupStarsRows: rowsFromPackages(tariff.topup_packages, "stars", "gb"),
premiumTopupRubRows: rowsFromPackages(tariff.premium_topup_packages, "rub", "gb"),
premiumTopupRubRows: rowsFromPackages(tariff.premium_topup_packages, currency, "gb"),
premiumTopupStarsRows: rowsFromPackages(tariff.premium_topup_packages, "stars", "gb"),
trafficRubRows: rowsFromPackages(tariff.traffic_packages, "rub", "gb"),
trafficRubRows: rowsFromPackages(tariff.traffic_packages, currency, "gb"),
trafficStarsRows: rowsFromPackages(tariff.traffic_packages, "stars", "gb"),
hwidRubRows: rowsFromPackages(tariff.hwid_device_packages, "rub", "count"),
hwidRubRows: rowsFromPackages(tariff.hwid_device_packages, currency, "count"),
hwidStarsRows: rowsFromPackages(tariff.hwid_device_packages, "stars", "count"),
};
}
@@ -136,10 +155,15 @@ export function packagesFromRows(rows, valueKey) {
.filter((row) => row[valueKey] > 0 && row.price !== null && row.price >= 0);
}
export function packageSetFromRows(rubRows, starsRows, valueKey) {
const rub = packagesFromRows(rubRows, valueKey);
export function packageSetFromRows(rubRows, starsRows, valueKey, defaultCurrency = "rub") {
const currency = normalizeCurrencyKey(defaultCurrency);
const defaultCurrencyPackages = packagesFromRows(rubRows, valueKey);
const stars = packagesFromRows(starsRows, valueKey);
return rub.length || stars.length ? { rub, stars } : null;
if (!defaultCurrencyPackages.length && !stars.length) return null;
return {
...(defaultCurrencyPackages.length ? { [currency]: defaultCurrencyPackages } : {}),
...(stars.length ? { stars } : {}),
};
}
export function normalizeUuidList(value) {
@@ -150,7 +174,8 @@ export function normalizeUuidList(value) {
.filter(Boolean);
}
export function tariffFromDraft(draft) {
export function tariffFromDraft(draft, fallbackCurrency = "rub") {
const defaultCurrency = normalizeCurrencyKey(draft.defaultCurrency || fallbackCurrency);
const key = draft.key.trim();
const names = compactMap({ ru: draft.nameRu.trim(), en: draft.nameEn.trim() });
const descriptions = compactMap({
@@ -174,14 +199,20 @@ export function tariffFromDraft(draft) {
const hwidLimit = parseIntNumber(draft.hwid_device_limit);
if (hwidLimit !== null) tariff.hwid_device_limit = hwidLimit;
const hwidPackages = packageSetFromRows(draft.hwidRubRows, draft.hwidStarsRows, "count");
const hwidPackages = packageSetFromRows(
draft.hwidRubRows,
draft.hwidStarsRows,
"count",
defaultCurrency
);
if (hwidPackages) tariff.hwid_device_packages = hwidPackages;
const premiumMonthlyGb = parseNumber(draft.premium_monthly_gb);
if (premiumMonthlyGb !== null) tariff.premium_monthly_gb = premiumMonthlyGb;
const premiumTopupPackages = packageSetFromRows(
draft.premiumTopupRubRows,
draft.premiumTopupStarsRows,
"gb"
"gb",
defaultCurrency
);
if (premiumTopupPackages) tariff.premium_topup_packages = premiumTopupPackages;
@@ -204,7 +235,12 @@ export function tariffFromDraft(draft) {
.sort((a, b) => a.months - b.months);
tariff.monthly_gb = parseNumber(draft.monthly_gb, 0);
tariff.enabled_periods = rows.map((row) => row.months);
tariff.prices_rub = Object.fromEntries(rows.map((row) => [String(row.months), row.rub || 0]));
const defaultPrices = Object.fromEntries(rows.map((row) => [String(row.months), row.rub || 0]));
if (defaultCurrency === "rub") {
tariff.prices_rub = defaultPrices;
} else {
tariff.prices = { [defaultCurrency]: defaultPrices };
}
tariff.prices_stars = Object.fromEntries(
rows.map((row) => [String(row.months), row.stars || 0])
);
@@ -218,10 +254,20 @@ export function tariffFromDraft(draft) {
.filter((row) => row.referral_referee !== null)
.map((row) => [String(row.months), row.referral_referee])
);
const topupPackages = packageSetFromRows(draft.topupRubRows, draft.topupStarsRows, "gb");
const topupPackages = packageSetFromRows(
draft.topupRubRows,
draft.topupStarsRows,
"gb",
defaultCurrency
);
if (topupPackages) tariff.topup_packages = topupPackages;
} else {
const trafficPackages = packageSetFromRows(draft.trafficRubRows, draft.trafficStarsRows, "gb");
const trafficPackages = packageSetFromRows(
draft.trafficRubRows,
draft.trafficStarsRows,
"gb",
defaultCurrency
);
if (trafficPackages) tariff.traffic_packages = trafficPackages;
const conversion = parseNumber(draft.conversion_rate_rub_per_gb);
if (conversion !== null) tariff.conversion_rate_rub_per_gb = conversion;
@@ -7,7 +7,10 @@
export let ariaLabel = "";
export let placeholder = "";
export let disabled = false;
export let side = "bottom";
export let align = "start";
export let sideOffset = 6;
export let collisionPadding = 12;
export let onValueChange = () => {};
let className = "";
export { className as class };
@@ -29,7 +32,7 @@
<ChevronDown size={14} class="admin-select-icon" />
</Select.Trigger>
<Select.Portal>
<Select.Content class="admin-select-content" {sideOffset}>
<Select.Content class="admin-select-content" {side} {align} {sideOffset} {collisionPadding}>
<Select.Viewport class="admin-select-viewport">
{#each items as item (item.value)}
<Select.Item value={item.value} label={item.label} class="admin-select-item">
+1 -1
View File
@@ -66,7 +66,7 @@
<X size={18} />
</Button>
</div>
<ScrollArea class="dialog-body-scroll" maxHeight="none">
<ScrollArea class="dialog-body-scroll scroll-area--dialog" maxHeight="none">
<slot />
</ScrollArea>
</section>
+3
View File
@@ -1,7 +1,9 @@
export {
Activity,
ArrowLeft,
ArrowDown,
ArrowRight,
ArrowUp,
Bitcoin,
CalendarDays,
Check,
@@ -19,6 +21,7 @@ export {
CreditCard,
Crown,
Database,
DollarSign,
Download,
ExternalLink,
Eye,
@@ -36,6 +36,10 @@
overflow: hidden;
}
:global(.scroll-area--dialog) {
overflow: visible;
}
:global(.scroll-area__viewport) {
width: 100%;
height: 100%;
@@ -56,6 +60,10 @@
width: 10px;
}
:global(.scroll-area--dialog .scroll-area__scrollbar[data-orientation="vertical"]) {
transform: translateX(12px);
}
:global(.scroll-area__scrollbar[data-orientation="horizontal"]) {
flex-direction: column;
height: 10px;
+21 -21
View File
@@ -82665,6 +82665,7 @@ export const DEMO_DATASET = {
paid_subscriptions: 177,
trial_users: 0,
inactive_users: 193,
expired_subscription_users: 97,
referral_users: 106,
},
financial: {
@@ -104957,10 +104958,9 @@ export const DEMO_DATASET = {
updated_by: null,
},
en: {
base: "Unlimited-style ceiling and a persistent bonus on the main traffic limit.",
base: "Unlimited access and a persistent bonus on the main traffic limit.",
fallback: "Режим безлимита и постоянный бонус к лимиту основного трафика.",
effective:
"Unlimited-style ceiling and a persistent bonus on the main traffic limit.",
effective: "Unlimited access and a persistent bonus on the main traffic limit.",
override: "",
overridden: false,
updated_at: null,
@@ -105241,22 +105241,22 @@ export const DEMO_DATASET = {
audience: "internal",
values: {
ru: {
base: "Дополнительный лимит основного и премиум-трафика поверх тарифа; безлимит только для премиум-сквадов.",
base: "Дополнительный лимит основного и премиум-трафика поверх тарифа; безлимит можно включить для основного трафика или премиум-сквадов.",
fallback:
"Дополнительный лимит основного и премиум-трафика поверх тарифа; безлимит только для премиум-сквадов.",
"Дополнительный лимит основного и премиум-трафика поверх тарифа; безлимит можно включить для основного трафика или премиум-сквадов.",
effective:
"Дополнительный лимит основного и премиум-трафика поверх тарифа; безлимит только для премиум-сквадов.",
"Дополнительный лимит основного и премиум-трафика поверх тарифа; безлимит можно включить для основного трафика или премиум-сквадов.",
override: "",
overridden: false,
updated_at: null,
updated_by: null,
},
en: {
base: "Extra main or premium traffic limits on top of the tariff; unlimited applies to premium squads only.",
base: "Extra main or premium traffic limits on top of the tariff; unlimited can be applied to main traffic or premium squads.",
fallback:
"Дополнительный лимит основного и премиум-трафика поверх тарифа; безлимит только для премиум-сквадов.",
"Дополнительный лимит основного и премиум-трафика поверх тарифа; безлимит можно включить для основного трафика или премиум-сквадов.",
effective:
"Extra main or premium traffic limits on top of the tariff; unlimited applies to premium squads only.",
"Extra main or premium traffic limits on top of the tariff; unlimited can be applied to main traffic or premium squads.",
override: "",
overridden: false,
updated_at: null,
@@ -134112,18 +134112,18 @@ export const DEMO_DATASET = {
audience: "user",
values: {
ru: {
base: "Докупить премиум-трафик (мини-приложение)",
fallback: "Докупить премиум-трафик (мини-приложение)",
effective: "Докупить премиум-трафик (мини-приложение)",
base: "Докупить премиум-трафик",
fallback: "Докупить премиум-трафик",
effective: "Докупить премиум-трафик",
override: "",
overridden: false,
updated_at: null,
updated_by: null,
},
en: {
base: "Top up premium traffic (mini app)",
fallback: "Докупить премиум-трафик (мини-приложение)",
effective: "Top up premium traffic (mini app)",
base: "Top up premium traffic",
fallback: "Докупить премиум-трафик",
effective: "Top up premium traffic",
override: "",
overridden: false,
updated_at: null,
@@ -134136,18 +134136,18 @@ export const DEMO_DATASET = {
audience: "user",
values: {
ru: {
base: "Докупить трафик (мини-приложение)",
fallback: "Докупить трафик (мини-приложение)",
effective: "Докупить трафик (мини-приложение)",
base: "Докупить трафик",
fallback: "Докупить трафик",
effective: "Докупить трафик",
override: "",
overridden: false,
updated_at: null,
updated_by: null,
},
en: {
base: "Top up traffic (mini app)",
fallback: "Докупить трафик (мини-приложение)",
effective: "Top up traffic (mini app)",
base: "Top up traffic",
fallback: "Докупить трафик",
effective: "Top up traffic",
override: "",
overridden: false,
updated_at: null,
+151 -50
View File
@@ -401,16 +401,82 @@ function userName(user) {
);
}
function demoUserSeed(user) {
return Math.abs(Number(user?.user_id || user?.telegram_id || 0)) || 1;
}
function demoFutureIso(user, offsetDays = 30) {
const seed = demoUserSeed(user);
const base = Date.parse(user?.registration_date || "") || Date.UTC(2026, 0, 1);
return new Date(base + (offsetDays + (seed % 180)) * 86400000).toISOString();
}
function withDemoAdminUserMetrics(user) {
const seed = demoUserSeed(user);
const paymentsCount =
user.payments_count ?? (user.panel_status === "bot_only" ? 0 : Math.max(1, seed % 9));
const paymentsTotal = user.payments_total_amount ?? paymentsCount * (290 + (seed % 11) * 75);
const invitedCount = user.invited_users_count ?? (seed % 5 === 0 ? seed % 8 : seed % 3);
const subscriptionExpiresAt =
user.subscription_expires_at ??
user.panel_status_expired_at ??
(user.panel_status === "active" ? demoFutureIso(user, 45) : null);
return {
...user,
payments_total_amount: paymentsTotal,
payments_count: paymentsCount,
payments_currency: user.payments_currency || "RUB",
invited_users_count: invitedCount,
subscription_expires_at: subscriptionExpiresAt,
};
}
function compareNullableDate(a, b, direction = "asc") {
const at = stringDate(a);
const bt = stringDate(b);
if (!at && !bt) return 0;
if (!at) return 1;
if (!bt) return -1;
return direction === "desc" ? bt - at : at - bt;
}
function withDemoAvatars(users, size = 96) {
return (users || []).map((user) => withDemoAvatar(user, size));
}
function demoAdminUserById(userId) {
return (DEMO_DATASET.adminUsers || []).find((user) => Number(user.user_id) === Number(userId));
}
function demoInviteesForUser(userId) {
return (DEMO_DATASET.adminUsers || [])
.filter((user) => Number(user.referred_by_id) === Number(userId))
.sort((a, b) => stringDate(b.registration_date) - stringDate(a.registration_date));
}
function withDemoReferralSummary(detail) {
if (!detail || typeof detail !== "object") return detail;
const decorated = withDemoAvatarDetail(detail);
const user = decorated.user || {};
const inviter = user.referred_by_id ? demoAdminUserById(user.referred_by_id) : null;
const invitees = demoInviteesForUser(user.user_id);
return {
...decorated,
referral: {
...(decorated.referral || {}),
inviter: inviter ? withDemoAvatar(inviter) : null,
invitees_total: invitees.length,
},
};
}
function withDemoAvatarTickets(tickets, size = 96) {
return (tickets || []).map((ticket) => withDemoAvatarTicket(ticket, size));
}
function filterDemoUsers(params) {
let out = [...(DEMO_DATASET.adminUsers || [])];
let out = (DEMO_DATASET.adminUsers || []).map(withDemoAdminUserMetrics);
const q = (params.get("q") || params.get("search") || "").trim().toLowerCase();
if (q) {
out = out.filter((user) =>
@@ -457,6 +523,22 @@ function filterDemoUsers(params) {
return Number(a.premium_traffic?.percent ?? -1) - Number(b.premium_traffic?.percent ?? -1);
if (sort === "premium_ratio_desc")
return Number(b.premium_traffic?.percent ?? -1) - Number(a.premium_traffic?.percent ?? -1);
if (sort === "payments_total_asc")
return Number(a.payments_total_amount || 0) - Number(b.payments_total_amount || 0);
if (sort === "payments_total_desc")
return Number(b.payments_total_amount || 0) - Number(a.payments_total_amount || 0);
if (sort === "payments_count_asc")
return Number(a.payments_count || 0) - Number(b.payments_count || 0);
if (sort === "payments_count_desc")
return Number(b.payments_count || 0) - Number(a.payments_count || 0);
if (sort === "invited_users_count_asc")
return Number(a.invited_users_count || 0) - Number(b.invited_users_count || 0);
if (sort === "invited_users_count_desc")
return Number(b.invited_users_count || 0) - Number(a.invited_users_count || 0);
if (sort === "subscription_expires_at_asc")
return compareNullableDate(a.subscription_expires_at, b.subscription_expires_at, "asc");
if (sort === "subscription_expires_at_desc")
return compareNullableDate(a.subscription_expires_at, b.subscription_expires_at, "desc");
return stringDate(b.registration_date) - stringDate(a.registration_date);
});
@@ -610,8 +692,21 @@ function demoApiResponse(path, cleanPath, options, context) {
const id = Number(parts[3]);
const detail = DEMO_DATASET.adminUserDetails?.[String(id)];
if (!detail) return { ok: false, error: "not_found" };
const decoratedDetail = withDemoAvatarDetail(detail);
const decoratedDetail = withDemoReferralSummary(detail);
if (parts[4]) {
if (parts[4] === "referrals") {
const invitees = demoInviteesForUser(id);
const page = paged(invitees, params, 25);
return {
ok: true,
user: clone(decoratedDetail.user),
inviter: clone(decoratedDetail.referral?.inviter || null),
invitees: clone(withDemoAvatars(page.items)),
total: page.total,
page: page.page,
page_size: page.pageSize,
};
}
if (parts[4] === "telegram-profile-link") {
return { ok: true, url: `https://t.me/${detail.user?.username || "demo_user"}` };
}
@@ -937,56 +1032,61 @@ export async function mockApi(path, options = {}, context = {}) {
normalizeLangCode,
});
if (demoResponse !== undefined) return demoResponse;
const adminUsers = withDemoAvatars([
{
user_id: 100200300,
telegram_id: 100200300,
username: "anna_ops",
first_name: "Анна",
last_name: "Смирнова",
email: "anna@example.com",
telegram_photo_url: "",
registration_date: "2026-04-24T10:20:00Z",
is_banned: false,
premium_traffic: {
state: "good",
unlimited: false,
used_bytes: 4 * 1073741824,
limit_bytes: 25 * 1073741824,
percent: 16,
const adminUsers = withDemoAvatars(
[
{
user_id: 100200300,
telegram_id: 100200300,
username: "anna_ops",
first_name: "Анна",
last_name: "Смирнова",
email: "anna@example.com",
telegram_photo_url: "",
registration_date: "2026-04-24T10:20:00Z",
is_banned: false,
premium_traffic: {
state: "good",
unlimited: false,
used_bytes: 4 * 1073741824,
limit_bytes: 25 * 1073741824,
percent: 16,
},
panel_status: "active",
},
},
{
user_id: 100200301,
telegram_id: 87543123,
username: "client_pro",
first_name: "Максим",
last_name: "Котов",
email: "",
telegram_photo_url: "",
registration_date: "2026-04-26T08:15:00Z",
is_banned: false,
premium_traffic: {
state: "warn",
unlimited: false,
used_bytes: 22 * 1073741824,
limit_bytes: 25 * 1073741824,
percent: 88,
{
user_id: 100200301,
telegram_id: 87543123,
username: "client_pro",
first_name: "Максим",
last_name: "Котов",
email: "",
telegram_photo_url: "",
registration_date: "2026-04-26T08:15:00Z",
is_banned: false,
premium_traffic: {
state: "warn",
unlimited: false,
used_bytes: 22 * 1073741824,
limit_bytes: 25 * 1073741824,
percent: 88,
},
panel_status: "active",
},
},
{
user_id: 100200302,
telegram_id: 88440011,
username: "",
first_name: "Daria",
last_name: "",
email: "daria@example.com",
telegram_photo_url: "",
registration_date: "2026-04-29T16:45:00Z",
is_banned: true,
premium_traffic: { state: "none" },
},
]);
{
user_id: 100200302,
telegram_id: 88440011,
username: "",
first_name: "Daria",
last_name: "",
email: "daria@example.com",
telegram_photo_url: "",
registration_date: "2026-04-29T16:45:00Z",
is_banned: true,
premium_traffic: { state: "none" },
panel_status: "bot_only",
},
].map(withDemoAdminUserMetrics)
);
const supportTickets = [
{
ticket_id: 42,
@@ -1239,6 +1339,7 @@ export async function mockApi(path, options = {}, context = {}) {
trial_users: 8,
free_subscription_users: 23,
inactive_users: 76,
expired_subscription_users: 31,
banned_users: 3,
referral_users: 34,
},
+12 -2
View File
@@ -188,10 +188,17 @@ export function createAccountStore({
async function requestLinkEmailCode() {
const s = get(state);
if (s.linkEmailPending && s.linkEmailResendCooldown > 0) return;
const normalized = String(s.linkEmailValue || "")
.trim()
.toLowerCase();
if (
s.linkEmailPending &&
s.linkEmailResendCooldown > 0 &&
(!normalized || normalized === s.linkEmailPending)
) {
state.update((s) => ({ ...s, linkEmailOpen: true }));
return;
}
if (!normalized || !normalized.includes("@")) {
state.update((s) => ({ ...s, linkEmailFieldError: t("wa_auth_invalid_email") }));
return;
@@ -251,7 +258,10 @@ export function createAccountStore({
async function requestSetPasswordCode() {
const s = get(state);
if (s.setPasswordPending && s.setPasswordResendCooldown > 0) return;
if (s.setPasswordPending && s.setPasswordResendCooldown > 0) {
state.update((s) => ({ ...s, setPasswordOpen: true }));
return;
}
if (!validatePasswordDraft()) return;
state.update((s) => ({ ...s, setPasswordBusy: true }));
setPasswordStatus(t("wa_auth_sending_code"));
+93 -1
View File
@@ -6,6 +6,10 @@ import {
emailError,
} from "../authHelpers.js";
const EMAIL_CODE_PENDING_STORAGE_KEY = "rw_email_code_login_pending_v1";
const EMAIL_CODE_PENDING_TTL_MS = 10 * 60 * 1000;
const EMAIL_CODE_RESEND_MS = 60 * 1000;
export function createAuthStore({
publicApi,
setToken,
@@ -35,6 +39,80 @@ export function createAuthStore({
let authResendTimer = null;
let telegramLoginWatchdogTimer = null;
function readPendingEmailCodeSession() {
if (typeof window === "undefined" || !window.sessionStorage) return null;
try {
const raw = window.sessionStorage.getItem(EMAIL_CODE_PENDING_STORAGE_KEY);
if (!raw) return null;
const parsed = JSON.parse(raw);
const email = String(parsed?.email || "")
.trim()
.toLowerCase();
const expiresAt = Number(parsed?.expiresAt || 0);
const cooldownUntil = Number(parsed?.cooldownUntil || 0);
if (!email || !email.includes("@") || !expiresAt || expiresAt <= Date.now()) {
window.sessionStorage.removeItem(EMAIL_CODE_PENDING_STORAGE_KEY);
return null;
}
return { email, expiresAt, cooldownUntil };
} catch (_error) {
window.sessionStorage.removeItem(EMAIL_CODE_PENDING_STORAGE_KEY);
return null;
}
}
function writePendingEmailCodeSession(email) {
if (typeof window === "undefined" || !window.sessionStorage) return;
try {
window.sessionStorage.setItem(
EMAIL_CODE_PENDING_STORAGE_KEY,
JSON.stringify({
email,
expiresAt: Date.now() + EMAIL_CODE_PENDING_TTL_MS,
cooldownUntil: Date.now() + EMAIL_CODE_RESEND_MS,
})
);
} catch (_error) {
void _error;
}
}
function clearPendingEmailCode() {
if (typeof window === "undefined" || !window.sessionStorage) return;
try {
window.sessionStorage.removeItem(EMAIL_CODE_PENDING_STORAGE_KEY);
} catch (_error) {
void _error;
}
}
function restorePendingEmailCode(changeScreen) {
const pending = readPendingEmailCodeSession();
if (!pending) return false;
state.update((s) => ({
...s,
email: pending.email,
pendingEmail: pending.email,
emailCode: "",
authStatus: "",
authIsError: false,
authBusy: false,
passwordLoginMode: false,
passwordLoginFallback: false,
loginEmailFieldError: "",
loginEmailTooltipOpen: false,
}));
const cooldownSeconds = Math.ceil((pending.cooldownUntil - Date.now()) / 1000);
if (cooldownSeconds > 0) {
startCooldownTimer(cooldownSeconds);
} else {
clearCooldownTimer();
state.update((s) => ({ ...s, authResendCooldown: 0 }));
}
if (typeof changeScreen === "function") changeScreen("code");
return true;
}
function setAuthStatus(message, isError = false) {
state.update((s) => ({ ...s, authStatus: message, authIsError: isError }));
}
@@ -101,6 +179,7 @@ export function createAuthStore({
const response = await publicApi("/auth/email/magic", payload);
if (response.ok && response.csrf_token) {
setToken("", response.csrf_token);
clearPendingEmailCode();
clearAuthQuery();
await loadData();
return true;
@@ -131,6 +210,7 @@ export function createAuthStore({
const response = await publicApi("/auth/token", payload, { signal: options.signal });
if (response.ok && response.csrf_token) {
setToken("", response.csrf_token);
clearPendingEmailCode();
clearAuthQuery();
setAuthStatus("");
await loadData();
@@ -157,8 +237,15 @@ export function createAuthStore({
async function requestEmailCode(changeScreen) {
const s = get(state);
if (s.authResendCooldown > 0 && s.pendingEmail) return;
const normalized = s.email.trim().toLowerCase();
if (
s.authResendCooldown > 0 &&
s.pendingEmail &&
(!normalized || normalized === s.pendingEmail)
) {
if (typeof changeScreen === "function") changeScreen("code");
return;
}
if (!normalized || !normalized.includes("@")) {
state.update((s) => ({
...s,
@@ -185,6 +272,7 @@ export function createAuthStore({
.replace(/\D/g, "")
.slice(0, 6);
state.update((s) => ({ ...s, pendingEmail: normalized, emailCode: presetCode }));
writePendingEmailCodeSession(normalized);
changeScreen("code");
setAuthStatus("");
startCooldownTimer(60);
@@ -226,6 +314,7 @@ export function createAuthStore({
});
if (!response.ok || !response.csrf_token) throw response;
setToken("", response.csrf_token);
clearPendingEmailCode();
await loadData();
setAuthStatus("");
} catch (error) {
@@ -258,6 +347,7 @@ export function createAuthStore({
const response = await publicApi("/auth/email/verify", payload);
if (!response.ok || !response.csrf_token) throw response;
setToken("", response.csrf_token);
clearPendingEmailCode();
await loadData();
setAuthStatus("");
} catch (error) {
@@ -335,6 +425,8 @@ export function createAuthStore({
loginWithEmailPassword,
verifyEmailCode,
openTelegramLogin,
restorePendingEmailCode,
clearPendingEmailCode,
clearCooldownTimer,
stopTelegramLoginWatchdog,
setAuthStatus,
+222 -14
View File
@@ -2760,15 +2760,6 @@
max-height: min(100%, 760px);
}
.admin-user-dialog .dialog-body-scroll {
margin-right: -10px;
padding-right: 10px;
}
.admin-user-dialog .dialog-body-scroll > .scroll-area__viewport {
padding-right: 10px;
}
.admin-user-dialog-body {
display: grid;
grid-template-columns: minmax(0, 1fr);
@@ -3134,6 +3125,169 @@
}
}
.admin-tariff-management {
display: grid;
gap: 14px;
}
.admin-tariff-overview-grid {
display: grid;
gap: 14px;
}
.admin-tariff-overview-grid > .admin-card {
min-width: 0;
}
@media (min-width: 980px) {
.admin-tariff-overview-grid {
grid-template-columns: minmax(260px, 0.82fr) minmax(0, 1.6fr);
align-items: stretch;
}
}
.admin-tariff-panel-head > div,
.admin-tariff-list-head > div {
min-width: 0;
}
.admin-tariff-panel-head small,
.admin-tariff-list-head small {
max-width: 72ch;
}
.admin-tariff-path {
display: block;
width: fit-content;
max-width: 100%;
margin-top: 6px;
overflow: hidden;
color: var(--admin-dim);
font-family: var(--font-mono);
font-size: 11px;
text-overflow: ellipsis;
white-space: nowrap;
}
.admin-tariff-currency-body {
display: grid;
align-content: start;
gap: 14px;
}
.admin-tariff-currency-current {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
min-width: 0;
padding: 12px;
border: 1px solid var(--admin-border);
border-radius: 8px;
background: var(--admin-surface-2);
}
.admin-tariff-currency-current span {
color: var(--admin-muted);
font-size: 12px;
font-weight: 600;
}
.admin-tariff-currency-current strong {
color: var(--admin-text);
font-family: var(--font-mono);
font-size: 18px;
letter-spacing: 0;
}
.admin-tariff-catalog-bar {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: end;
gap: 10px;
}
.admin-tariff-currency-field {
display: grid;
min-width: 0;
gap: 8px;
}
.admin-tariff-currency-field > span {
color: var(--admin-text);
font-size: 13px;
font-weight: 500;
}
.admin-currency-input {
width: 100%;
max-width: 160px;
text-transform: uppercase;
}
.admin-provider-summary {
display: flex;
justify-content: flex-end;
flex-wrap: wrap;
gap: 6px;
min-width: 0;
}
.admin-provider-currency-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 10px;
}
.admin-provider-currency {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 10px;
min-width: 0;
padding: 10px 12px;
border: 1px solid var(--admin-border);
border-radius: 8px;
background: var(--admin-surface-2);
}
.admin-provider-currency.is-supported {
border-color: color-mix(in srgb, var(--success-border) 70%, var(--admin-border));
}
.admin-provider-currency.is-unavailable {
opacity: 0.74;
}
.admin-provider-currency-main {
display: grid;
gap: 3px;
min-width: 0;
}
.admin-provider-currency-main strong,
.admin-provider-currency-main small {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.admin-provider-currency-main strong {
color: var(--admin-text);
font-size: 13px;
font-weight: 700;
}
.admin-provider-currency-main small {
color: var(--admin-muted);
font-size: 11px;
}
.admin-provider-currency > .admin-badge {
flex: 0 0 auto;
}
.admin-tariff-grid {
display: grid;
grid-template-columns: minmax(0, 1fr);
@@ -3154,6 +3308,7 @@
.admin-tariff-card {
display: grid;
align-content: start;
gap: 12px;
min-width: 0;
border: 1px solid var(--admin-border);
@@ -3177,6 +3332,11 @@
.admin-tariff-top {
justify-content: space-between;
min-width: 0;
}
.admin-tariff-top > div {
min-width: 0;
}
.admin-tariff-title {
@@ -3213,9 +3373,7 @@
.admin-tariff-facts span {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
overflow-wrap: anywhere;
}
.admin-tariff-actions {
@@ -3447,6 +3605,15 @@
transform: translateX(0);
}
.admin-screen-wrap.is-admin-language-open .admin-sidebar {
z-index: 120;
pointer-events: none;
}
.admin-screen-wrap.is-admin-language-open .admin-language-switch {
pointer-events: auto;
}
.admin-sidebar-backdrop {
position: fixed;
inset: 0;
@@ -3559,6 +3726,33 @@
padding: 14px;
}
.admin-tariff-panel-head,
.admin-tariff-list-head {
gap: 10px;
}
.admin-provider-summary {
justify-content: flex-start;
}
.admin-tariff-catalog-bar {
grid-template-columns: minmax(0, 1fr);
}
.admin-currency-input {
max-width: none;
}
.admin-provider-currency {
align-items: flex-start;
flex-direction: column;
}
.admin-provider-currency-main strong,
.admin-provider-currency-main small {
white-space: normal;
}
.admin-tariff-card {
padding: 12px;
}
@@ -3571,12 +3765,18 @@
display: none;
}
.admin-tariff-actions {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.admin-tariff-actions .admin-btn {
flex: 1 1 calc(50% - 4px);
width: 100%;
min-width: 0;
}
.admin-tariff-actions .admin-btn[aria-label] {
flex: 0 0 auto;
width: 100%;
}
/* (legacy three-row user-row layout removed in favour of the compact
@@ -3854,6 +4054,7 @@
.admin-select-content {
z-index: 90;
min-width: var(--bits-select-anchor-width);
max-width: calc(100vw - 24px);
max-height: var(--bits-select-content-available-height, 320px);
overflow: hidden;
border: 1px solid var(--admin-border);
@@ -3907,6 +4108,13 @@
user-select: none;
}
.admin-select-item span {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.admin-select-item[data-highlighted],
.admin-select-item:hover {
background: var(--admin-surface-2);
+56
View File
@@ -336,6 +336,18 @@ a {
margin-left: 0;
justify-content: center;
}
.subscription-renew-action {
min-height: 54px;
padding-block: 12px;
font-size: 15px;
}
.subscription-renew-action svg {
width: 19px;
height: 19px;
flex: 0 0 auto;
}
}
.traffic-top,
@@ -2366,6 +2378,50 @@ a {
color: var(--accent);
}
.auth-language-trigger {
appearance: none;
-webkit-tap-highlight-color: transparent;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 5px;
min-height: 26px;
border: 0;
border-radius: 7px;
background: transparent;
color: var(--muted);
font: inherit;
font-size: 12px;
line-height: 1;
cursor: pointer;
}
.auth-language-trigger:hover,
.auth-language-trigger[data-state="open"] {
color: var(--accent);
}
.auth-language-trigger:focus-visible {
outline: 0;
box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent) 42%, transparent);
}
.auth-language-trigger > span:last-of-type {
text-decoration: underline;
text-underline-offset: 2px;
}
.auth-language-trigger > svg,
.auth-language-trigger > .emoji-flag {
flex: 0 0 auto;
}
.auth-language-content {
width: min(188px, calc(100vw - 40px));
min-width: 154px;
transform-origin: top center;
}
.auth-bottom strong {
color: var(--accent);
}
+110 -34
View File
@@ -1,6 +1,14 @@
<script>
import { LockKeyhole, Mail, Send, TriangleAlert } from "$components/ui/icons.js";
import { Tooltip } from "$components/ui/primitives.js";
import {
Check,
ChevronsUpDown,
Globe2,
LockKeyhole,
Mail,
Send,
TriangleAlert,
} from "$components/ui/icons.js";
import { Select, Tooltip } from "$components/ui/primitives.js";
import Button from "$components/ui/button.svelte";
import BrandMark from "$lib/webapp/BrandMark.svelte";
@@ -32,7 +40,15 @@
export let telegramLoginUnavailableMessage;
export let privacyPolicyUrl;
export let userAgreementUrl;
export let currentLang = "ru";
export let currentLanguageOption = null;
export let languageOptions = [];
export let languageMenuOpen = false;
export let languageClickGuard = false;
export let languageClickGuardArmed = false;
export let t;
export let setLanguageMenuOpen = () => {};
export let updateLoginLanguage = () => {};
export let requestEmailCode;
export let loginWithEmailPassword;
export let verifyEmailCode;
@@ -48,6 +64,13 @@
$: emailAuthEnabled = CFG.emailAuthEnabled !== false;
$: passwordModeActive = Boolean(passwordLoginMode && emailAuthEnabled);
$: authCardHeight = authPanelHeight ? `${authPanelHeight}px` : undefined;
$: showLanguageSelect = languageOptions.length > 1;
function closeLanguageFromGuard(event) {
event.preventDefault();
event.stopPropagation();
if (languageClickGuardArmed) setLanguageMenuOpen(false);
}
</script>
{#if screen === "code"}
@@ -236,40 +259,93 @@
</div>
{/key}
</section>
{#if userAgreementUrl || privacyPolicyUrl}
{#if userAgreementUrl || privacyPolicyUrl || showLanguageSelect}
<div class="auth-legal">
<span class="auth-legal-intro">{t("wa_auth_legal_intro")}</span>
<div class="auth-legal-links">
{#if privacyPolicyUrl}
<a
href={privacyPolicyUrl}
target="_blank"
rel="noopener noreferrer"
onclick={(e) => {
e.preventDefault();
openExternalLink(privacyPolicyUrl);
}}
{#if userAgreementUrl || privacyPolicyUrl}
<span class="auth-legal-intro">{t("wa_auth_legal_intro")}</span>
<div class="auth-legal-links">
{#if privacyPolicyUrl}
<a
href={privacyPolicyUrl}
target="_blank"
rel="noopener noreferrer"
onclick={(e) => {
e.preventDefault();
openExternalLink(privacyPolicyUrl);
}}
>
{t("wa_auth_legal_privacy")}
</a>
{/if}
{#if privacyPolicyUrl && userAgreementUrl}
<span>{t("wa_auth_legal_and")}</span>
{/if}
{#if userAgreementUrl}
<a
href={userAgreementUrl}
target="_blank"
rel="noopener noreferrer"
onclick={(e) => {
e.preventDefault();
openExternalLink(userAgreementUrl);
}}
>
{t("wa_auth_legal_agreement")}
</a>
{/if}
</div>
{/if}
{#if showLanguageSelect}
{#if languageMenuOpen || languageClickGuard}
<button
class="language-select-guard"
class:language-select-guard--armed={languageClickGuardArmed}
type="button"
aria-label={t("wa_close")}
onpointerdown={closeLanguageFromGuard}
onclick={closeLanguageFromGuard}
></button>
{/if}
<Select.Root
type="single"
bind:open={languageMenuOpen}
value={currentLang}
items={languageOptions}
onOpenChange={setLanguageMenuOpen}
onValueChange={updateLoginLanguage}
>
<Select.Trigger class="auth-language-trigger" aria-label={t("wa_settings_language")}>
<Globe2 size={13} />
<span class="emoji-flag" aria-hidden="true"
>{currentLanguageOption?.flag || "🏳️"}</span
>
<span>{currentLanguageOption?.label || currentLang}</span>
<ChevronsUpDown size={12} />
</Select.Trigger>
<Select.Content
class="language-select-content auth-language-content"
side="bottom"
align="center"
sideOffset={7}
>
{t("wa_auth_legal_privacy")}
</a>
{/if}
{#if privacyPolicyUrl && userAgreementUrl}
<span>{t("wa_auth_legal_and")}</span>
{/if}
{#if userAgreementUrl}
<a
href={userAgreementUrl}
target="_blank"
rel="noopener noreferrer"
onclick={(e) => {
e.preventDefault();
openExternalLink(userAgreementUrl);
}}
>
{t("wa_auth_legal_agreement")}
</a>
{/if}
</div>
<Select.Viewport class="language-select-viewport">
{#each languageOptions as option (option.value)}
<Select.Item
value={option.value}
label={option.label}
class="language-select-item"
>
<span class="language-select-item-main">
<span class="emoji-flag" aria-hidden="true">{option.flag}</span>
<span>{option.label}</span>
</span>
<Check size={15} class="language-select-item-check" />
</Select.Item>
{/each}
</Select.Viewport>
</Select.Content>
</Select.Root>
{/if}
</div>
{/if}
</div>
@@ -6,7 +6,7 @@
const AUTO_OPEN_DELAY_MS = 80;
const MANUAL_STATE_DELAY_MS = 1600;
const DONE_STATE_DELAY_MS = 900;
const CLOSE_ATTEMPT_DELAY_MS = 120;
const CLOSE_ATTEMPT_DELAY_MS = 2500;
export let brand = {};
export let appLaunchTarget = "";
@@ -54,7 +54,6 @@
autoOpenTimer = window.setTimeout(openTarget, AUTO_OPEN_DELAY_MS);
window.addEventListener("pagehide", notePageLeft);
window.addEventListener("blur", notePageLeft);
document.addEventListener("visibilitychange", handleVisibilityChange);
return () => {
@@ -63,7 +62,6 @@
clearTimer(doneStateTimer);
clearTimer(closeAttemptTimer);
window.removeEventListener("pagehide", notePageLeft);
window.removeEventListener("blur", notePageLeft);
document.removeEventListener("visibilitychange", handleVisibilityChange);
};
});
@@ -91,7 +89,9 @@
if (!attempted || state === "done" || !activeTarget) return;
state = "done";
clearTimer(closeAttemptTimer);
closeAttemptTimer = window.setTimeout(tryCloseWindow, CLOSE_ATTEMPT_DELAY_MS);
closeAttemptTimer = window.setTimeout(() => {
if (pageLeft || document.hidden) tryCloseWindow();
}, CLOSE_ATTEMPT_DELAY_MS);
}
function notePageLeft() {
@@ -343,7 +343,7 @@
</Button>
{/if}
<Button
class="wide"
class={`wide${subscription.active ? " subscription-renew-action" : ""}`}
variant={subscription.active ? "secondary" : "default"}
onclick={openPaymentModal}
>
+80 -4
View File
@@ -179,6 +179,7 @@
"broadcast_target_all_button": "👥 All",
"broadcast_target_active_button": "✅ Active",
"broadcast_target_inactive_button": "⌛ Inactive",
"broadcast_target_expired_button": "⏰ Expired",
"confirm_broadcast_send_button": "✅ Send",
"admin_broadcast_sending_started": "Starting broadcast...",
"admin_broadcast_error_no_message": "Error: no message to broadcast.",
@@ -374,8 +375,8 @@
"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_webapp_regular": "Top up traffic",
"traffic_warn_btn_topup_webapp_premium": "Top up premium traffic",
"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}",
@@ -440,6 +441,13 @@
"admin_user_referral_revenue_label": "💸 <b>Referral Revenue:</b>",
"admin_user_invited_friends_label": "👥 <b>Friends invited:</b>",
"admin_user_ref_purchased_label": "💳 <b>Purchased subscription:</b>",
"admin_user_invited_by_label": "🎁 <b>Invited by:</b>",
"admin_user_invitees_button": "👥 Invited users",
"admin_user_invitees_message_title": "👥 <b>Users invited by {user}</b>\n\nTotal: <b>{total}</b>. Page {current}/{total_pages}.",
"admin_user_invitees_empty": "This user has not invited anyone yet.",
"admin_user_invitee_item": "{index}. {user}{suffix}",
"admin_user_invitee_registered_suffix": " · registered {date}",
"admin_user_invitees_error": "❌ Failed to load invited users",
"admin_user_links_section_title": "🔗 <b>Links</b>",
"admin_user_subscription_url_label": "📡 <b>Subscription:</b>",
"admin_user_ref_bot_link_label": "🤖 <b>Referral link (bot):</b>",
@@ -664,6 +672,28 @@
"email_subscription_lifecycle_row_end_date": "Active until",
"email_subscription_lifecycle_cta": "Open dashboard",
"email_subscription_lifecycle_text_renew": "Dashboard: {url}",
"email_support_new_ticket_admin_subject": "New support ticket #{ticket_id}",
"email_support_new_ticket_admin_heading": "New support ticket #{ticket_id}",
"email_support_new_ticket_admin_intro": "A user opened a new support ticket.",
"email_support_user_reply_admin_subject": "New user reply in ticket #{ticket_id}",
"email_support_user_reply_admin_heading": "User replied in ticket #{ticket_id}",
"email_support_user_reply_admin_intro": "A user sent a new support message.",
"email_support_admin_reply_user_subject": "New reply for ticket #{ticket_id}",
"email_support_admin_reply_user_heading": "New reply for ticket #{ticket_id}",
"email_support_admin_reply_user_intro": "Support has replied to your ticket.",
"email_support_ticket_closed_user_subject": "Ticket #{ticket_id} was closed",
"email_support_ticket_closed_user_heading": "Ticket #{ticket_id} was closed",
"email_support_ticket_closed_user_intro": "Your support ticket has been closed.",
"email_support_ticket_closed_user_body": "The ticket is closed.",
"email_support_row_ticket": "Ticket",
"email_support_row_user": "User",
"email_support_row_subject": "Subject",
"email_support_row_tariff": "Tariff",
"email_support_row_end_date": "End date",
"email_support_row_remaining": "Remaining",
"email_support_row_panel_status": "Panel status",
"email_support_cta_open_ticket": "Open ticket",
"email_support_cta_open_mini_app": "Open ticket",
"wa_loading": "Loading...",
"wa_back": "Back",
"wa_next": "Next",
@@ -994,6 +1024,9 @@
"admin_sort_id_desc": "ID ↓",
"admin_sort_premium_ratio_asc": "Premium usage % ↑",
"admin_sort_premium_ratio_desc": "Premium usage % ↓",
"admin_sort_ascending": "Sorted ascending",
"admin_sort_descending": "Sorted descending",
"admin_sort_off": "Not sorted",
"admin_premium_traffic_filter_label": "Premium traffic",
"admin_premium_traffic_filter_all": "All",
"admin_premium_traffic_filter_none": "No tariff limit",
@@ -1010,9 +1043,20 @@
"admin_users_search_placeholder": "ID, @username, or email",
"admin_find": "Find",
"admin_filter": "Filter",
"admin_filters": "Filters",
"admin_active_filters": "Active filters",
"admin_clear_filter": "Clear {label}",
"admin_done": "Done",
"admin_users_filters_open": "Open filters",
"admin_users_filters_title": "User filters",
"admin_users_filters_description": "Refine the user list without leaving the table.",
"admin_sort": "Sort",
"admin_total": "Total",
"admin_users_empty": "No users found",
"admin_users_col_payments_total": "Paid total",
"admin_users_col_payments_count": "Payments",
"admin_users_col_invited": "Invited",
"admin_users_col_subscription_expires": "Expires",
"admin_users_col_registration": "Registered",
"admin_page": "Page",
"admin_page_short": "Page",
@@ -1063,6 +1107,9 @@
"admin_settings_section_backups": "Backups",
"admin_settings_section_devices": "Devices",
"admin_settings_section_support": "Support",
"admin_settings_section_system": "System",
"admin_settings_field_telemetry_enabled_label": "Anonymous install analytics",
"admin_settings_field_telemetry_enabled_description": "Sends one anonymous heartbeat per day (version, OS, locale, user-count range). No personal data, tokens or domains. Helps gauge how many installs are active and which versions are in use. Toggling this off takes effect without a restart.",
"admin_settings_subsection_common": "Common",
"admin_settings_subsection_checkout": "Checkout",
"admin_settings_subsection_remnawave": "Remnawave",
@@ -1095,6 +1142,7 @@
"admin_broadcast_target_all": "All active",
"admin_broadcast_target_active": "With subscription",
"admin_broadcast_target_inactive": "No subscription",
"admin_broadcast_target_expired": "Expired subscription",
"admin_expired_at": "Expired {date}",
"admin_expired_badge": "Expired {date}",
"admin_stats_error": "Failed to load statistics: {error}",
@@ -1122,6 +1170,7 @@
"admin_stats_trend_referrals": "Referrals: {count}",
"admin_stats_label_inactive": "No active subscription",
"admin_stats_trend_new_today": "Registrations today: {count}",
"admin_stats_trend_expired_subscriptions": "Expired subscriptions: {count}",
"admin_stats_section_revenue": "Revenue",
"admin_stats_section_revenue_hint": "Succeeded payments, shop currency",
"admin_stats_revenue_chart_title": "Daily revenue (UTC)",
@@ -1204,6 +1253,7 @@
"admin_status_limited": "Limited",
"admin_status_expired": "Expired",
"admin_status_disabled": "Disabled",
"admin_status_not_configured": "Not configured",
"admin_status_bot_only": "Bot Only",
"admin_user_detail_title": "User #{id}",
"admin_user_avatar_open": "Open avatar",
@@ -1225,6 +1275,14 @@
"admin_user_section_profile": "Profile",
"admin_user_label_registration": "Registration",
"admin_user_label_ref_code": "Referral Code",
"admin_user_label_invited_by": "Invited by",
"admin_user_label_invited_users": "Invited users",
"admin_user_invited_by_none": "—",
"admin_user_invitees_open": "Show",
"admin_user_open_related": "Open user card",
"admin_user_invitees_title": "Invited users",
"admin_user_invitees_description": "{name} · {count}",
"admin_user_col_user": "User",
"admin_user_section_links": "Links",
"admin_user_copy_tooltip": "Copy",
"admin_user_sub_link_copied": "Subscription link copied",
@@ -1246,11 +1304,11 @@
"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_card_hint": "Unlimited access and a persistent bonus on the main traffic limit.",
"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_traffic_override_hint": "Extra main or premium traffic limits on top of the tariff; unlimited can be applied to main traffic or premium squads.",
"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",
@@ -1351,6 +1409,9 @@
"admin_ads_col_registrations": "Registrations",
"admin_ads_col_conversions": "Conversions",
"admin_no_data": "No data",
"admin_pagination_meta": "{current}/{total}",
"admin_prev_page": "Back",
"admin_next_page": "Next",
"admin_settings_hint": "Changes in the admin panel take precedence over .env. The 'Reset' button returns the value from environment variables.",
"admin_settings_legacy_tariffs_warning_title": "remnawave-tg-shop legacy compatibility",
"admin_settings_legacy_tariffs_warning_body": "These fields are only for the old mode without a JSON catalog. They do not apply when tariffs are configured in the dedicated Tariffs section.",
@@ -1940,6 +2001,9 @@
"admin_tariff_btn_period": "Period",
"admin_tariff_create_subtitle": "The catalog will be saved to JSON after confirmation",
"admin_tariff_create_title": "New tariff",
"admin_tariff_currency_supported": "Available",
"admin_tariff_currency_unsupported": "Blocked",
"admin_tariff_default_currency": "Payment currency",
"admin_tariff_default_updated": "Default tariff updated",
"admin_tariff_delete_subtitle": "Tariff {key} will disappear from the sales catalog.",
"admin_tariff_delete_title": "Delete tariff?",
@@ -1986,6 +2050,8 @@
"admin_tariff_premium_subhead": "Premium squads give the user access to faster/premium nodes; their traffic is counted separately from main traffic so it can be limited or sold separately",
"admin_tariff_premium_topup_subtitle": "Packages that extend the monthly premium limit when the user runs out",
"admin_tariff_premium_topup_title": "Premium traffic top-up",
"admin_tariff_provider_any_currency": "Any",
"admin_tariff_provider_not_declared": "Not declared",
"admin_tariff_pricing_empty": "Add at least one period so the tariff appears in the storefront.",
"admin_tariff_pricing_period_subtitle": "Each row is a separate storefront option: how many months the user pays for and how much it costs",
"admin_tariff_pricing_period_title": "Subscription periods and prices",
@@ -1997,6 +2063,16 @@
"admin_tariff_topup_title": "Traffic top-up over the monthly limit",
"admin_tariff_topup_traffic_hint": "For the traffic model, separate top-ups are not needed: packages configured on the Prices tab are the top-ups users can buy again as they run out.",
"admin_tariff_visible": "Tariff is visible in the storefront",
"admin_tariffs_catalog_subtitle": "Periods, prices, traffic, and user access.",
"admin_tariffs_currency_current": "Current currency",
"admin_tariffs_currency_subtitle": "Tariff prices and payment providers are checked against this currency.",
"admin_tariffs_currency_title": "Catalog currency",
"admin_tariffs_provider_available_count": "Available: {count}",
"admin_tariffs_provider_blocked_count": "Unsupported: {count}",
"admin_tariffs_provider_empty": "Provider data has not been loaded yet.",
"admin_tariffs_provider_enabled_count": "Enabled: {count}",
"admin_tariffs_provider_subtitle": "Shows which providers can accept the current catalog currency.",
"admin_tariffs_provider_title": "Payment providers",
"admin_tariffs_save_failed": "Failed to save tariffs",
"admin_tariffs_saved": "Tariffs saved",
"admin_tariffs_title": "Tariff catalog",
+79 -3
View File
@@ -179,6 +179,7 @@
"broadcast_target_all_button": "👥 Все",
"broadcast_target_active_button": "✅ Активные",
"broadcast_target_inactive_button": "⌛ Неактивные",
"broadcast_target_expired_button": "⏰ Просроченные",
"confirm_broadcast_send_button": "✅ Отправить",
"admin_broadcast_sending_started": "Начинаю рассылку...",
"admin_broadcast_error_no_message": "Ошибка: сообщение для рассылки не найдено.",
@@ -374,8 +375,8 @@
"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_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}",
@@ -440,6 +441,13 @@
"admin_user_referral_revenue_label": "💸 <b>Доход по рефералам:</b>",
"admin_user_invited_friends_label": "👥 <b>Приглашено друзей:</b>",
"admin_user_ref_purchased_label": "💳 <b>Купили подписку:</b>",
"admin_user_invited_by_label": "🎁 <b>Пригласил:</b>",
"admin_user_invitees_button": "👥 Приглашённые",
"admin_user_invitees_message_title": "👥 <b>Пользователи, приглашённые {user}</b>\n\nВсего: <b>{total}</b>. Страница {current}/{total_pages}.",
"admin_user_invitees_empty": "Пользователь пока никого не пригласил.",
"admin_user_invitee_item": "{index}. {user}{suffix}",
"admin_user_invitee_registered_suffix": " · регистрация {date}",
"admin_user_invitees_error": "❌ Ошибка загрузки приглашённых пользователей",
"admin_user_links_section_title": "🔗 <b>Ссылки</b>",
"admin_user_subscription_url_label": "📡 <b>Подписка:</b>",
"admin_user_ref_bot_link_label": "🤖 <b>Реф. ссылка (бот):</b>",
@@ -664,6 +672,28 @@
"email_subscription_lifecycle_row_end_date": "Действует до",
"email_subscription_lifecycle_cta": "Открыть кабинет",
"email_subscription_lifecycle_text_renew": "Кабинет: {url}",
"email_support_new_ticket_admin_subject": "Новый тикет поддержки #{ticket_id}",
"email_support_new_ticket_admin_heading": "Новый тикет поддержки #{ticket_id}",
"email_support_new_ticket_admin_intro": "Пользователь создал новый тикет поддержки.",
"email_support_user_reply_admin_subject": "Новый ответ пользователя в тикете #{ticket_id}",
"email_support_user_reply_admin_heading": "Пользователь ответил в тикете #{ticket_id}",
"email_support_user_reply_admin_intro": "Пользователь отправил новое сообщение в поддержку.",
"email_support_admin_reply_user_subject": "Новый ответ по тикету #{ticket_id}",
"email_support_admin_reply_user_heading": "Новый ответ по тикету #{ticket_id}",
"email_support_admin_reply_user_intro": "Поддержка ответила на ваш тикет.",
"email_support_ticket_closed_user_subject": "Тикет #{ticket_id} закрыт",
"email_support_ticket_closed_user_heading": "Тикет #{ticket_id} закрыт",
"email_support_ticket_closed_user_intro": "Ваш тикет поддержки закрыт.",
"email_support_ticket_closed_user_body": "Тикет закрыт.",
"email_support_row_ticket": "Тикет",
"email_support_row_user": "Пользователь",
"email_support_row_subject": "Тема",
"email_support_row_tariff": "Тариф",
"email_support_row_end_date": "Дата окончания",
"email_support_row_remaining": "Осталось",
"email_support_row_panel_status": "Статус в панели",
"email_support_cta_open_ticket": "Открыть тикет",
"email_support_cta_open_mini_app": "Открыть обращение",
"wa_loading": "Загрузка...",
"wa_back": "Назад",
"wa_next": "Далее",
@@ -994,6 +1024,9 @@
"admin_sort_id_desc": "ID ↓",
"admin_sort_premium_ratio_asc": "Премиум % ↑",
"admin_sort_premium_ratio_desc": "Премиум % ↓",
"admin_sort_ascending": "Сортировка по возрастанию",
"admin_sort_descending": "Сортировка по убыванию",
"admin_sort_off": "Без сортировки",
"admin_premium_traffic_filter_label": "Премиум трафик",
"admin_premium_traffic_filter_all": "Все",
"admin_premium_traffic_filter_none": "Без лимита в тарифе",
@@ -1010,9 +1043,20 @@
"admin_users_search_placeholder": "ID, @username или email",
"admin_find": "Найти",
"admin_filter": "Фильтр",
"admin_filters": "Фильтры",
"admin_active_filters": "Активные фильтры",
"admin_clear_filter": "Сбросить {label}",
"admin_done": "Готово",
"admin_users_filters_open": "Открыть фильтры",
"admin_users_filters_title": "Фильтры пользователей",
"admin_users_filters_description": "Уточните список пользователей, не уходя из таблицы.",
"admin_sort": "Сортировка",
"admin_total": "Всего",
"admin_users_empty": "Никого не найдено",
"admin_users_col_payments_total": "Сумма платежей",
"admin_users_col_payments_count": "Платежи",
"admin_users_col_invited": "Приглашенные",
"admin_users_col_subscription_expires": "Истекает",
"admin_users_col_registration": "Регистрация",
"admin_page": "Страница",
"admin_page_short": "Стр.",
@@ -1063,6 +1107,9 @@
"admin_settings_section_backups": "Бэкапы",
"admin_settings_section_devices": "Устройства",
"admin_settings_section_support": "Поддержка",
"admin_settings_section_system": "Система",
"admin_settings_field_telemetry_enabled_label": "Анонимная статистика установки",
"admin_settings_field_telemetry_enabled_description": "Раз в сутки отправляет обезличенный сигнал: версия, ОС, локаль и число пользователей в виде диапазона. Без персональных данных, токенов и доменов. Помогает оценить число активных установок и используемые версии. Отключение применяется без перезапуска.",
"admin_settings_subsection_common": "Общие",
"admin_settings_subsection_checkout": "Оформление оплаты",
"admin_settings_subsection_remnawave": "Remnawave",
@@ -1095,6 +1142,7 @@
"admin_broadcast_target_all": "Все активные",
"admin_broadcast_target_active": "С подпиской",
"admin_broadcast_target_inactive": "Без подписки",
"admin_broadcast_target_expired": "С просроченной подпиской",
"admin_expired_at": "Истекла {date}",
"admin_expired_badge": "Expired {date}",
"admin_stats_error": "Не удалось загрузить статистику: {error}",
@@ -1122,6 +1170,7 @@
"admin_stats_trend_referrals": "Рефералы: {count}",
"admin_stats_label_inactive": "Без активной подписки",
"admin_stats_trend_new_today": "Регистраций сегодня: {count}",
"admin_stats_trend_expired_subscriptions": "С просроченной подпиской: {count}",
"admin_stats_section_revenue": "Доходы",
"admin_stats_section_revenue_hint": "Успешные платежи, валюта магазина",
"admin_stats_revenue_chart_title": "Выручка по дням (UTC)",
@@ -1204,6 +1253,7 @@
"admin_status_limited": "Ограничен",
"admin_status_expired": "Истёк",
"admin_status_disabled": "Выключен",
"admin_status_not_configured": "Не настроен",
"admin_status_bot_only": "Только бот",
"admin_user_detail_title": "Пользователь #{id}",
"admin_user_avatar_open": "Открыть аватар",
@@ -1225,6 +1275,14 @@
"admin_user_section_profile": "Профиль",
"admin_user_label_registration": "Регистрация",
"admin_user_label_ref_code": "Реф. код",
"admin_user_label_invited_by": "Пригласил",
"admin_user_label_invited_users": "Приглашённые",
"admin_user_invited_by_none": "—",
"admin_user_invitees_open": "Показать",
"admin_user_open_related": "Открыть карточку",
"admin_user_invitees_title": "Приглашённые пользователи",
"admin_user_invitees_description": "{name} · {count}",
"admin_user_col_user": "Пользователь",
"admin_user_section_links": "Ссылки",
"admin_user_copy_tooltip": "Скопировать",
"admin_user_sub_link_copied": "Ссылка на подписку скопирована",
@@ -1250,7 +1308,7 @@
"user_regular_override_status_unlimited": "Сейчас: безлимит",
"regular_override_saved": "Оверрайд основного трафика сохранён",
"user_traffic_override_title": "Оверрайд трафика",
"user_traffic_override_hint": "Дополнительный лимит основного и премиум-трафика поверх тарифа; безлимит только для премиум-сквадов.",
"user_traffic_override_hint": "Дополнительный лимит основного и премиум-трафика поверх тарифа; безлимит можно включить для основного трафика или премиум-сквадов.",
"user_regular_override_bonus": "Доп. основной трафик, GB",
"user_regular_override_bonus_hint": "Постоянный бонус к лимиту основного трафика (не то же самое, что докупка ГБ).",
"user_regular_override_status_bonus": "Основной сейчас: +{gb} GB",
@@ -1351,6 +1409,9 @@
"admin_ads_col_registrations": "Регистрации",
"admin_ads_col_conversions": "Конверсии",
"admin_no_data": "Нет данных",
"admin_pagination_meta": "{current}/{total}",
"admin_prev_page": "Назад",
"admin_next_page": "Вперёд",
"admin_settings_hint": "Изменения в админке имеют приоритет над .env. Кнопка «Сбросить» возвращает значение из переменных окружения.",
"admin_settings_legacy_tariffs_warning_title": "Совместимость с remnawave-tg-shop legacy",
"admin_settings_legacy_tariffs_warning_body": "Эти поля нужны только для старого режима без JSON-каталога. Если тарифы настроены через отдельный раздел «Тарифы», они не применяются.",
@@ -1940,6 +2001,9 @@
"admin_tariff_btn_period": "Период",
"admin_tariff_create_subtitle": "Каталог будет сохранён в JSON после подтверждения",
"admin_tariff_create_title": "Новый тариф",
"admin_tariff_currency_supported": "Доступен",
"admin_tariff_currency_unsupported": "Заблокирован",
"admin_tariff_default_currency": "Валюта оплаты",
"admin_tariff_default_updated": "Тариф по умолчанию обновлён",
"admin_tariff_delete_subtitle": "Тариф {key} исчезнет из каталога продаж.",
"admin_tariff_delete_title": "Удалить тариф?",
@@ -1986,6 +2050,8 @@
"admin_tariff_premium_subhead": "Premium-сквады дают пользователю доступ к более быстрым/премиальным нодам; их трафик считается отдельно от основного, чтобы можно было ограничить или продавать дополнительно",
"admin_tariff_premium_topup_subtitle": "Пакеты для расширения месячного premium-лимита, когда пользователь его исчерпал",
"admin_tariff_premium_topup_title": "Докупка premium-трафика",
"admin_tariff_provider_any_currency": "Любая",
"admin_tariff_provider_not_declared": "Не задано",
"admin_tariff_pricing_empty": "Добавьте хотя бы один период — без него тариф не появится на витрине.",
"admin_tariff_pricing_period_subtitle": "Каждая строка — отдельный вариант на витрине: за сколько месяцев пользователь платит и сколько это стоит",
"admin_tariff_pricing_period_title": "Периоды подписки и цены",
@@ -1997,6 +2063,16 @@
"admin_tariff_topup_title": "Докупка трафика поверх месячного лимита",
"admin_tariff_topup_traffic_hint": "Для трафиковой модели отдельные «докупки» не нужны — пакеты, которые вы настроили на вкладке «Цены», и являются докупками: пользователь покупает их повторно по мере исчерпания.",
"admin_tariff_visible": "Тариф виден на витрине",
"admin_tariffs_catalog_subtitle": "Периоды, цены, трафик и доступы пользователей.",
"admin_tariffs_currency_current": "Текущая валюта",
"admin_tariffs_currency_subtitle": "Цены тарифов и платёжные провайдеры проверяются по этой валюте.",
"admin_tariffs_currency_title": "Валюта каталога",
"admin_tariffs_provider_available_count": "Доступно: {count}",
"admin_tariffs_provider_blocked_count": "Не подходят: {count}",
"admin_tariffs_provider_empty": "Данные по провайдерам пока не загружены.",
"admin_tariffs_provider_enabled_count": "Включено: {count}",
"admin_tariffs_provider_subtitle": "Здесь видно, какие провайдеры смогут принять текущую валюту каталога.",
"admin_tariffs_provider_title": "Платёжные провайдеры",
"admin_tariffs_save_failed": "Ошибка сохранения тарифов",
"admin_tariffs_saved": "Тарифы сохранены",
"admin_tariffs_title": "Каталог тарифов",
+1
View File
@@ -101,6 +101,7 @@ class AdminDbStatsCacheTests(unittest.IsolatedAsyncioTestCase):
"trial_users": 1,
"free_subscription_users": 0,
"inactive_users": 2,
"expired_subscription_users": 1,
"referral_users": 3,
}
)

Some files were not shown because too many files have changed in this diff Show More