Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9393962510 | ||
|
|
2c32e81638 | ||
|
|
0032c1804b | ||
|
|
a98ea65ef4 | ||
|
|
5218ede0f1 | ||
|
|
5b19ba2c2f | ||
|
|
687fc03e8c | ||
|
|
74272039c5 | ||
|
|
eadb86faf2 | ||
|
|
4fa18a1262 | ||
|
|
7d3178bd48 | ||
|
|
88d99fb578 | ||
|
|
8366a73575 | ||
|
|
dcdddeb1c5 | ||
|
|
d7840d3a86 | ||
|
|
6a40d0c9ce | ||
|
|
3d579b12d4 | ||
|
|
0d932b0915 | ||
|
|
2b763efef9 | ||
|
|
a0ea2261f4 | ||
|
|
5bb1400917 | ||
|
|
63ec3a6152 | ||
|
|
3896c455b8 | ||
|
|
d97afbec18 | ||
|
|
21079f78dc | ||
|
|
3e922d8edb | ||
|
|
391487811b | ||
|
|
939bc37995 | ||
|
|
c2344824dc | ||
|
|
7cf1d577f3 | ||
|
|
ecf779763c | ||
|
|
1578a9da36 | ||
|
|
80e5f0c80d | ||
|
|
df8f2636d2 | ||
|
|
45543983c2 | ||
|
|
b531d4de3b | ||
|
|
eda5d3e633 | ||
|
|
e4ef52df8b | ||
|
|
3e1f1cb787 | ||
|
|
74da8ab98e | ||
|
|
19f7daff6c | ||
|
|
cf3af17243 | ||
|
|
93353db511 | ||
|
|
e7f93a5f47 | ||
|
|
c2ab881be2 | ||
|
|
4a84bba697 | ||
|
|
cda3b741a1 | ||
|
|
2e82febdcc | ||
|
|
bd7710d03c |
@@ -73,3 +73,15 @@ FRONTEND_PORT=8082
|
|||||||
# Reverse proxy IPs/CIDRs trusted for X-Forwarded-For.
|
# Reverse proxy IPs/CIDRs trusted for X-Forwarded-For.
|
||||||
# Keep loopback for local proxy; add your proxy network if needed.
|
# Keep loopback for local proxy; add your proxy network if needed.
|
||||||
TRUSTED_PROXIES=127.0.0.1,::1
|
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,5 +1,6 @@
|
|||||||
.gitattributes text eol=lf
|
.gitattributes text eol=lf
|
||||||
*.sh text eol=lf
|
*.sh text eol=lf
|
||||||
|
.github/workflows/*.yml text eol=lf
|
||||||
deploy/docker/frontend/*.sh text eol=lf
|
deploy/docker/frontend/*.sh text eol=lf
|
||||||
frontend/src/*.js text eol=lf
|
frontend/src/*.js text eol=lf
|
||||||
frontend/src/**/*.js text eol=lf
|
frontend/src/**/*.js text eol=lf
|
||||||
|
|||||||
@@ -0,0 +1,116 @@
|
|||||||
|
name: Docker build & push (reusable)
|
||||||
|
|
||||||
|
# Reusable workflow that builds the three image targets defined in
|
||||||
|
# deploy/docker/Dockerfile (backend, worker, frontend) and optionally pushes
|
||||||
|
# them to both ghcr.io and Docker Hub under the 3252a8/ namespace.
|
||||||
|
#
|
||||||
|
# Called by:
|
||||||
|
# - docker-dev.yml (tag_mode: dev, push: true) on pushes to dev
|
||||||
|
# - docker-release.yml (tag_mode: release, push: true) on pushes to main
|
||||||
|
# - ci.yml (tag_mode: dev, push: false) on pull requests
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_call:
|
||||||
|
inputs:
|
||||||
|
push:
|
||||||
|
description: "Push the built images to the registries"
|
||||||
|
type: boolean
|
||||||
|
default: true
|
||||||
|
tag_mode:
|
||||||
|
description: "Tagging strategy: 'dev' or 'release'"
|
||||||
|
type: string
|
||||||
|
required: true
|
||||||
|
|
||||||
|
# No permissions block here on purpose: a reusable workflow cannot request more
|
||||||
|
# than its caller grants, so the token scope is set by each caller
|
||||||
|
# (docker-dev.yml / docker-release.yml grant packages: write to push; ci.yml
|
||||||
|
# only needs contents: read for a no-push build).
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
include:
|
||||||
|
- target: backend
|
||||||
|
image: remnawave-minishop-backend
|
||||||
|
- target: worker
|
||||||
|
image: remnawave-minishop-worker
|
||||||
|
- target: frontend
|
||||||
|
image: remnawave-minishop-frontend
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
# Full history + tags: the Dockerfile's version-builder stage runs
|
||||||
|
# `git describe --tags` against the copied .git tree.
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Resolve release version
|
||||||
|
id: version
|
||||||
|
if: inputs.tag_mode == 'release'
|
||||||
|
run: |
|
||||||
|
# On a tag push github.ref_name is the tag (e.g. v3.4.5); for a
|
||||||
|
# manual workflow_dispatch on a branch, fall back to the latest tag.
|
||||||
|
if [ "${{ github.ref_type }}" = "tag" ]; then
|
||||||
|
raw="${{ github.ref_name }}"
|
||||||
|
else
|
||||||
|
raw="$(git describe --tags --abbrev=0 2>/dev/null)"
|
||||||
|
fi
|
||||||
|
version="${raw#v}"
|
||||||
|
if [ -z "$version" ]; then
|
||||||
|
echo "::error::No git tag found to derive the release version from"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "version=${version}" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "Release version: ${version}"
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
- name: Log in to GitHub Container Registry
|
||||||
|
if: inputs.push
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: ghcr.io
|
||||||
|
username: ${{ github.actor }}
|
||||||
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Log in to Docker Hub
|
||||||
|
if: inputs.push
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Docker metadata
|
||||||
|
id: meta
|
||||||
|
uses: docker/metadata-action@v5
|
||||||
|
with:
|
||||||
|
images: |
|
||||||
|
3252a8/${{ matrix.image }}
|
||||||
|
ghcr.io/3252a8/${{ matrix.image }}
|
||||||
|
tags: |
|
||||||
|
type=raw,value=dev,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' }}
|
||||||
|
|
||||||
|
- name: Build${{ inputs.push && ' & push' || '' }} ${{ matrix.image }}
|
||||||
|
uses: docker/build-push-action@v6
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
file: deploy/docker/Dockerfile
|
||||||
|
target: ${{ matrix.target }}
|
||||||
|
platforms: linux/amd64
|
||||||
|
push: ${{ inputs.push }}
|
||||||
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
|
# The Dockerfile's version-builder appends a "-<branch>" suffix to the
|
||||||
|
# internal version string for non-main builds. Force "main" on release
|
||||||
|
# (the ref is the tag, not a branch) so release images stay un-suffixed.
|
||||||
|
build-args: |
|
||||||
|
GITHUB_REF_NAME=${{ inputs.tag_mode == 'release' && 'main' || github.ref_name }}
|
||||||
|
cache-from: type=gha,scope=${{ matrix.target }}
|
||||||
|
cache-to: type=gha,mode=max,scope=${{ matrix.target }}
|
||||||
|
provenance: false
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
name: PR checks
|
||||||
|
|
||||||
|
# Runs on pull requests into main (typically from dev) and into dev (typically
|
||||||
|
# from feature/* branches): lint + format checks and a no-push image build to
|
||||||
|
# prove the Docker images still build.
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
branches: [main, dev]
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: ci-${{ github.workflow }}-${{ github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
lint:
|
||||||
|
name: Lint & format
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Python
|
||||||
|
uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: "3.12"
|
||||||
|
|
||||||
|
- name: Install ruff
|
||||||
|
run: pip install "ruff>=0.8.0"
|
||||||
|
|
||||||
|
- name: Ruff lint (Python)
|
||||||
|
run: ruff check .
|
||||||
|
|
||||||
|
- name: Ruff format check (Python)
|
||||||
|
run: ruff format --check .
|
||||||
|
|
||||||
|
- name: Set up Node
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: "22"
|
||||||
|
cache: npm
|
||||||
|
cache-dependency-path: frontend/package-lock.json
|
||||||
|
|
||||||
|
- name: Install frontend deps
|
||||||
|
run: npm ci
|
||||||
|
working-directory: frontend
|
||||||
|
|
||||||
|
- name: ESLint (frontend)
|
||||||
|
run: npm run lint
|
||||||
|
working-directory: frontend
|
||||||
|
|
||||||
|
- name: Prettier check (frontend)
|
||||||
|
run: npm run format:check
|
||||||
|
working-directory: frontend
|
||||||
|
|
||||||
|
build:
|
||||||
|
name: Docker build
|
||||||
|
uses: ./.github/workflows/_docker-build-push.yml
|
||||||
|
with:
|
||||||
|
push: false
|
||||||
|
tag_mode: dev
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
name: Dependency review
|
||||||
|
|
||||||
|
# On PRs into main/dev, flag any newly added dependency that has a known
|
||||||
|
# vulnerability or an incompatible license before it gets merged.
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
branches: [main, dev]
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
dependency-review:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
pull-requests: write
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Dependency review
|
||||||
|
uses: actions/dependency-review-action@v4
|
||||||
|
with:
|
||||||
|
fail-on-severity: high
|
||||||
|
comment-summary-in-pr: on-failure
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
name: Dev images
|
||||||
|
|
||||||
|
# On every push to the dev branch, build all three images and push them to
|
||||||
|
# ghcr.io and Docker Hub tagged `dev` and `dev-<short-sha>`.
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [dev]
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: docker-dev-${{ github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
packages: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-push:
|
||||||
|
uses: ./.github/workflows/_docker-build-push.yml
|
||||||
|
with:
|
||||||
|
push: true
|
||||||
|
tag_mode: dev
|
||||||
|
secrets: inherit
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
name: Release images
|
||||||
|
|
||||||
|
# Build all three images and push them to ghcr.io and Docker Hub tagged
|
||||||
|
# `latest` and the release version (the pushed tag with its leading `v`
|
||||||
|
# stripped, e.g. v3.4.5 -> 3.4.5). Triggered only when a new v* tag is pushed,
|
||||||
|
# so images are built once per release rather than on every commit to main.
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- "v*"
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: docker-release-${{ github.ref }}
|
||||||
|
cancel-in-progress: false
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
packages: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-push:
|
||||||
|
uses: ./.github/workflows/_docker-build-push.yml
|
||||||
|
with:
|
||||||
|
push: true
|
||||||
|
tag_mode: release
|
||||||
|
secrets: inherit
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
name: Security
|
||||||
|
|
||||||
|
# Audits the full dependency set (pip-audit, npm audit) and runs a Trivy
|
||||||
|
# filesystem scan (dependencies + Dockerfile/IaC misconfig). Trivy results are
|
||||||
|
# uploaded to the Security -> Code scanning tab.
|
||||||
|
#
|
||||||
|
# pip-audit / npm audit are informational (continue-on-error): they surface
|
||||||
|
# upstream/transitive advisories that aren't necessarily fixable in a given PR,
|
||||||
|
# so they report in the logs without blocking merges. The PR gate for newly
|
||||||
|
# introduced vulnerable deps is dependency-review.yml.
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
branches: [main, dev]
|
||||||
|
push:
|
||||||
|
branches: [main, dev]
|
||||||
|
schedule:
|
||||||
|
- cron: "27 4 * * 1" # weekly, Monday 04:27 UTC
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: security-${{ github.workflow }}-${{ github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
python-audit:
|
||||||
|
name: pip-audit
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Python
|
||||||
|
uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: "3.12"
|
||||||
|
|
||||||
|
- name: Install pip-audit
|
||||||
|
run: pip install pip-audit
|
||||||
|
|
||||||
|
- name: Audit Python dependencies
|
||||||
|
continue-on-error: true
|
||||||
|
run: pip-audit -r backend/requirements.txt
|
||||||
|
|
||||||
|
npm-audit:
|
||||||
|
name: npm audit
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Node
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: "22"
|
||||||
|
cache: npm
|
||||||
|
cache-dependency-path: frontend/package-lock.json
|
||||||
|
|
||||||
|
- name: Install frontend deps
|
||||||
|
run: npm ci
|
||||||
|
working-directory: frontend
|
||||||
|
|
||||||
|
- name: Audit npm dependencies
|
||||||
|
continue-on-error: true
|
||||||
|
run: npm audit --audit-level=high
|
||||||
|
working-directory: frontend
|
||||||
|
|
||||||
|
trivy:
|
||||||
|
name: Trivy filesystem scan
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
security-events: write
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Run Trivy
|
||||||
|
uses: aquasecurity/trivy-action@v0.36.0
|
||||||
|
with:
|
||||||
|
scan-type: fs
|
||||||
|
scan-ref: .
|
||||||
|
format: sarif
|
||||||
|
output: trivy-results.sarif
|
||||||
|
severity: CRITICAL,HIGH
|
||||||
|
ignore-unfixed: true
|
||||||
|
|
||||||
|
- name: Upload Trivy results
|
||||||
|
uses: github/codeql-action/upload-sarif@v3
|
||||||
|
if: always()
|
||||||
|
with:
|
||||||
|
sarif_file: trivy-results.sarif
|
||||||
|
category: trivy-fs
|
||||||
@@ -11,6 +11,7 @@ from bot.services.email_auth_service import EmailAuthService
|
|||||||
from bot.services.lknpd_service import LknpdService
|
from bot.services.lknpd_service import LknpdService
|
||||||
from bot.services.notification_service import NotificationService
|
from bot.services.notification_service import NotificationService
|
||||||
from bot.services.panel_api_service import PanelApiService
|
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.panel_webhook_service import PanelWebhookService
|
||||||
from bot.services.promo_code_service import PromoCodeService
|
from bot.services.promo_code_service import PromoCodeService
|
||||||
from bot.services.referral_service import ReferralService
|
from bot.services.referral_service import ReferralService
|
||||||
@@ -26,7 +27,11 @@ def build_core_services(
|
|||||||
i18n: JsonI18n,
|
i18n: JsonI18n,
|
||||||
bot_username_for_default_return: str,
|
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)
|
subscription_service = SubscriptionService(settings, panel_service, bot, i18n)
|
||||||
referral_service = ReferralService(settings, subscription_service, bot, i18n)
|
referral_service = ReferralService(settings, subscription_service, bot, i18n)
|
||||||
promo_code_service = PromoCodeService(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 import MessageContent, send_message_via_queue
|
||||||
from bot.utils.message_queue import get_queue_manager
|
from bot.utils.message_queue import get_queue_manager
|
||||||
from config.settings import Settings
|
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 (
|
from db.dal import (
|
||||||
ad_dal,
|
ad_dal,
|
||||||
app_settings_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:
|
except (OSError, subprocess.SubprocessError, TimeoutError) as exc:
|
||||||
logger.exception("Backup restore failed")
|
logger.exception("Backup restore failed")
|
||||||
return _error(500, "backup_restore_failed", str(exc))
|
return _error(500, "backup_restore_failed", str(exc))
|
||||||
|
finally:
|
||||||
if result.database_restored:
|
|
||||||
try:
|
try:
|
||||||
from db import database_setup
|
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()
|
await database_setup.async_engine.dispose()
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception("Failed to dispose DB engine after backup restore")
|
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()
|
target = str(payload.get("target") or "all").strip().lower()
|
||||||
if not text:
|
if not text:
|
||||||
return _error(400, "empty_text")
|
return _error(400, "empty_text")
|
||||||
if target not in {"all", "active", "inactive"}:
|
if target not in {"all", "active", "inactive", "expired"}:
|
||||||
target = "all"
|
target = "all"
|
||||||
|
|
||||||
queue_manager = get_queue_manager()
|
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)
|
user_ids = await user_dal.get_user_ids_with_active_subscription(session)
|
||||||
elif target == "inactive":
|
elif target == "inactive":
|
||||||
user_ids = await user_dal.get_user_ids_without_active_subscription(session)
|
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:
|
else:
|
||||||
user_ids = await user_dal.get_all_active_user_ids_for_broadcast(session)
|
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", 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+}", 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_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+}/ban", admin_user_ban_route)
|
||||||
router.add_post("/api/admin/users/{user_id:-?\\d+}/message", admin_user_message_route)
|
router.add_post("/api/admin/users/{user_id:-?\\d+}/message", admin_user_message_route)
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ async def admin_stats_route(request: web.Request) -> web.Response:
|
|||||||
except Exception: # pragma: no cover - defensive
|
except Exception: # pragma: no cover - defensive
|
||||||
payload["queue"] = None
|
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)
|
return _ok(payload)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -21,9 +21,14 @@ async def admin_tariffs_get_route(request: web.Request) -> web.Response:
|
|||||||
"path": str(path),
|
"path": str(path),
|
||||||
"catalog": {
|
"catalog": {
|
||||||
"default_tariff": "",
|
"default_tariff": "",
|
||||||
|
"default_currency": "rub",
|
||||||
"topup_packages_default": {"rub": [], "stars": []},
|
"topup_packages_default": {"rub": [], "stars": []},
|
||||||
"tariffs": [],
|
"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,
|
"exists": True,
|
||||||
"path": str(path),
|
"path": str(path),
|
||||||
"catalog": _tariffs_config_payload(config),
|
"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=[])
|
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
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import hashlib
|
|||||||
from html import escape as html_escape
|
from html import escape as html_escape
|
||||||
|
|
||||||
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
|
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.app.web.webapp.cache_helpers import invalidate_webapp_user_caches
|
||||||
from bot.infra.redis import cache_delete_pattern, redis_key
|
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(
|
active_subs = await _bulk_active_subscriptions_for_users(
|
||||||
session, [u.user_id for u in 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 = []
|
serialized = []
|
||||||
for user in users:
|
for user in users:
|
||||||
payload = _serialize_user(user)
|
payload = _serialize_user(user)
|
||||||
status_payload = statuses.get(user.user_id) or {"status": "bot_only", "end_date": None}
|
status_payload = statuses.get(user.user_id) or {"status": "bot_only", "end_date": None}
|
||||||
payload["panel_status"] = status_payload.get("status")
|
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"):
|
if status_payload.get("status") == "expired" and status_payload.get("end_date"):
|
||||||
payload["panel_status_expired_at"] = status_payload["end_date"]
|
payload["panel_status_expired_at"] = status_payload["end_date"]
|
||||||
payload["avatar_url"] = (
|
payload["avatar_url"] = (
|
||||||
@@ -141,6 +145,11 @@ async def _load_admin_users_list_payload_uncached(
|
|||||||
else None
|
else None
|
||||||
)
|
)
|
||||||
payload["premium_traffic"] = _premium_traffic_list_payload(active_subs.get(user.user_id))
|
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)
|
serialized.append(payload)
|
||||||
|
|
||||||
return {
|
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}
|
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:
|
async def admin_user_avatar_route(request: web.Request) -> web.Response:
|
||||||
"""Serve the cached Telegram avatar for any user (admin-only).
|
"""Serve the cached Telegram avatar for any user (admin-only).
|
||||||
|
|
||||||
@@ -353,6 +373,88 @@ async def _bulk_active_subscriptions_for_users(
|
|||||||
return out
|
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(
|
async def _filter_and_sort_users(
|
||||||
session: AsyncSession,
|
session: AsyncSession,
|
||||||
*,
|
*,
|
||||||
@@ -381,6 +483,13 @@ async def _filter_and_sort_users(
|
|||||||
ratio_expr = None
|
ratio_expr = None
|
||||||
plim_expr = None
|
plim_expr = None
|
||||||
pu_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:
|
if needs_premium_sq:
|
||||||
sq = _ranked_active_subscriptions_sq(now)
|
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),
|
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)
|
search_cond = _user_search_condition(query)
|
||||||
if search_cond is not None:
|
if search_cond is not None:
|
||||||
stmt = stmt.where(search_cond)
|
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())
|
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":
|
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())
|
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:
|
else:
|
||||||
order = sort_map.get(sort_key, sort_map["registered_desc"])
|
order = sort_map.get(sort_key, sort_map["registered_desc"])
|
||||||
if isinstance(order, tuple):
|
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)
|
normalized_status == "active", blank_status & Subscription.is_active.is_(True)
|
||||||
)
|
)
|
||||||
elif status == "expired":
|
elif status == "expired":
|
||||||
status_cond = or_(
|
now = datetime.now(timezone.utc)
|
||||||
normalized_status == "expired", blank_status & Subscription.is_active.is_(False)
|
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:
|
else:
|
||||||
status_cond = normalized_status == "limited"
|
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()
|
recent_payments = (await session.execute(recent_payments_stmt)).scalars().all()
|
||||||
log_count = await message_log_dal.count_user_message_logs(session, target_id)
|
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 links — both the bot deep-link and the webapp deep-link.
|
||||||
referral_code: Optional[str] = None
|
referral_code: Optional[str] = None
|
||||||
@@ -635,11 +826,9 @@ async def admin_user_detail_route(request: web.Request) -> web.Response:
|
|||||||
exc_panel,
|
exc_panel,
|
||||||
)
|
)
|
||||||
|
|
||||||
serialized_user = _serialize_user(user)
|
serialized_user = _serialize_admin_user_with_avatar(user, avatar_keys)
|
||||||
serialized_user["avatar_url"] = (
|
serialized_inviter = (
|
||||||
f"/api/admin/users/{target_id}/avatar?v={avatar_keys[target_id]}"
|
_serialize_admin_user_with_avatar(inviter, avatar_keys) if inviter is not None else None
|
||||||
if target_id in avatar_keys
|
|
||||||
else None
|
|
||||||
)
|
)
|
||||||
|
|
||||||
return _ok(
|
return _ok(
|
||||||
@@ -655,11 +844,54 @@ async def admin_user_detail_route(request: web.Request) -> web.Response:
|
|||||||
"code": referral_code,
|
"code": referral_code,
|
||||||
"bot_link": referral_bot_link,
|
"bot_link": referral_bot_link,
|
||||||
"webapp_link": referral_webapp_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:
|
async def admin_user_ban_route(request: web.Request) -> web.Response:
|
||||||
_require_admin_user_id(request)
|
_require_admin_user_id(request)
|
||||||
target_id = int(request.match_info["user_id"])
|
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)
|
actor_id = _require_admin_user_id(request)
|
||||||
target_id = int(request.match_info["user_id"])
|
target_id = int(request.match_info["user_id"])
|
||||||
settings: Settings = request.app["settings"]
|
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_session_factory: sessionmaker = request.app["async_session_factory"]
|
||||||
async with async_session_factory() as session:
|
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:
|
if not user:
|
||||||
return _error(404, "not_found")
|
return _error(404, "not_found")
|
||||||
|
|
||||||
active = await subscription_dal.get_active_subscription_by_user_id(session, target_id)
|
reset_at = await user_dal.mark_trial_eligibility_reset(session, target_id)
|
||||||
if active:
|
if reset_at is None:
|
||||||
await session.delete(active)
|
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,
|
session,
|
||||||
{
|
{
|
||||||
"user_id": actor_id,
|
"user_id": actor_id,
|
||||||
"event_type": "admin_reset_trial_webapp",
|
"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,
|
"is_admin_event": True,
|
||||||
"target_user_id": target_id,
|
"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:
|
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)
|
actor_id = _require_admin_user_id(request)
|
||||||
target_id = int(request.match_info["user_id"])
|
target_id = int(request.match_info["user_id"])
|
||||||
settings: Settings = request.app["settings"]
|
settings: Settings = request.app["settings"]
|
||||||
|
|||||||
@@ -588,6 +588,17 @@ SETTINGS_MANIFEST: List[SettingField] = [
|
|||||||
),
|
),
|
||||||
SettingField("USER_TRAFFIC_LIMIT_GB", "float", "devices", "Лимит трафика пользователя (ГБ)"),
|
SettingField("USER_TRAFFIC_LIMIT_GB", "float", "devices", "Лимит трафика пользователя (ГБ)"),
|
||||||
SettingField("USER_TRAFFIC_STRATEGY", "string", "devices", "Стратегия сброса трафика"),
|
SettingField("USER_TRAFFIC_STRATEGY", "string", "devices", "Стратегия сброса трафика"),
|
||||||
|
# ─── System ────────────────────────────────────────────────────
|
||||||
|
SettingField(
|
||||||
|
"TELEMETRY_ENABLED",
|
||||||
|
"bool",
|
||||||
|
"system",
|
||||||
|
"Анонимная статистика установки",
|
||||||
|
"Раз в сутки отправляет обезличенный сигнал: версия, ОС, локаль и число "
|
||||||
|
"пользователей в виде диапазона. Без персональных данных, токенов и "
|
||||||
|
"доменов. Помогает понять число активных установок и какие версии "
|
||||||
|
"используются. Можно отключить здесь без перезапуска.",
|
||||||
|
),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@@ -722,6 +733,7 @@ def manifest_payload() -> List[dict]:
|
|||||||
"backups": 9,
|
"backups": 9,
|
||||||
"devices": 10,
|
"devices": 10,
|
||||||
"subscription_guides": 10,
|
"subscription_guides": 10,
|
||||||
|
"system": 12,
|
||||||
}
|
}
|
||||||
exclusive_map = {
|
exclusive_map = {
|
||||||
key: opposite
|
key: opposite
|
||||||
|
|||||||
@@ -109,6 +109,8 @@
|
|||||||
let attempted = false;
|
let attempted = false;
|
||||||
let pageLeft = false;
|
let pageLeft = false;
|
||||||
let state = "opening";
|
let state = "opening";
|
||||||
|
let closeAttemptTimer = null;
|
||||||
|
const CLOSE_ATTEMPT_DELAY_MS = 2500;
|
||||||
|
|
||||||
function hasControlChars(value) {
|
function hasControlChars(value) {
|
||||||
return Array.from(String(value || "")).some((char) => {
|
return Array.from(String(value || "")).some((char) => {
|
||||||
@@ -167,7 +169,10 @@
|
|||||||
function markDone() {
|
function markDone() {
|
||||||
if (state === "done" || isUnsafe) return;
|
if (state === "done" || isUnsafe) return;
|
||||||
render("done");
|
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() {
|
function notePageLeft() {
|
||||||
@@ -201,7 +206,6 @@
|
|||||||
render("done");
|
render("done");
|
||||||
});
|
});
|
||||||
window.addEventListener("pagehide", notePageLeft);
|
window.addEventListener("pagehide", notePageLeft);
|
||||||
window.addEventListener("blur", notePageLeft);
|
|
||||||
document.addEventListener("visibilitychange", () => {
|
document.addEventListener("visibilitychange", () => {
|
||||||
if (!attempted) return;
|
if (!attempted) return;
|
||||||
if (document.hidden) {
|
if (document.hidden) {
|
||||||
|
|||||||
@@ -55,6 +55,11 @@ from bot.utils.text_sanitizer import (
|
|||||||
sanitize_username,
|
sanitize_username,
|
||||||
)
|
)
|
||||||
from config.settings import Settings
|
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 import payment_dal, security_dal, subscription_dal, support_dal, user_dal
|
||||||
from db.dal.user_dal import UserMergeConflictError
|
from db.dal.user_dal import UserMergeConflictError
|
||||||
from db.models import Payment, User, UserTelegramAvatar
|
from db.models import Payment, User, UserTelegramAvatar
|
||||||
|
|||||||
@@ -934,87 +934,12 @@ def _get_cached_webapp_settings(request: web.Request) -> Dict[str, Any]:
|
|||||||
return cache["data"]
|
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:
|
def _resolve_app_version() -> str:
|
||||||
global _APP_VERSION_CACHE
|
# Single source of truth shared with the telemetry worker so the admin
|
||||||
if _APP_VERSION_CACHE:
|
# sidebar and the install beacon always report the same version.
|
||||||
return _APP_VERSION_CACHE
|
from bot.utils.app_version import resolve_app_version
|
||||||
|
|
||||||
env_version = os.getenv("REMNAWAVE_MINISHOP_VERSION", "").strip()
|
return resolve_app_version()
|
||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
async def _enforce_webapp_rate_limit(
|
async def _enforce_webapp_rate_limit(
|
||||||
|
|||||||
@@ -103,6 +103,8 @@ async def create_payment_route(request: web.Request) -> web.Response:
|
|||||||
subscription_service: SubscriptionService = request.app["subscription_service"]
|
subscription_service: SubscriptionService = request.app["subscription_service"]
|
||||||
cached = _get_cached_webapp_settings(request)
|
cached = _get_cached_webapp_settings(request)
|
||||||
tariffs_config = settings.tariffs_config
|
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)
|
traffic_mode = bool(settings.traffic_sale_mode)
|
||||||
sale_mode = "subscription"
|
sale_mode = "subscription"
|
||||||
traffic_gb_for_payment: Optional[float] = None
|
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"
|
if requested_sale_mode == "premium_topup"
|
||||||
else tariffs_config.topup_packages_for(tariff)
|
else tariffs_config.topup_packages_for(tariff)
|
||||||
)
|
)
|
||||||
rub_packages = {
|
currency_packages = {
|
||||||
float(package.gb): float(package.price)
|
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 = {
|
stars_packages = {
|
||||||
float(package.gb): int(float(package.price))
|
float(package.gb): int(float(package.price))
|
||||||
for package in (packages.stars if packages else [])
|
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)
|
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_price = (
|
||||||
stars_packages.get(stars_package_key) if stars_package_key is not None else None
|
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")
|
return _json_error(400, "invalid_plan", "Invalid traffic package")
|
||||||
if traffic_gb <= 0:
|
if traffic_gb <= 0:
|
||||||
return _json_error(400, "invalid_plan", "Invalid traffic package")
|
return _json_error(400, "invalid_plan", "Invalid traffic package")
|
||||||
rub_packages = {
|
currency_packages = {
|
||||||
float(package.gb): float(package.price)
|
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 = {
|
stars_packages = {
|
||||||
float(package.gb): int(float(package.price))
|
float(package.gb): int(float(package.price))
|
||||||
for package in (tariff.traffic_packages.stars if tariff.traffic_packages else [])
|
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)
|
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_price = (
|
||||||
stars_packages.get(stars_package_key) if stars_package_key is not None else None
|
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")
|
return _json_error(400, "invalid_plan", "Invalid subscription period")
|
||||||
if months not in tariff.enabled_periods:
|
if months not in tariff.enabled_periods:
|
||||||
return _json_error(400, "invalid_plan", "Subscription period is not available")
|
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_raw = tariff.period_price(months, "stars")
|
||||||
stars_price = int(stars_price_raw) if stars_price_raw and stars_price_raw > 0 else None
|
stars_price = int(stars_price_raw) if stars_price_raw and stars_price_raw > 0 else None
|
||||||
if price is None and method != "stars":
|
if price is None and method != "stars":
|
||||||
@@ -296,7 +302,7 @@ async def create_payment_route(request: web.Request) -> web.Response:
|
|||||||
active_tariff = None
|
active_tariff = None
|
||||||
if not active_tariff or active_tariff.billing_model != "period":
|
if not active_tariff or active_tariff.billing_model != "period":
|
||||||
return _json_error(400, "invalid_plan", "Device top-up is not available")
|
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(
|
hwid_quote = await subscription_service.quote_hwid_device_topup(
|
||||||
session,
|
session,
|
||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
@@ -325,6 +331,7 @@ async def create_payment_route(request: web.Request) -> web.Response:
|
|||||||
months=payment_units,
|
months=payment_units,
|
||||||
price=float(price or 0),
|
price=float(price or 0),
|
||||||
stars_price=stars_price,
|
stars_price=stars_price,
|
||||||
|
currency=default_currency_code,
|
||||||
lang=lang,
|
lang=lang,
|
||||||
sale_mode=sale_mode,
|
sale_mode=sale_mode,
|
||||||
traffic_gb=traffic_gb_for_payment,
|
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()
|
tariff_key = str(payment_payload.tariff_key or "").strip()
|
||||||
settings: Settings = request.app["settings"]
|
settings: Settings = request.app["settings"]
|
||||||
config = settings.tariffs_config
|
config = settings.tariffs_config
|
||||||
|
default_currency_code = default_payment_currency_code_for_settings(settings)
|
||||||
if not config:
|
if not config:
|
||||||
return _json_error(404, "tariffs_unavailable", "Tariffs are not configured")
|
return _json_error(404, "tariffs_unavailable", "Tariffs are not configured")
|
||||||
if not tariff_key:
|
if not tariff_key:
|
||||||
@@ -618,6 +626,7 @@ async def tariff_change_payment_route(request: web.Request) -> web.Response:
|
|||||||
months=1,
|
months=1,
|
||||||
price=price,
|
price=price,
|
||||||
stars_price=None,
|
stars_price=None,
|
||||||
|
currency=default_currency_code,
|
||||||
lang=db_user.language_code or settings.DEFAULT_LANGUAGE,
|
lang=db_user.language_code or settings.DEFAULT_LANGUAGE,
|
||||||
sale_mode=f"tariff_upgrade@{target.key}",
|
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
|
active.get("extra_hwid_devices_valid_until_text") if active else None
|
||||||
) or _billing_datetime_text(extra_hwid_valid_until)
|
) or _billing_datetime_text(extra_hwid_valid_until)
|
||||||
packages = tariff.hwid_device_packages
|
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 [])}
|
stars_counts = {int(package.count) for package in (packages.stars if packages else [])}
|
||||||
plans = []
|
plans = []
|
||||||
for count in sorted(rub_counts | stars_counts):
|
for count in sorted(currency_counts | stars_counts):
|
||||||
rub_quote = (
|
currency_quote = (
|
||||||
await subscription_service.quote_hwid_device_topup(
|
await subscription_service.quote_hwid_device_topup(
|
||||||
session,
|
session,
|
||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
device_count=count,
|
device_count=count,
|
||||||
tariff_key=tariff.key,
|
tariff_key=tariff.key,
|
||||||
renewal=renewal_available,
|
renewal=renewal_available,
|
||||||
currency="rub",
|
currency=default_currency,
|
||||||
)
|
)
|
||||||
if count in rub_counts
|
if count in currency_counts
|
||||||
else None
|
else None
|
||||||
)
|
)
|
||||||
stars_quote = (
|
stars_quote = (
|
||||||
@@ -684,7 +699,7 @@ async def device_topup_options_route(request: web.Request) -> web.Response:
|
|||||||
if count in stars_counts
|
if count in stars_counts
|
||||||
else None
|
else None
|
||||||
)
|
)
|
||||||
if not rub_quote and not stars_quote:
|
if not currency_quote and not stars_quote:
|
||||||
continue
|
continue
|
||||||
sale_mode_for_plan = "hwid_devices_renewal" if renewal_available else "hwid_devices"
|
sale_mode_for_plan = "hwid_devices_renewal" if renewal_available else "hwid_devices"
|
||||||
plan = {
|
plan = {
|
||||||
@@ -695,13 +710,19 @@ async def device_topup_options_route(request: web.Request) -> web.Response:
|
|||||||
"sale_mode": sale_mode_for_plan,
|
"sale_mode": sale_mode_for_plan,
|
||||||
"months": count,
|
"months": count,
|
||||||
"device_count": count,
|
"device_count": count,
|
||||||
"price": float(rub_quote.get("price") if rub_quote else 0),
|
"price": float(currency_quote.get("price") if currency_quote else 0),
|
||||||
"currency": settings.DEFAULT_CURRENCY_SYMBOL or "RUB",
|
"currency": default_currency_code,
|
||||||
"title": f"+{count}",
|
"title": f"+{count}",
|
||||||
"subtitle": tariff.name(lang),
|
"subtitle": tariff.name(lang),
|
||||||
"valid_from": _billing_iso_datetime((rub_quote or stars_quote).get("valid_from")),
|
"valid_from": _billing_iso_datetime(
|
||||||
"valid_until": _billing_iso_datetime((rub_quote or stars_quote).get("valid_until")),
|
(currency_quote or stars_quote).get("valid_from")
|
||||||
"proration_ratio": float((rub_quote or stars_quote).get("proration_ratio") or 0),
|
),
|
||||||
|
"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:
|
if stars_quote and int(stars_quote.get("price") or 0) > 0:
|
||||||
plan["stars_price"] = int(stars_quote["price"])
|
plan["stars_price"] = int(stars_quote["price"])
|
||||||
@@ -929,12 +950,14 @@ async def _create_subscription_payment(
|
|||||||
price: float,
|
price: float,
|
||||||
stars_price: Optional[int],
|
stars_price: Optional[int],
|
||||||
lang: str,
|
lang: str,
|
||||||
|
currency: Optional[str] = None,
|
||||||
sale_mode: str = "subscription",
|
sale_mode: str = "subscription",
|
||||||
traffic_gb: Optional[float] = None,
|
traffic_gb: Optional[float] = None,
|
||||||
is_admin: bool = False,
|
is_admin: bool = False,
|
||||||
hwid_quote: Optional[Dict[str, Any]] = None,
|
hwid_quote: Optional[Dict[str, Any]] = None,
|
||||||
) -> web.Response:
|
) -> web.Response:
|
||||||
settings: Settings = request.app["settings"]
|
settings: Settings = request.app["settings"]
|
||||||
|
payment_currency = (currency or default_payment_currency_code_for_settings(settings)).upper()
|
||||||
sale_mode = str(sale_mode or "subscription")
|
sale_mode = str(sale_mode or "subscription")
|
||||||
traffic_sale = _sale_mode_is_traffic(sale_mode)
|
traffic_sale = _sale_mode_is_traffic(sale_mode)
|
||||||
hwid_devices_sale = _sale_mode_is_hwid_devices(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),
|
provider_spec.is_service_configured(request.app),
|
||||||
)
|
)
|
||||||
return _json_error(400, "payment_unavailable", "Payment method unavailable")
|
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(
|
return await provider_spec.create_webapp_payment(
|
||||||
WebAppPaymentContext(
|
WebAppPaymentContext(
|
||||||
request=request,
|
request=request,
|
||||||
@@ -967,6 +1001,7 @@ async def _create_subscription_payment(
|
|||||||
months=months,
|
months=months,
|
||||||
price=price,
|
price=price,
|
||||||
stars_price=stars_price,
|
stars_price=stars_price,
|
||||||
|
currency=payment_currency,
|
||||||
description=description,
|
description=description,
|
||||||
sale_mode=sale_mode,
|
sale_mode=sale_mode,
|
||||||
traffic_gb=traffic_gb,
|
traffic_gb=traffic_gb,
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ async def _build_user_payload(request: web.Request, user_id: int) -> Dict[str, A
|
|||||||
trial_available = bool(
|
trial_available = bool(
|
||||||
settings.TRIAL_ENABLED
|
settings.TRIAL_ENABLED
|
||||||
and settings.TRIAL_DURATION_DAYS > 0
|
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)
|
avatar = await _ensure_cached_telegram_avatar(request, session, db_user)
|
||||||
try:
|
try:
|
||||||
@@ -472,6 +472,8 @@ def _serialize_plans(
|
|||||||
) -> List[Dict[str, Any]]:
|
) -> List[Dict[str, Any]]:
|
||||||
tariffs_config = settings.tariffs_config
|
tariffs_config = settings.tariffs_config
|
||||||
if 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]] = []
|
plans: List[Dict[str, Any]] = []
|
||||||
for tariff in tariffs_config.enabled_tariffs:
|
for tariff in tariffs_config.enabled_tariffs:
|
||||||
common = {
|
common = {
|
||||||
@@ -481,7 +483,7 @@ def _serialize_plans(
|
|||||||
"billing_model": tariff.billing_model,
|
"billing_model": tariff.billing_model,
|
||||||
"description": tariff.description(lang),
|
"description": tariff.description(lang),
|
||||||
"squad_uuids": tariff.squad_uuids,
|
"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_limit": tariff.hwid_device_limit,
|
||||||
"hwid_device_packages": _serialize_hwid_device_packages(
|
"hwid_device_packages": _serialize_hwid_device_packages(
|
||||||
settings,
|
settings,
|
||||||
@@ -494,7 +496,7 @@ def _serialize_plans(
|
|||||||
}
|
}
|
||||||
if tariff.billing_model == "period":
|
if tariff.billing_model == "period":
|
||||||
for months in sorted(tariff.enabled_periods):
|
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")
|
stars_price = tariff.period_price(int(months), "stars")
|
||||||
if price is None and (stars_price is None or int(stars_price) <= 0):
|
if price is None and (stars_price is None or int(stars_price) <= 0):
|
||||||
continue
|
continue
|
||||||
@@ -512,9 +514,13 @@ def _serialize_plans(
|
|||||||
plan["stars_price"] = int(stars_price)
|
plan["stars_price"] = int(stars_price)
|
||||||
plans.append(plan)
|
plans.append(plan)
|
||||||
else:
|
else:
|
||||||
rub_packages = {
|
currency_packages = {
|
||||||
float(package.gb): float(package.price)
|
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 = {
|
stars_packages = {
|
||||||
float(package.gb): int(float(package.price))
|
float(package.gb): int(float(package.price))
|
||||||
@@ -522,8 +528,8 @@ def _serialize_plans(
|
|||||||
tariff.traffic_packages.stars if tariff.traffic_packages else []
|
tariff.traffic_packages.stars if tariff.traffic_packages else []
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
for traffic_gb in sorted(set(rub_packages) | set(stars_packages)):
|
for traffic_gb in sorted(set(currency_packages) | set(stars_packages)):
|
||||||
price = rub_packages.get(traffic_gb)
|
price = currency_packages.get(traffic_gb)
|
||||||
stars_price = stars_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):
|
if price is None and (stars_price is None or int(stars_price) <= 0):
|
||||||
continue
|
continue
|
||||||
@@ -609,16 +615,19 @@ def _serialize_topup_packages(
|
|||||||
sale_mode: str = "topup",
|
sale_mode: str = "topup",
|
||||||
title_prefix: str = "",
|
title_prefix: str = "",
|
||||||
) -> List[Dict[str, Any]]:
|
) -> List[Dict[str, Any]]:
|
||||||
rub_packages = {
|
default_currency = default_currency_key_for_settings(settings)
|
||||||
float(package.gb): float(package.price) for package in (packages.rub if packages else [])
|
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 = {
|
stars_packages = {
|
||||||
float(package.gb): int(float(package.price))
|
float(package.gb): int(float(package.price))
|
||||||
for package in (packages.stars if packages else [])
|
for package in (packages.stars if packages else [])
|
||||||
}
|
}
|
||||||
plans: List[Dict[str, Any]] = []
|
plans: List[Dict[str, Any]] = []
|
||||||
for traffic_gb in sorted(set(rub_packages) | set(stars_packages)):
|
for traffic_gb in sorted(set(currency_packages) | set(stars_packages)):
|
||||||
price = rub_packages.get(traffic_gb)
|
price = currency_packages.get(traffic_gb)
|
||||||
stars_price = stars_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):
|
if price is None and (stars_price is None or int(stars_price) <= 0):
|
||||||
continue
|
continue
|
||||||
@@ -632,7 +641,7 @@ def _serialize_topup_packages(
|
|||||||
"months": int(traffic_value) if traffic_value.is_integer() else traffic_value,
|
"months": int(traffic_value) if traffic_value.is_integer() else traffic_value,
|
||||||
"traffic_gb": traffic_value,
|
"traffic_gb": traffic_value,
|
||||||
"price": float(price or 0),
|
"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)}",
|
"title": f"{title_prefix}{_format_traffic_title(traffic_value, lang)}",
|
||||||
"subtitle": tariff.premium_name(lang)
|
"subtitle": tariff.premium_name(lang)
|
||||||
if sale_mode == "premium_topup"
|
if sale_mode == "premium_topup"
|
||||||
@@ -650,16 +659,19 @@ def _serialize_hwid_device_packages(
|
|||||||
packages: Optional[Any],
|
packages: Optional[Any],
|
||||||
lang: str,
|
lang: str,
|
||||||
) -> List[Dict[str, Any]]:
|
) -> List[Dict[str, Any]]:
|
||||||
rub_packages = {
|
default_currency = default_currency_key_for_settings(settings)
|
||||||
int(package.count): float(package.price) for package in (packages.rub if packages else [])
|
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 = {
|
stars_packages = {
|
||||||
int(package.count): int(float(package.price))
|
int(package.count): int(float(package.price))
|
||||||
for package in (packages.stars if packages else [])
|
for package in (packages.stars if packages else [])
|
||||||
}
|
}
|
||||||
plans: List[Dict[str, Any]] = []
|
plans: List[Dict[str, Any]] = []
|
||||||
for count in sorted(set(rub_packages) | set(stars_packages)):
|
for count in sorted(set(currency_packages) | set(stars_packages)):
|
||||||
price = rub_packages.get(count)
|
price = currency_packages.get(count)
|
||||||
stars_price = stars_packages.get(count)
|
stars_price = stars_packages.get(count)
|
||||||
if price is None and (stars_price is None or int(stars_price) <= 0):
|
if price is None and (stars_price is None or int(stars_price) <= 0):
|
||||||
continue
|
continue
|
||||||
@@ -672,7 +684,7 @@ def _serialize_hwid_device_packages(
|
|||||||
"months": int(count),
|
"months": int(count),
|
||||||
"device_count": int(count),
|
"device_count": int(count),
|
||||||
"price": float(price or 0),
|
"price": float(price or 0),
|
||||||
"currency": settings.DEFAULT_CURRENCY_SYMBOL or "RUB",
|
"currency": default_currency_code,
|
||||||
"title": f"+{count}",
|
"title": f"+{count}",
|
||||||
"subtitle": tariff.name(lang),
|
"subtitle": tariff.name(lang),
|
||||||
}
|
}
|
||||||
@@ -689,6 +701,8 @@ def _serialize_tariff_change_target(
|
|||||||
options: Dict[str, Any],
|
options: Dict[str, Any],
|
||||||
lang: str,
|
lang: str,
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
|
default_currency = default_currency_key_for_settings(settings)
|
||||||
|
default_currency_code = payment_currency_code(default_currency)
|
||||||
actions: List[Dict[str, Any]] = []
|
actions: List[Dict[str, Any]] = []
|
||||||
mode = str(options.get("mode") or "")
|
mode = str(options.get("mode") or "")
|
||||||
if mode == "period_to_period":
|
if mode == "period_to_period":
|
||||||
@@ -711,7 +725,7 @@ def _serialize_tariff_change_target(
|
|||||||
"kind": "payment",
|
"kind": "payment",
|
||||||
"title": "paid_diff",
|
"title": "paid_diff",
|
||||||
"price": paid_diff,
|
"price": paid_diff,
|
||||||
"currency": settings.DEFAULT_CURRENCY_SYMBOL or "RUB",
|
"currency": default_currency_code,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
elif mode == "period_to_traffic":
|
elif mode == "period_to_traffic":
|
||||||
@@ -733,13 +747,17 @@ def _serialize_tariff_change_target(
|
|||||||
"title": f"+{package.gb:g} GB",
|
"title": f"+{package.gb:g} GB",
|
||||||
"traffic_gb": float(package.gb),
|
"traffic_gb": float(package.gb),
|
||||||
"price": float(package.price),
|
"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:
|
else:
|
||||||
for months in tariff.enabled_periods:
|
for months in tariff.enabled_periods:
|
||||||
price = tariff.period_price(int(months), "rub")
|
price = tariff.period_price(int(months), default_currency)
|
||||||
if price:
|
if price:
|
||||||
actions.append(
|
actions.append(
|
||||||
{
|
{
|
||||||
@@ -748,7 +766,7 @@ def _serialize_tariff_change_target(
|
|||||||
"months": int(months),
|
"months": int(months),
|
||||||
"title": _format_months_title(int(months), lang),
|
"title": _format_months_title(int(months), lang),
|
||||||
"price": float(price),
|
"price": float(price),
|
||||||
"currency": settings.DEFAULT_CURRENCY_SYMBOL or "RUB",
|
"currency": default_currency_code,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
return {
|
return {
|
||||||
@@ -772,10 +790,15 @@ def _serialize_payment_methods(
|
|||||||
from bot.payment_providers import get_provider_spec, resolve_provider_presentation
|
from bot.payment_providers import get_provider_spec, resolve_provider_presentation
|
||||||
|
|
||||||
methods: List[Dict[str, Any]] = []
|
methods: List[Dict[str, Any]] = []
|
||||||
|
payment_currency = default_payment_currency_code_for_settings(settings)
|
||||||
for method in settings.payment_methods_order:
|
for method in settings.payment_methods_order:
|
||||||
method = method.lower()
|
method = method.lower()
|
||||||
spec = get_provider_spec(method)
|
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)
|
presentation = resolve_provider_presentation(spec, settings, language=lang)
|
||||||
methods.append(
|
methods.append(
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -155,7 +155,7 @@ async def change_broadcast_target_handler(
|
|||||||
return
|
return
|
||||||
|
|
||||||
new_target = callback.data.split(":")[1]
|
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)
|
await callback.answer("Unknown target.", show_alert=True)
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -247,6 +247,8 @@ async def confirm_broadcast_callback_handler(
|
|||||||
user_ids = await user_dal.get_user_ids_with_active_subscription(session)
|
user_ids = await user_dal.get_user_ids_with_active_subscription(session)
|
||||||
elif target == "inactive":
|
elif target == "inactive":
|
||||||
user_ids = await user_dal.get_user_ids_without_active_subscription(session)
|
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:
|
else:
|
||||||
user_ids = await user_dal.get_all_active_user_ids_for_broadcast(session)
|
user_ids = await user_dal.get_all_active_user_ids_for_broadcast(session)
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ from bot.middlewares.i18n import JsonI18n
|
|||||||
from bot.payment_providers import pending_statuses
|
from bot.payment_providers import pending_statuses
|
||||||
from bot.services.panel_api_service import PanelApiService
|
from bot.services.panel_api_service import PanelApiService
|
||||||
from config.settings import Settings
|
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.dal import panel_sync_dal, payment_dal, user_dal
|
||||||
from db.models import PanelSyncStatus, Payment
|
from db.models import PanelSyncStatus, Payment
|
||||||
|
|
||||||
@@ -71,11 +72,17 @@ async def show_statistics_handler(
|
|||||||
f"📊 {_('admin_user_stats_total_label')}: <b>{user_stats['total_users']}</b>"
|
f"📊 {_('admin_user_stats_total_label')}: <b>{user_stats['total_users']}</b>"
|
||||||
)
|
)
|
||||||
# Removed: Active today moved to panel stats
|
# Removed: Active today moved to panel stats
|
||||||
|
stats_text_parts.append(
|
||||||
|
f"📡 {_('admin_user_stats_active_subscription_label')}: <b>{user_stats['active_subscriptions']}</b>" # noqa: E501
|
||||||
|
)
|
||||||
stats_text_parts.append(
|
stats_text_parts.append(
|
||||||
f"💳 {_('admin_user_stats_paid_subs_label')}: <b>{user_stats['paid_subscriptions']}</b>"
|
f"💳 {_('admin_user_stats_paid_subs_label')}: <b>{user_stats['paid_subscriptions']}</b>"
|
||||||
)
|
)
|
||||||
stats_text_parts.append(
|
stats_text_parts.append(
|
||||||
f"🆓 {_('admin_user_stats_trial_label')}: <b>{user_stats['trial_users']}</b>"
|
f"🧪 {_('admin_user_stats_trial_label')}: <b>{user_stats['trial_users']}</b>"
|
||||||
|
)
|
||||||
|
stats_text_parts.append(
|
||||||
|
f"🎁 {_('admin_user_stats_free_subscription_label')}: <b>{user_stats['free_subscription_users']}</b>" # noqa: E501
|
||||||
)
|
)
|
||||||
stats_text_parts.append(
|
stats_text_parts.append(
|
||||||
f"😴 {_('admin_user_stats_inactive_label')}: <b>{user_stats['inactive_users']}</b>"
|
f"😴 {_('admin_user_stats_inactive_label')}: <b>{user_stats['inactive_users']}</b>"
|
||||||
@@ -189,19 +196,20 @@ async def show_statistics_handler(
|
|||||||
|
|
||||||
# Financial statistics
|
# Financial statistics
|
||||||
financial_stats = await payment_dal.get_financial_statistics(session)
|
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"\n<b>💰 {_('admin_financial_stats_header')}</b>")
|
||||||
stats_text_parts.append(
|
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(
|
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(
|
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(
|
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(
|
last_payments_models: List[Payment] = await payment_dal.get_recent_payment_logs_with_user(
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ from bot.utils.text_sanitizer import (
|
|||||||
username_for_display,
|
username_for_display,
|
||||||
)
|
)
|
||||||
from config.settings import Settings
|
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.dal import message_log_dal, subscription_dal, user_dal
|
||||||
from db.models import User
|
from db.models import User
|
||||||
|
|
||||||
@@ -88,6 +89,31 @@ async def _find_user_by_admin_input(
|
|||||||
return None
|
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(
|
async def users_list_handler(
|
||||||
callback: types.CallbackQuery,
|
callback: types.CallbackQuery,
|
||||||
i18n_data: dict,
|
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}"
|
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(
|
builder.button(
|
||||||
text=_(key="admin_user_premium_override_button"),
|
text=_(key="admin_user_premium_override_button"),
|
||||||
callback_data=f"user_action:premium_override:{user_id}",
|
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)
|
quick_links_count = (1 if has_self_link else 0) + (1 if has_referrer_link else 0)
|
||||||
if quick_links_count == 0:
|
if quick_links_count == 0:
|
||||||
builder.adjust(2, 2, 2, 2, 1, 2)
|
builder.adjust(2, 2, 2, 1, 2, 1, 2)
|
||||||
else:
|
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
|
return builder
|
||||||
|
|
||||||
|
|
||||||
@@ -314,7 +346,11 @@ async def format_user_card(
|
|||||||
|
|
||||||
# Referral info
|
# Referral info
|
||||||
if user.referred_by_id:
|
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
|
# Panel info
|
||||||
if user.panel_user_uuid:
|
if user.panel_user_uuid:
|
||||||
@@ -407,17 +443,18 @@ async def format_user_card(
|
|||||||
try:
|
try:
|
||||||
from db.dal import payment_dal
|
from db.dal import payment_dal
|
||||||
|
|
||||||
|
currency = default_payment_currency_code_for_settings(settings)
|
||||||
|
|
||||||
# Total amount paid by this user
|
# Total amount paid by this user
|
||||||
total_paid = await payment_dal.get_user_total_paid(session, user.user_id)
|
total_paid = await payment_dal.get_user_total_paid(session, user.user_id)
|
||||||
card_parts.append(
|
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
|
# Total revenue from referrals
|
||||||
referral_revenue = await payment_dal.get_referral_revenue(session, user.user_id)
|
referral_revenue = await payment_dal.get_referral_revenue(session, user.user_id)
|
||||||
card_parts.append(
|
referral_revenue_text = hcode(f"{referral_revenue:.2f} {currency}")
|
||||||
f"{_('admin_user_referral_revenue_label')} {hcode(f'{referral_revenue:.2f} RUB')}"
|
card_parts.append(f"{_('admin_user_referral_revenue_label')} {referral_revenue_text}")
|
||||||
)
|
|
||||||
except Exception as e_fin:
|
except Exception as e_fin:
|
||||||
logging.error(
|
logging.error(
|
||||||
f"Failed to build financial analytics for admin card {user.user_id}: {e_fin}"
|
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)
|
await handle_send_message_prompt(callback, state, user, i18n, current_lang)
|
||||||
elif action == "view_logs":
|
elif action == "view_logs":
|
||||||
await handle_view_user_logs(callback, user, session, settings, i18n, current_lang)
|
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":
|
elif action == "refresh":
|
||||||
await handle_refresh_user_card(
|
await handle_refresh_user_card(
|
||||||
callback, user, subscription_service, session, settings, i18n, current_lang
|
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)
|
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Delete all user subscriptions to reset trial eligibility
|
await user_dal.mark_trial_eligibility_reset(session, user.user_id)
|
||||||
await subscription_dal.delete_all_user_subscriptions(session, user.user_id)
|
|
||||||
await session.commit()
|
await session.commit()
|
||||||
|
|
||||||
await callback.answer(_("admin_user_trial_reset_success"), show_alert=True)
|
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)
|
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(
|
async def handle_refresh_user_card(
|
||||||
callback: types.CallbackQuery,
|
callback: types.CallbackQuery,
|
||||||
user: User,
|
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}"
|
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
|
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
|
# Format user card
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -166,8 +166,10 @@ async def create_user_stats_result(
|
|||||||
"inline_user_stats_message",
|
"inline_user_stats_message",
|
||||||
total=user_stats["total_users"],
|
total=user_stats["total_users"],
|
||||||
active_today=user_stats["active_today"],
|
active_today=user_stats["active_today"],
|
||||||
|
active=user_stats["active_subscriptions"],
|
||||||
paid=user_stats["paid_subscriptions"],
|
paid=user_stats["paid_subscriptions"],
|
||||||
trial=user_stats["trial_users"],
|
trial=user_stats["trial_users"],
|
||||||
|
free=user_stats["free_subscription_users"],
|
||||||
inactive=user_stats["inactive_users"],
|
inactive=user_stats["inactive_users"],
|
||||||
banned=user_stats["banned_users"],
|
banned=user_stats["banned_users"],
|
||||||
referral=user_stats["referral_users"],
|
referral=user_stats["referral_users"],
|
||||||
@@ -179,7 +181,7 @@ async def create_user_stats_result(
|
|||||||
description=_(
|
description=_(
|
||||||
"inline_user_stats_description",
|
"inline_user_stats_description",
|
||||||
total=user_stats["total_users"],
|
total=user_stats["total_users"],
|
||||||
active=user_stats["paid_subscriptions"],
|
active=user_stats["active_subscriptions"],
|
||||||
),
|
),
|
||||||
input_message_content=InputTextMessageContent(
|
input_message_content=InputTextMessageContent(
|
||||||
message_text=stats_text, parse_mode="HTML"
|
message_text=stats_text, parse_mode="HTML"
|
||||||
|
|||||||
@@ -24,6 +24,10 @@ from bot.services.referral_service import ReferralService
|
|||||||
from bot.services.subscription_service import SubscriptionService
|
from bot.services.subscription_service import SubscriptionService
|
||||||
from bot.services.telegram_notifications import TELEGRAM_NOTIFICATIONS_ENABLED
|
from bot.services.telegram_notifications import TELEGRAM_NOTIFICATIONS_ENABLED
|
||||||
from bot.utils.callback_answer import safe_answer_callback
|
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 (
|
from bot.utils.install_links import (
|
||||||
append_install_share_link_text,
|
append_install_share_link_text,
|
||||||
ensure_user_install_guide_links,
|
ensure_user_install_guide_links,
|
||||||
@@ -45,12 +49,12 @@ async def should_show_trial_button(
|
|||||||
if not settings.TRIAL_ENABLED:
|
if not settings.TRIAL_ENABLED:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
if hasattr(subscription_service, "has_had_any_subscription") and callable(
|
if hasattr(subscription_service, "has_trial_blocking_subscription") and callable(
|
||||||
getattr(subscription_service, "has_had_any_subscription")
|
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
|
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).
|
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.
|
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:
|
if not required_channel_id:
|
||||||
return True
|
return True
|
||||||
|
|
||||||
@@ -279,6 +283,29 @@ async def ensure_required_channel_subscription(
|
|||||||
if status_value in allowed_statuses:
|
if status_value in allowed_statuses:
|
||||||
is_member = True
|
is_member = True
|
||||||
except TelegramBadRequest as bad_request:
|
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(
|
logging.info(
|
||||||
"Required channel check: user %s not subscribed (details: %s)",
|
"Required channel check: user %s not subscribed (details: %s)",
|
||||||
user_id,
|
user_id,
|
||||||
|
|||||||
@@ -32,6 +32,10 @@ from bot.utils.install_links import (
|
|||||||
ensure_user_install_guide_links,
|
ensure_user_install_guide_links,
|
||||||
)
|
)
|
||||||
from config.settings import Settings
|
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.dal import subscription_dal, user_billing_dal
|
||||||
from db.models import Subscription
|
from db.models import Subscription
|
||||||
|
|
||||||
@@ -80,11 +84,13 @@ def _tariff_purchase_markup(
|
|||||||
back_callback=back_callback,
|
back_callback=back_callback,
|
||||||
callback_context=callback_context,
|
callback_context=callback_context,
|
||||||
)
|
)
|
||||||
|
default_currency = default_currency_key_for_settings(settings)
|
||||||
return get_tariff_packages_keyboard(
|
return get_tariff_packages_keyboard(
|
||||||
tariff,
|
tariff,
|
||||||
tariff.traffic_packages.rub,
|
tariff.traffic_packages.for_currency(default_currency),
|
||||||
current_lang,
|
current_lang,
|
||||||
i18n,
|
i18n,
|
||||||
|
currency_symbol=default_payment_currency_code_for_settings(settings),
|
||||||
back_callback=back_callback,
|
back_callback=back_callback,
|
||||||
callback_context=callback_context,
|
callback_context=callback_context,
|
||||||
)
|
)
|
||||||
@@ -185,6 +191,7 @@ async def display_subscription_options(
|
|||||||
enabled_tariffs,
|
enabled_tariffs,
|
||||||
current_lang,
|
current_lang,
|
||||||
i18n,
|
i18n,
|
||||||
|
settings=settings,
|
||||||
back_callback=back_callback,
|
back_callback=back_callback,
|
||||||
callback_context=callback_context,
|
callback_context=callback_context,
|
||||||
)
|
)
|
||||||
@@ -329,7 +336,9 @@ async def select_tariff_period_callback(
|
|||||||
callback_context = parts[4] if len(parts) > 4 else None
|
callback_context = parts[4] if len(parts) > 4 else None
|
||||||
tariff = config.require(tariff_key)
|
tariff = config.require(tariff_key)
|
||||||
months = int(months_raw)
|
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")
|
stars_price = tariff.period_price(months, "stars")
|
||||||
if price_rub is None:
|
if price_rub is None:
|
||||||
await callback.answer(get_text("error_try_again"), show_alert=True)
|
await callback.answer(get_text("error_try_again"), show_alert=True)
|
||||||
@@ -338,7 +347,7 @@ async def select_tariff_period_callback(
|
|||||||
months,
|
months,
|
||||||
price_rub,
|
price_rub,
|
||||||
int(stars_price) if stars_price else None,
|
int(stars_price) if stars_price else None,
|
||||||
settings.DEFAULT_CURRENCY_SYMBOL,
|
currency_code,
|
||||||
current_lang,
|
current_lang,
|
||||||
i18n,
|
i18n,
|
||||||
settings,
|
settings,
|
||||||
@@ -369,10 +378,16 @@ async def select_tariff_package_callback(
|
|||||||
callback_context = parts[4] if len(parts) > 4 else None
|
callback_context = parts[4] if len(parts) > 4 else None
|
||||||
tariff = config.require(tariff_key)
|
tariff = config.require(tariff_key)
|
||||||
gb = float(gb_raw)
|
gb = float(gb_raw)
|
||||||
|
default_currency = default_currency_key_for_settings(settings)
|
||||||
|
currency_code = default_payment_currency_code_for_settings(settings)
|
||||||
packages = (
|
packages = (
|
||||||
tariff.traffic_packages.rub
|
tariff.traffic_packages.for_currency(default_currency)
|
||||||
if tariff.billing_model == "traffic"
|
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)
|
package = next((pkg for pkg in packages if float(pkg.gb) == gb), None)
|
||||||
if not package:
|
if not package:
|
||||||
@@ -391,7 +406,7 @@ async def select_tariff_package_callback(
|
|||||||
gb,
|
gb,
|
||||||
package.price,
|
package.price,
|
||||||
None,
|
None,
|
||||||
settings.DEFAULT_CURRENCY_SYMBOL,
|
currency_code,
|
||||||
current_lang,
|
current_lang,
|
||||||
i18n,
|
i18n,
|
||||||
settings,
|
settings,
|
||||||
@@ -423,14 +438,19 @@ async def tariff_topup_list_callback(
|
|||||||
return
|
return
|
||||||
tariff = config.require(active["tariff_key"])
|
tariff = config.require(active["tariff_key"])
|
||||||
packages = config.topup_packages_for(tariff)
|
packages = config.topup_packages_for(tariff)
|
||||||
rub_packages = packages.rub if packages else []
|
default_currency = default_currency_key_for_settings(settings)
|
||||||
premium_packages = tariff.premium_topup_packages.rub if tariff.premium_topup_packages else []
|
currency = default_payment_currency_code_for_settings(settings)
|
||||||
if not rub_packages and not premium_packages:
|
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)
|
await callback.answer(get_text("no_subscription_options_available"), show_alert=True)
|
||||||
return
|
return
|
||||||
builder = InlineKeyboardBuilder()
|
builder = InlineKeyboardBuilder()
|
||||||
currency = settings.DEFAULT_CURRENCY_SYMBOL
|
for package in currency_packages:
|
||||||
for package in rub_packages:
|
|
||||||
builder.row(
|
builder.row(
|
||||||
InlineKeyboardButton(
|
InlineKeyboardButton(
|
||||||
text=f"Обычный трафик +{package.gb:g} GB — {package.price:g} {currency}",
|
text=f"Обычный трафик +{package.gb:g} GB — {package.price:g} {currency}",
|
||||||
@@ -452,7 +472,7 @@ async def tariff_topup_list_callback(
|
|||||||
|
|
||||||
premium_lines = []
|
premium_lines = []
|
||||||
carryover_lines = []
|
carryover_lines = []
|
||||||
if rub_packages or premium_packages:
|
if currency_packages or premium_packages:
|
||||||
carryover_lines.append(
|
carryover_lines.append(
|
||||||
"Докупленный трафик не сгорает: сначала расходуется месячный лимит, затем докупленный остаток." # noqa: E501
|
"Докупленный трафик не сгорает: сначала расходуется месячный лимит, затем докупленный остаток." # noqa: E501
|
||||||
)
|
)
|
||||||
@@ -495,7 +515,13 @@ async def select_tariff_premium_package_callback(
|
|||||||
_, _, tariff_key, gb_raw = callback.data.split(":", 3)
|
_, _, tariff_key, gb_raw = callback.data.split(":", 3)
|
||||||
tariff = config.require(tariff_key)
|
tariff = config.require(tariff_key)
|
||||||
gb = float(gb_raw)
|
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)
|
package = next((pkg for pkg in packages if float(pkg.gb) == gb), None)
|
||||||
if not package:
|
if not package:
|
||||||
await callback.answer(get_text("error_try_again"), show_alert=True)
|
await callback.answer(get_text("error_try_again"), show_alert=True)
|
||||||
@@ -504,7 +530,7 @@ async def select_tariff_premium_package_callback(
|
|||||||
gb,
|
gb,
|
||||||
package.price,
|
package.price,
|
||||||
None,
|
None,
|
||||||
settings.DEFAULT_CURRENCY_SYMBOL,
|
currency_code,
|
||||||
current_lang,
|
current_lang,
|
||||||
i18n,
|
i18n,
|
||||||
settings,
|
settings,
|
||||||
@@ -542,7 +568,12 @@ async def hwid_devices_list_callback(
|
|||||||
if tariff.billing_model != "period":
|
if tariff.billing_model != "period":
|
||||||
await callback.answer(get_text("no_hwid_device_packages_available"), show_alert=True)
|
await callback.answer(get_text("no_hwid_device_packages_available"), show_alert=True)
|
||||||
return
|
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:
|
if not packages:
|
||||||
await callback.answer(get_text("no_hwid_device_packages_available"), show_alert=True)
|
await callback.answer(get_text("no_hwid_device_packages_available"), show_alert=True)
|
||||||
return
|
return
|
||||||
@@ -594,7 +625,13 @@ async def hwid_devices_package_callback(
|
|||||||
package = next(
|
package = next(
|
||||||
(
|
(
|
||||||
pkg
|
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
|
if int(pkg.count) == count
|
||||||
),
|
),
|
||||||
None,
|
None,
|
||||||
@@ -603,13 +640,15 @@ async def hwid_devices_package_callback(
|
|||||||
await callback.answer(get_text("error_try_again"), show_alert=True)
|
await callback.answer(get_text("error_try_again"), show_alert=True)
|
||||||
return
|
return
|
||||||
sale_mode_base = "hwid_devices_renewal" if action == "renewal_package" else "hwid_devices"
|
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,
|
session,
|
||||||
user_id=callback.from_user.id,
|
user_id=callback.from_user.id,
|
||||||
device_count=count,
|
device_count=count,
|
||||||
tariff_key=tariff.key,
|
tariff_key=tariff.key,
|
||||||
renewal=action == "renewal_package",
|
renewal=action == "renewal_package",
|
||||||
currency="rub",
|
currency=default_currency,
|
||||||
)
|
)
|
||||||
stars_quote = await subscription_service.quote_hwid_device_topup(
|
stars_quote = await subscription_service.quote_hwid_device_topup(
|
||||||
session,
|
session,
|
||||||
@@ -619,16 +658,16 @@ async def hwid_devices_package_callback(
|
|||||||
renewal=action == "renewal_package",
|
renewal=action == "renewal_package",
|
||||||
currency="stars",
|
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)
|
await callback.answer(get_text("error_try_again"), show_alert=True)
|
||||||
return
|
return
|
||||||
markup = get_payment_method_keyboard(
|
markup = get_payment_method_keyboard(
|
||||||
count,
|
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"])
|
int(stars_quote["price"])
|
||||||
if stars_quote and int(stars_quote.get("price") or 0) > 0
|
if stars_quote and int(stars_quote.get("price") or 0) > 0
|
||||||
else None,
|
else None,
|
||||||
settings.DEFAULT_CURRENCY_SYMBOL,
|
currency_code,
|
||||||
current_lang,
|
current_lang,
|
||||||
i18n,
|
i18n,
|
||||||
settings,
|
settings,
|
||||||
@@ -715,6 +754,8 @@ async def tariff_change_select_callback(
|
|||||||
options = await subscription_service.calculate_tariff_switch_options_with_hwid(
|
options = await subscription_service.calculate_tariff_switch_options_with_hwid(
|
||||||
session, db_sub, target
|
session, db_sub, target
|
||||||
)
|
)
|
||||||
|
default_currency = default_currency_key_for_settings(settings)
|
||||||
|
currency_code = default_payment_currency_code_for_settings(settings)
|
||||||
rows = []
|
rows = []
|
||||||
if options["mode"] == "period_to_period":
|
if options["mode"] == "period_to_period":
|
||||||
rows.append(
|
rows.append(
|
||||||
@@ -729,7 +770,7 @@ async def tariff_change_select_callback(
|
|||||||
rows.append(
|
rows.append(
|
||||||
[
|
[
|
||||||
InlineKeyboardButton(
|
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']}",
|
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(
|
rows.append(
|
||||||
[
|
[
|
||||||
InlineKeyboardButton(
|
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}",
|
callback_data=f"tariff:package:{target.key}:{package.gb:g}",
|
||||||
)
|
)
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
for months in target.enabled_periods:
|
for months in target.enabled_periods:
|
||||||
price = target.period_price(months, "rub")
|
price = target.period_price(months, default_currency)
|
||||||
if price:
|
if price:
|
||||||
rows.append(
|
rows.append(
|
||||||
[
|
[
|
||||||
InlineKeyboardButton(
|
InlineKeyboardButton(
|
||||||
text=f"{months} мес. за {price:g} RUB",
|
text=f"{months} мес. за {price:g} {currency_code}",
|
||||||
callback_data=f"tariff:period:{target.key}:{months}",
|
callback_data=f"tariff:period:{target.key}:{months}",
|
||||||
)
|
)
|
||||||
]
|
]
|
||||||
@@ -842,6 +883,7 @@ async def tariff_change_confirm_pay_callback(
|
|||||||
return
|
return
|
||||||
_, _, tariff_key, amount_raw = callback.data.split(":", 3)
|
_, _, tariff_key, amount_raw = callback.data.split(":", 3)
|
||||||
target = config.require(tariff_key)
|
target = config.require(tariff_key)
|
||||||
|
currency_code = default_payment_currency_code_for_settings(settings)
|
||||||
rows = [
|
rows = [
|
||||||
[
|
[
|
||||||
InlineKeyboardButton(
|
InlineKeyboardButton(
|
||||||
@@ -857,7 +899,7 @@ async def tariff_change_confirm_pay_callback(
|
|||||||
],
|
],
|
||||||
]
|
]
|
||||||
await callback.message.edit_text(
|
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),
|
reply_markup=InlineKeyboardMarkup(inline_keyboard=rows),
|
||||||
)
|
)
|
||||||
await callback.answer()
|
await callback.answer()
|
||||||
@@ -899,11 +941,12 @@ async def tariff_change_pay_callback(
|
|||||||
i18n: JsonI18n = i18n_data.get("i18n_instance")
|
i18n: JsonI18n = i18n_data.get("i18n_instance")
|
||||||
_, _, tariff_key, amount_raw = callback.data.split(":", 3)
|
_, _, tariff_key, amount_raw = callback.data.split(":", 3)
|
||||||
amount = float(amount_raw)
|
amount = float(amount_raw)
|
||||||
|
currency_code = default_payment_currency_code_for_settings(settings)
|
||||||
markup = get_payment_method_keyboard(
|
markup = get_payment_method_keyboard(
|
||||||
1,
|
1,
|
||||||
amount,
|
amount,
|
||||||
None,
|
None,
|
||||||
settings.DEFAULT_CURRENCY_SYMBOL,
|
currency_code,
|
||||||
current_lang,
|
current_lang,
|
||||||
i18n,
|
i18n,
|
||||||
settings,
|
settings,
|
||||||
@@ -1226,7 +1269,9 @@ async def my_subscription_command_handler(
|
|||||||
if (
|
if (
|
||||||
tariff_for_devices.billing_model == "period"
|
tariff_for_devices.billing_model == "period"
|
||||||
and tariff_for_devices.hwid_device_packages
|
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(
|
prepend_rows.append(
|
||||||
[
|
[
|
||||||
@@ -1443,7 +1488,9 @@ async def my_devices_command_handler(
|
|||||||
if (
|
if (
|
||||||
tariff_for_devices.billing_model == "period"
|
tariff_for_devices.billing_model == "period"
|
||||||
and tariff_for_devices.hwid_device_packages
|
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(
|
devices_kb.append(
|
||||||
[
|
[
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ async def request_trial_confirmation_handler(
|
|||||||
return
|
return
|
||||||
|
|
||||||
if settings.TRIAL_ENABLED:
|
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
|
pass
|
||||||
|
|
||||||
if not settings.TRIAL_ENABLED:
|
if not settings.TRIAL_ENABLED:
|
||||||
@@ -60,7 +60,7 @@ async def request_trial_confirmation_handler(
|
|||||||
pass
|
pass
|
||||||
return
|
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(
|
await callback.message.edit_text(
|
||||||
_("trial_already_had_subscription_or_trial"),
|
_("trial_already_had_subscription_or_trial"),
|
||||||
reply_markup=get_main_menu_inline_keyboard(current_lang, i18n, settings, False),
|
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)
|
await callback.answer(final_message_text_in_chat, show_alert=True)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
if settings.TRIAL_ENABLED and not await subscription_service.has_had_any_subscription(
|
if (
|
||||||
session, user_id
|
settings.TRIAL_ENABLED
|
||||||
|
and not await subscription_service.has_trial_blocking_subscription(session, user_id)
|
||||||
):
|
):
|
||||||
show_trial_button_after_action = True
|
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
|
callback, settings, i18n_data, subscription_service, session, is_edit=True
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
if await subscription_service.has_had_any_subscription(session, user_id):
|
if await subscription_service.has_trial_blocking_subscription(session, user_id):
|
||||||
try:
|
try:
|
||||||
await callback.answer(_("trial_already_had_subscription_or_trial"), show_alert=True)
|
await callback.answer(_("trial_already_had_subscription_or_trial"), show_alert=True)
|
||||||
except Exception:
|
except Exception:
|
||||||
@@ -283,8 +284,9 @@ async def confirm_activate_trial_handler(
|
|||||||
await callback.answer(final_message_text_in_chat, show_alert=True)
|
await callback.answer(final_message_text_in_chat, show_alert=True)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
if settings.TRIAL_ENABLED and not await subscription_service.has_had_any_subscription(
|
if (
|
||||||
session, user_id
|
settings.TRIAL_ENABLED
|
||||||
|
and not await subscription_service.has_trial_blocking_subscription(session, user_id)
|
||||||
):
|
):
|
||||||
show_trial_button_after_action = True
|
show_trial_button_after_action = True
|
||||||
|
|
||||||
|
|||||||
@@ -452,10 +452,11 @@ def get_broadcast_confirmation_keyboard(
|
|||||||
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
|
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
|
||||||
builder = InlineKeyboardBuilder()
|
builder = InlineKeyboardBuilder()
|
||||||
|
|
||||||
# Row: target selection (all / active / inactive)
|
# Row: target selection (all / active / inactive / expired)
|
||||||
target_all_label = _(key="broadcast_target_all_button")
|
target_all_label = _(key="broadcast_target_all_button")
|
||||||
target_active_label = _(key="broadcast_target_active_button")
|
target_active_label = _(key="broadcast_target_active_button")
|
||||||
target_inactive_label = _(key="broadcast_target_inactive_button")
|
target_inactive_label = _(key="broadcast_target_inactive_button")
|
||||||
|
target_expired_label = _(key="broadcast_target_expired_button")
|
||||||
|
|
||||||
# Highlight current selection with a prefix
|
# Highlight current selection with a prefix
|
||||||
def mark_selected(label: str, is_selected: bool) -> str:
|
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"),
|
text=mark_selected(target_inactive_label, target == "inactive"),
|
||||||
callback_data="broadcast_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
|
# Row: confirmation
|
||||||
builder.button(
|
builder.button(
|
||||||
@@ -482,7 +486,7 @@ def get_broadcast_confirmation_keyboard(
|
|||||||
builder.button(
|
builder.button(
|
||||||
text=_(key="cancel_broadcast_button"), callback_data="broadcast_final_action:cancel"
|
text=_(key="cancel_broadcast_button"), callback_data="broadcast_final_action:cancel"
|
||||||
)
|
)
|
||||||
builder.adjust(2)
|
builder.adjust(2, 2, 2)
|
||||||
return builder.as_markup()
|
return builder.as_markup()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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.install_links import bot_install_guide_url
|
||||||
from bot.utils.mini_app_url import subscription_mini_app_trial_url
|
from bot.utils.mini_app_url import subscription_mini_app_trial_url
|
||||||
from config.settings import Settings
|
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"
|
BOT_MENU_CONTEXT = "bot"
|
||||||
|
|
||||||
@@ -328,19 +332,31 @@ def get_tariff_catalog_keyboard(
|
|||||||
tariffs: List[Any],
|
tariffs: List[Any],
|
||||||
lang: str,
|
lang: str,
|
||||||
i18n_instance,
|
i18n_instance,
|
||||||
|
settings: Optional[Settings] = None,
|
||||||
back_callback: str = "main_action:back_to_main",
|
back_callback: str = "main_action:back_to_main",
|
||||||
callback_context: Optional[str] = None,
|
callback_context: Optional[str] = None,
|
||||||
) -> InlineKeyboardMarkup:
|
) -> InlineKeyboardMarkup:
|
||||||
builder = InlineKeyboardBuilder()
|
builder = InlineKeyboardBuilder()
|
||||||
callback_context = callback_context or callback_context_from_back_callback(back_callback)
|
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:
|
for tariff in tariffs:
|
||||||
label = tariff.name(lang)
|
label = tariff.name(lang)
|
||||||
if tariff.billing_model == "period":
|
if tariff.billing_model == "period":
|
||||||
|
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()
|
min_price = tariff.min_period_price_rub()
|
||||||
|
else:
|
||||||
|
min_price = None
|
||||||
if min_price is not None:
|
if min_price is not None:
|
||||||
label = f"{label} от {min_price:g}"
|
label = f"{label} от {min_price:g}"
|
||||||
else:
|
else:
|
||||||
|
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()
|
package = tariff.min_traffic_package_rub()
|
||||||
|
else:
|
||||||
|
package = None
|
||||||
if package:
|
if package:
|
||||||
label = f"{label} от {package.price:g} / {package.gb:g} GB"
|
label = f"{label} от {package.price:g} / {package.gb:g} GB"
|
||||||
builder.row(
|
builder.row(
|
||||||
@@ -368,8 +384,10 @@ def get_tariff_periods_keyboard(
|
|||||||
builder = InlineKeyboardBuilder()
|
builder = InlineKeyboardBuilder()
|
||||||
callback_context = callback_context or callback_context_from_back_callback(back_callback)
|
callback_context = callback_context or callback_context_from_back_callback(back_callback)
|
||||||
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
|
_ = 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:
|
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:
|
if rub_price and rub_price > 0:
|
||||||
builder.row(
|
builder.row(
|
||||||
InlineKeyboardButton(
|
InlineKeyboardButton(
|
||||||
@@ -377,7 +395,7 @@ def get_tariff_periods_keyboard(
|
|||||||
"subscribe_for_months_button",
|
"subscribe_for_months_button",
|
||||||
months=months,
|
months=months,
|
||||||
price=rub_price,
|
price=rub_price,
|
||||||
currency_symbol=settings.DEFAULT_CURRENCY_SYMBOL,
|
currency_symbol=currency_code,
|
||||||
),
|
),
|
||||||
callback_data=f"tariff:period:{tariff.key}:{months}"
|
callback_data=f"tariff:period:{tariff.key}:{months}"
|
||||||
f"{callback_suffix_for_context(callback_context)}",
|
f"{callback_suffix_for_context(callback_context)}",
|
||||||
@@ -394,6 +412,7 @@ def get_tariff_packages_keyboard(
|
|||||||
packages: List[Any],
|
packages: List[Any],
|
||||||
lang: str,
|
lang: str,
|
||||||
i18n_instance,
|
i18n_instance,
|
||||||
|
currency_symbol: str = "RUB",
|
||||||
back_callback: str = "main_action:subscribe",
|
back_callback: str = "main_action:subscribe",
|
||||||
callback_context: Optional[str] = None,
|
callback_context: Optional[str] = None,
|
||||||
) -> InlineKeyboardMarkup:
|
) -> InlineKeyboardMarkup:
|
||||||
@@ -407,7 +426,7 @@ def get_tariff_packages_keyboard(
|
|||||||
"buy_traffic_package_button",
|
"buy_traffic_package_button",
|
||||||
traffic_gb=f"{package.gb:g}",
|
traffic_gb=f"{package.gb:g}",
|
||||||
price=package.price,
|
price=package.price,
|
||||||
currency_symbol="RUB",
|
currency_symbol=currency_symbol,
|
||||||
),
|
),
|
||||||
callback_data=f"tariff:package:{tariff.key}:{package.gb:g}"
|
callback_data=f"tariff:package:{tariff.key}:{package.gb:g}"
|
||||||
f"{callback_suffix_for_context(callback_context)}",
|
f"{callback_suffix_for_context(callback_context)}",
|
||||||
@@ -430,6 +449,7 @@ def get_hwid_device_packages_keyboard(
|
|||||||
) -> InlineKeyboardMarkup:
|
) -> InlineKeyboardMarkup:
|
||||||
builder = InlineKeyboardBuilder()
|
builder = InlineKeyboardBuilder()
|
||||||
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
|
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
|
||||||
|
currency_code = default_payment_currency_code_for_settings(settings)
|
||||||
for package in packages:
|
for package in packages:
|
||||||
builder.row(
|
builder.row(
|
||||||
InlineKeyboardButton(
|
InlineKeyboardButton(
|
||||||
@@ -437,7 +457,7 @@ def get_hwid_device_packages_keyboard(
|
|||||||
"buy_hwid_devices_button",
|
"buy_hwid_devices_button",
|
||||||
count=package.count,
|
count=package.count,
|
||||||
price=package.price,
|
price=package.price,
|
||||||
currency_symbol=settings.DEFAULT_CURRENCY_SYMBOL,
|
currency_symbol=currency_code,
|
||||||
),
|
),
|
||||||
callback_data=(
|
callback_data=(
|
||||||
f"hwid_devices:{'renewal_package' if renewal else 'package'}:"
|
f"hwid_devices:{'renewal_package' if renewal else 'package'}:"
|
||||||
@@ -484,6 +504,7 @@ def get_payment_method_keyboard(
|
|||||||
if (
|
if (
|
||||||
not spec
|
not spec
|
||||||
or not spec.callback_prefix
|
or not spec.callback_prefix
|
||||||
|
or not spec.is_usable_for_payment_currency(settings, currency_symbol_val)
|
||||||
or not spec.is_available_to_user(
|
or not spec.is_available_to_user(
|
||||||
settings,
|
settings,
|
||||||
user_id=user_id,
|
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.keyboards.inline.user_keyboards import get_channel_subscription_keyboard
|
||||||
from bot.middlewares.i18n import JsonI18n
|
from bot.middlewares.i18n import JsonI18n
|
||||||
|
from bot.utils.channel_subscription import normalize_required_channel_id
|
||||||
from config.settings import Settings
|
from config.settings import Settings
|
||||||
from db.dal import user_dal
|
from db.dal import user_dal
|
||||||
|
|
||||||
@@ -32,7 +33,7 @@ class ChannelSubscriptionMiddleware(BaseMiddleware):
|
|||||||
event: Update,
|
event: Update,
|
||||||
data: Dict[str, Any],
|
data: Dict[str, Any],
|
||||||
) -> 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:
|
if not required_channel_id:
|
||||||
return await handler(event, data)
|
return await handler(event, data)
|
||||||
|
|
||||||
|
|||||||
@@ -112,6 +112,7 @@ class WebAppPaymentContext:
|
|||||||
stars_price: Optional[int]
|
stars_price: Optional[int]
|
||||||
description: str
|
description: str
|
||||||
sale_mode: str
|
sale_mode: str
|
||||||
|
currency: str = "RUB"
|
||||||
traffic_gb: Optional[float] = None
|
traffic_gb: Optional[float] = None
|
||||||
hwid_valid_from: Optional[Any] = None
|
hwid_valid_from: Optional[Any] = None
|
||||||
hwid_valid_until: Optional[Any] = None
|
hwid_valid_until: Optional[Any] = None
|
||||||
@@ -125,6 +126,36 @@ ServiceFactory = Callable[[ServiceFactoryContext], Any]
|
|||||||
WebhookPathGetter = Callable[[Any], str]
|
WebhookPathGetter = Callable[[Any], str]
|
||||||
WebhookRoute = Callable[[Any], Awaitable[Any]]
|
WebhookRoute = Callable[[Any], Awaitable[Any]]
|
||||||
WebAppPaymentFactory = Callable[[WebAppPaymentContext], 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)
|
@dataclass(frozen=True)
|
||||||
@@ -158,6 +189,10 @@ class PaymentProviderSpec:
|
|||||||
admin_only_manifest_key: Optional[str] = None
|
admin_only_manifest_key: Optional[str] = None
|
||||||
admin_only_config_attr: str = "ADMIN_ONLY_ENABLED"
|
admin_only_config_attr: str = "ADMIN_ONLY_ENABLED"
|
||||||
admin_only_enabled: Optional[EnabledPredicate] = None
|
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
|
@property
|
||||||
def settings_key(self) -> str:
|
def settings_key(self) -> str:
|
||||||
@@ -237,6 +272,39 @@ class PaymentProviderSpec:
|
|||||||
service = app.get(self.service_key) if hasattr(app, "get") else None
|
service = app.get(self.service_key) if hasattr(app, "get") else None
|
||||||
return bool(service and getattr(service, "configured", False))
|
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:
|
def is_visible(self, source: Any, app: Any) -> bool:
|
||||||
return self.is_enabled(source) and self.is_service_configured(app)
|
return self.is_enabled(source) and self.is_service_configured(app)
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,10 @@ from bot.middlewares.i18n import JsonI18n
|
|||||||
from bot.services.referral_service import ReferralService
|
from bot.services.referral_service import ReferralService
|
||||||
from bot.services.subscription_service import SubscriptionService
|
from bot.services.subscription_service import SubscriptionService
|
||||||
from config.settings import Settings
|
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 db.dal import payment_dal
|
||||||
|
|
||||||
from .base import (
|
from .base import (
|
||||||
@@ -25,6 +29,7 @@ from .base import (
|
|||||||
ProviderManifestField,
|
ProviderManifestField,
|
||||||
ServiceFactoryContext,
|
ServiceFactoryContext,
|
||||||
WebAppPaymentContext,
|
WebAppPaymentContext,
|
||||||
|
normalize_payment_currency_code,
|
||||||
provider_env_file,
|
provider_env_file,
|
||||||
provider_runtime_enabled,
|
provider_runtime_enabled,
|
||||||
)
|
)
|
||||||
@@ -49,6 +54,34 @@ from .shared import (
|
|||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
_LOG = "cryptopay"
|
_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):
|
class CryptoPayConfig(ProviderEnvConfig):
|
||||||
@@ -159,11 +192,23 @@ class CryptoPayService:
|
|||||||
sale_mode: str = "subscription",
|
sale_mode: str = "subscription",
|
||||||
url_kind: str = "bot",
|
url_kind: str = "bot",
|
||||||
hwid_quote: Optional[dict] = None,
|
hwid_quote: Optional[dict] = None,
|
||||||
|
currency: Optional[str] = None,
|
||||||
) -> Optional[str]:
|
) -> Optional[str]:
|
||||||
if not self.configured or not self.client:
|
if not self.configured or not self.client:
|
||||||
logging.error("CryptoPayService not configured")
|
logging.error("CryptoPayService not configured")
|
||||||
return None
|
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)
|
sale_base = sale_mode_base(sale_mode)
|
||||||
amounts = payment_record_amounts(months=months, sale_mode=sale_mode)
|
amounts = payment_record_amounts(months=months, sale_mode=sale_mode)
|
||||||
try:
|
try:
|
||||||
@@ -172,7 +217,7 @@ class CryptoPayService:
|
|||||||
{
|
{
|
||||||
"user_id": user_id,
|
"user_id": user_id,
|
||||||
"amount": float(amount),
|
"amount": float(amount),
|
||||||
"currency": self.config.ASSET,
|
"currency": currency_code,
|
||||||
"status": "pending_cryptopay",
|
"status": "pending_cryptopay",
|
||||||
"description": description,
|
"description": description,
|
||||||
"subscription_duration_months": (
|
"subscription_duration_months": (
|
||||||
@@ -212,9 +257,9 @@ class CryptoPayService:
|
|||||||
try:
|
try:
|
||||||
invoice = await self.client.create_invoice(
|
invoice = await self.client.create_invoice(
|
||||||
amount=amount,
|
amount=amount,
|
||||||
currency_type=self.config.CURRENCY_TYPE,
|
currency_type=currency_type,
|
||||||
fiat=self.config.ASSET if self.config.CURRENCY_TYPE == "fiat" else None,
|
fiat=currency_code if currency_type == "fiat" else None,
|
||||||
asset=self.config.ASSET if self.config.CURRENCY_TYPE == "crypto" else None,
|
asset=currency_code if currency_type == "crypto" else None,
|
||||||
description=description,
|
description=description,
|
||||||
payload=payload,
|
payload=payload,
|
||||||
)
|
)
|
||||||
@@ -393,7 +438,7 @@ async def pay_crypto_callback_handler(
|
|||||||
user_id=callback.from_user.id,
|
user_id=callback.from_user.id,
|
||||||
parts=parts,
|
parts=parts,
|
||||||
subscription_service=cryptopay_service.subscription_service,
|
subscription_service=cryptopay_service.subscription_service,
|
||||||
currency="rub",
|
currency=default_currency_key_for_settings(settings),
|
||||||
)
|
)
|
||||||
if not parts:
|
if not parts:
|
||||||
await notify_callback_parse_error(callback, translator)
|
await notify_callback_parse_error(callback, translator)
|
||||||
@@ -408,6 +453,7 @@ async def pay_crypto_callback_handler(
|
|||||||
description=payment_description,
|
description=payment_description,
|
||||||
sale_mode=parts.sale_mode,
|
sale_mode=parts.sale_mode,
|
||||||
hwid_quote=hwid_quote,
|
hwid_quote=hwid_quote,
|
||||||
|
currency=default_payment_currency_code_for_settings(settings),
|
||||||
)
|
)
|
||||||
|
|
||||||
if invoice_url:
|
if invoice_url:
|
||||||
@@ -457,6 +503,7 @@ async def create_webapp_payment(ctx: WebAppPaymentContext) -> web.Response:
|
|||||||
description=ctx.description,
|
description=ctx.description,
|
||||||
sale_mode=ctx.sale_mode,
|
sale_mode=ctx.sale_mode,
|
||||||
url_kind="web",
|
url_kind="web",
|
||||||
|
currency=ctx.currency,
|
||||||
hwid_quote={
|
hwid_quote={
|
||||||
"valid_from": ctx.hwid_valid_from,
|
"valid_from": ctx.hwid_valid_from,
|
||||||
"valid_until": ctx.hwid_valid_until,
|
"valid_until": ctx.hwid_valid_until,
|
||||||
@@ -592,4 +639,10 @@ SPEC = PaymentProviderSpec(
|
|||||||
config_class=CryptoPayConfig,
|
config_class=CryptoPayConfig,
|
||||||
presentation_class=CryptoPayPresentation,
|
presentation_class=CryptoPayPresentation,
|
||||||
manifest_fields=_CONFIG_MANIFEST + _PRESENTATION_MANIFEST,
|
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/",
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -20,6 +20,10 @@ from bot.services.referral_service import ReferralService
|
|||||||
from bot.services.subscription_service import SubscriptionService
|
from bot.services.subscription_service import SubscriptionService
|
||||||
from bot.utils.request_security import ip_in_allowlist, request_client_ip
|
from bot.utils.request_security import ip_in_allowlist, request_client_ip
|
||||||
from config.settings import Settings
|
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 db.dal import payment_dal
|
||||||
|
|
||||||
from .base import (
|
from .base import (
|
||||||
@@ -28,6 +32,7 @@ from .base import (
|
|||||||
ProviderManifestField,
|
ProviderManifestField,
|
||||||
ServiceFactoryContext,
|
ServiceFactoryContext,
|
||||||
WebAppPaymentContext,
|
WebAppPaymentContext,
|
||||||
|
normalize_payment_currency_code,
|
||||||
provider_env_file,
|
provider_env_file,
|
||||||
provider_runtime_enabled,
|
provider_runtime_enabled,
|
||||||
)
|
)
|
||||||
@@ -56,6 +61,7 @@ from .shared import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
_LOG = "freekassa"
|
_LOG = "freekassa"
|
||||||
|
FREEKASSA_SUPPORTED_CURRENCIES = ("RUB", "USD", "EUR", "UAH", "KZT")
|
||||||
|
|
||||||
|
|
||||||
class FreeKassaConfig(ProviderEnvConfig):
|
class FreeKassaConfig(ProviderEnvConfig):
|
||||||
@@ -144,7 +150,7 @@ class FreeKassaService(HttpClientMixin):
|
|||||||
self.subscription_service = subscription_service
|
self.subscription_service = subscription_service
|
||||||
self.referral_service = referral_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.api_base_url: str = "https://api.fk.life/v1"
|
||||||
self._init_http_client(total_timeout=15)
|
self._init_http_client(total_timeout=15)
|
||||||
@@ -207,7 +213,13 @@ class FreeKassaService(HttpClientMixin):
|
|||||||
return False, {"message": "missing_ip"}
|
return False, {"message": "missing_ip"}
|
||||||
|
|
||||||
email = email or f"{user_id}@telegram.org"
|
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] = {
|
payload: Dict[str, Any] = {
|
||||||
"shopId": int(self.shop_id),
|
"shopId": int(self.shop_id),
|
||||||
@@ -477,7 +489,7 @@ async def pay_fk_callback_handler(
|
|||||||
user_id=callback.from_user.id,
|
user_id=callback.from_user.id,
|
||||||
parts=parts,
|
parts=parts,
|
||||||
subscription_service=freekassa_service.subscription_service,
|
subscription_service=freekassa_service.subscription_service,
|
||||||
currency="rub",
|
currency=default_currency_key_for_settings(settings),
|
||||||
)
|
)
|
||||||
if not parts:
|
if not parts:
|
||||||
await notify_callback_parse_error(callback, translator)
|
await notify_callback_parse_error(callback, translator)
|
||||||
@@ -485,7 +497,7 @@ async def pay_fk_callback_handler(
|
|||||||
|
|
||||||
currency_code = (
|
currency_code = (
|
||||||
getattr(freekassa_service, "default_currency", None)
|
getattr(freekassa_service, "default_currency", None)
|
||||||
or settings.DEFAULT_CURRENCY_SYMBOL
|
or default_payment_currency_code_for_settings(settings)
|
||||||
or "RUB"
|
or "RUB"
|
||||||
)
|
)
|
||||||
payment_description = describe_payment(translator, parts)
|
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"]
|
service: FreeKassaService = ctx.request.app["freekassa_service"]
|
||||||
if not service or not service.configured or not service.payment_method_id:
|
if not service or not service.configured or not service.payment_method_id:
|
||||||
return payment_unavailable()
|
return payment_unavailable()
|
||||||
|
currency = ctx.currency or service.default_currency
|
||||||
|
|
||||||
try:
|
try:
|
||||||
payment = await create_webapp_payment_record(
|
payment = await create_webapp_payment_record(
|
||||||
ctx,
|
ctx,
|
||||||
amount=ctx.price,
|
amount=ctx.price,
|
||||||
currency=service.default_currency,
|
currency=currency,
|
||||||
status="pending_freekassa",
|
status="pending_freekassa",
|
||||||
provider="freekassa",
|
provider="freekassa",
|
||||||
)
|
)
|
||||||
@@ -592,7 +605,7 @@ async def create_webapp_payment(ctx: WebAppPaymentContext) -> web.Response:
|
|||||||
user_id=ctx.user_id,
|
user_id=ctx.user_id,
|
||||||
months=ctx.months,
|
months=ctx.months,
|
||||||
amount=ctx.price,
|
amount=ctx.price,
|
||||||
currency=service.default_currency,
|
currency=currency,
|
||||||
payment_method_id=service.payment_method_id,
|
payment_method_id=service.payment_method_id,
|
||||||
ip_address=service.server_ip,
|
ip_address=service.server_ip,
|
||||||
extra_params={"us_method": service.payment_method_id},
|
extra_params={"us_method": service.payment_method_id},
|
||||||
@@ -762,4 +775,9 @@ SPEC = PaymentProviderSpec(
|
|||||||
config_class=FreeKassaConfig,
|
config_class=FreeKassaConfig,
|
||||||
presentation_class=FreeKassaPresentation,
|
presentation_class=FreeKassaPresentation,
|
||||||
manifest_fields=_CONFIG_MANIFEST + _PRESENTATION_MANIFEST,
|
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/",
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -18,6 +18,10 @@ from bot.services.referral_service import ReferralService
|
|||||||
from bot.services.subscription_service import SubscriptionService
|
from bot.services.subscription_service import SubscriptionService
|
||||||
from bot.utils.request_security import ip_in_allowlist, request_client_ip
|
from bot.utils.request_security import ip_in_allowlist, request_client_ip
|
||||||
from config.settings import Settings
|
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 db.dal import payment_dal
|
||||||
|
|
||||||
from .base import (
|
from .base import (
|
||||||
@@ -26,6 +30,8 @@ from .base import (
|
|||||||
ProviderManifestField,
|
ProviderManifestField,
|
||||||
ServiceFactoryContext,
|
ServiceFactoryContext,
|
||||||
WebAppPaymentContext,
|
WebAppPaymentContext,
|
||||||
|
normalize_payment_currency_code,
|
||||||
|
parse_supported_currency_codes,
|
||||||
provider_env_file,
|
provider_env_file,
|
||||||
provider_runtime_enabled,
|
provider_runtime_enabled,
|
||||||
)
|
)
|
||||||
@@ -59,6 +65,10 @@ _LOG = "heleket"
|
|||||||
|
|
||||||
_SUCCESS_STATUSES = {"paid", "paid_over"}
|
_SUCCESS_STATUSES = {"paid", "paid_over"}
|
||||||
_FAILED_STATUSES = {"fail", "wrong_amount", "cancel", "system_fail"}
|
_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):
|
class HeleketConfig(ProviderEnvConfig):
|
||||||
@@ -83,6 +93,7 @@ class HeleketConfig(ProviderEnvConfig):
|
|||||||
LIFETIME_SECONDS: int = Field(default=3600)
|
LIFETIME_SECONDS: int = Field(default=3600)
|
||||||
VERIFY_WEBHOOK_SIGNATURE: bool = Field(default=True)
|
VERIFY_WEBHOOK_SIGNATURE: bool = Field(default=True)
|
||||||
TRUSTED_IPS: str = Field(default="31.133.220.8")
|
TRUSTED_IPS: str = Field(default="31.133.220.8")
|
||||||
|
SUPPORTED_CURRENCIES: str = Field(default=HELEKET_DEFAULT_SUPPORTED_CURRENCIES)
|
||||||
|
|
||||||
@field_validator("LIFETIME_SECONDS", mode="before")
|
@field_validator("LIFETIME_SECONDS", mode="before")
|
||||||
@classmethod
|
@classmethod
|
||||||
@@ -299,9 +310,18 @@ class HeleketService(HttpClientMixin):
|
|||||||
logging.error("HeleketService is not configured. Cannot create payment link.")
|
logging.error("HeleketService is not configured. Cannot create payment link.")
|
||||||
return False, {"message": "service_not_configured"}
|
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] = {
|
body: Dict[str, Any] = {
|
||||||
"amount": str(format_decimal_amount(amount)),
|
"amount": str(format_decimal_amount(amount)),
|
||||||
"currency": (currency or self.currency).upper(),
|
"currency": currency_code,
|
||||||
"order_id": str(payment_db_id),
|
"order_id": str(payment_db_id),
|
||||||
"url_return": self.return_url,
|
"url_return": self.return_url,
|
||||||
"url_success": self.success_url,
|
"url_success": self.success_url,
|
||||||
@@ -572,13 +592,13 @@ async def pay_heleket_callback_handler(
|
|||||||
user_id=callback.from_user.id,
|
user_id=callback.from_user.id,
|
||||||
parts=parts,
|
parts=parts,
|
||||||
subscription_service=heleket_service.subscription_service,
|
subscription_service=heleket_service.subscription_service,
|
||||||
currency="rub",
|
currency=default_currency_key_for_settings(settings),
|
||||||
)
|
)
|
||||||
if not parts:
|
if not parts:
|
||||||
await notify_callback_parse_error(callback, translator)
|
await notify_callback_parse_error(callback, translator)
|
||||||
return
|
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)
|
payment_description = describe_payment(translator, parts)
|
||||||
record_payload = build_payment_record_payload(
|
record_payload = build_payment_record_payload(
|
||||||
user_id=callback.from_user.id,
|
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:
|
if not service or not service.configured:
|
||||||
return payment_unavailable()
|
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:
|
try:
|
||||||
payment = await create_webapp_payment_record(
|
payment = await create_webapp_payment_record(
|
||||||
ctx,
|
ctx,
|
||||||
@@ -787,6 +807,18 @@ _CONFIG_MANIFEST = (
|
|||||||
subsection="Heleket",
|
subsection="Heleket",
|
||||||
attr="CURRENCY",
|
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(
|
ProviderManifestField(
|
||||||
"HELEKET_TO_CURRENCY",
|
"HELEKET_TO_CURRENCY",
|
||||||
"string",
|
"string",
|
||||||
@@ -859,4 +891,12 @@ SPEC = PaymentProviderSpec(
|
|||||||
config_class=HeleketConfig,
|
config_class=HeleketConfig,
|
||||||
presentation_class=HeleketPresentation,
|
presentation_class=HeleketPresentation,
|
||||||
manifest_fields=_CONFIG_MANIFEST + _PRESENTATION_MANIFEST,
|
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",
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -14,6 +14,10 @@ from bot.middlewares.i18n import JsonI18n
|
|||||||
from bot.services.referral_service import ReferralService
|
from bot.services.referral_service import ReferralService
|
||||||
from bot.services.subscription_service import SubscriptionService
|
from bot.services.subscription_service import SubscriptionService
|
||||||
from config.settings import Settings
|
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 db.dal import payment_dal
|
||||||
|
|
||||||
from .base import (
|
from .base import (
|
||||||
@@ -22,6 +26,8 @@ from .base import (
|
|||||||
ProviderManifestField,
|
ProviderManifestField,
|
||||||
ServiceFactoryContext,
|
ServiceFactoryContext,
|
||||||
WebAppPaymentContext,
|
WebAppPaymentContext,
|
||||||
|
normalize_payment_currency_code,
|
||||||
|
parse_supported_currency_codes,
|
||||||
provider_env_file,
|
provider_env_file,
|
||||||
provider_runtime_enabled,
|
provider_runtime_enabled,
|
||||||
)
|
)
|
||||||
@@ -76,6 +82,7 @@ class PlategaConfig(ProviderEnvConfig):
|
|||||||
CRYPTO_METHOD: int = Field(default=13)
|
CRYPTO_METHOD: int = Field(default=13)
|
||||||
RETURN_URL: Optional[str] = None
|
RETURN_URL: Optional[str] = None
|
||||||
FAILED_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")
|
@field_validator("MERCHANT_ID", "SECRET", "RETURN_URL", "FAILED_URL", mode="before")
|
||||||
@classmethod
|
@classmethod
|
||||||
@@ -229,9 +236,19 @@ class PlategaService(HttpClientMixin):
|
|||||||
logging.error("PlategaService is not configured. Cannot create transaction.")
|
logging.error("PlategaService is not configured. Cannot create transaction.")
|
||||||
return False, {"message": "service_not_configured"}
|
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()
|
session = await self._get_session()
|
||||||
url = f"{self.base_url}/transaction/process"
|
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)
|
method_id = int(payment_method if payment_method is not None else self.payment_method)
|
||||||
|
|
||||||
body: Dict[str, Any] = {
|
body: Dict[str, Any] = {
|
||||||
@@ -482,13 +499,13 @@ async def pay_platega_callback_handler(
|
|||||||
user_id=callback.from_user.id,
|
user_id=callback.from_user.id,
|
||||||
parts=parts,
|
parts=parts,
|
||||||
subscription_service=platega_service.subscription_service,
|
subscription_service=platega_service.subscription_service,
|
||||||
currency="rub",
|
currency=default_currency_key_for_settings(settings),
|
||||||
)
|
)
|
||||||
if not parts:
|
if not parts:
|
||||||
await notify_callback_parse_error(callback, translator)
|
await notify_callback_parse_error(callback, translator)
|
||||||
return
|
return
|
||||||
|
|
||||||
currency_code = settings.DEFAULT_CURRENCY_SYMBOL or "RUB"
|
currency_code = default_payment_currency_code_for_settings(settings)
|
||||||
payment_description = describe_payment(translator, parts)
|
payment_description = describe_payment(translator, parts)
|
||||||
record_payload = build_payment_record_payload(
|
record_payload = build_payment_record_payload(
|
||||||
user_id=callback.from_user.id,
|
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(
|
payment = await create_webapp_payment_record(
|
||||||
ctx,
|
ctx,
|
||||||
amount=ctx.price,
|
amount=ctx.price,
|
||||||
currency=settings.DEFAULT_CURRENCY_SYMBOL or "RUB",
|
currency=ctx.currency or settings.DEFAULT_CURRENCY_SYMBOL or "RUB",
|
||||||
status="pending_platega",
|
status="pending_platega",
|
||||||
provider="platega",
|
provider="platega",
|
||||||
)
|
)
|
||||||
@@ -616,7 +633,7 @@ async def _create_webapp_payment(ctx: WebAppPaymentContext, variant: str) -> web
|
|||||||
)
|
)
|
||||||
success, response_data = await service.create_transaction(
|
success, response_data = await service.create_transaction(
|
||||||
amount=ctx.price,
|
amount=ctx.price,
|
||||||
currency=settings.DEFAULT_CURRENCY_SYMBOL or "RUB",
|
currency=ctx.currency or settings.DEFAULT_CURRENCY_SYMBOL or "RUB",
|
||||||
description=ctx.description,
|
description=ctx.description,
|
||||||
payload=payload,
|
payload=payload,
|
||||||
payment_method=platega_method_id,
|
payment_method=platega_method_id,
|
||||||
@@ -757,6 +774,18 @@ _CONFIG_MANIFEST = (
|
|||||||
subsection="Platega",
|
subsection="Platega",
|
||||||
attr="CRYPTO_METHOD",
|
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(
|
ProviderManifestField(
|
||||||
"PLATEGA_RETURN_URL", "url", "Return URL", subsection="Platega", attr="RETURN_URL"
|
"PLATEGA_RETURN_URL", "url", "Return URL", subsection="Platega", attr="RETURN_URL"
|
||||||
),
|
),
|
||||||
@@ -793,6 +822,12 @@ SBP_SPEC = PaymentProviderSpec(
|
|||||||
presentation_class=PlategaSbpPresentation,
|
presentation_class=PlategaSbpPresentation,
|
||||||
manifest_fields=_CONFIG_MANIFEST
|
manifest_fields=_CONFIG_MANIFEST
|
||||||
+ _platega_presentation_manifest("Platega", "CreditCard", "PLATEGA_SBP"),
|
+ _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(
|
CRYPTO_SPEC = PaymentProviderSpec(
|
||||||
@@ -818,6 +853,12 @@ CRYPTO_SPEC = PaymentProviderSpec(
|
|||||||
config_class=PlategaConfig,
|
config_class=PlategaConfig,
|
||||||
presentation_class=PlategaCryptoPresentation,
|
presentation_class=PlategaCryptoPresentation,
|
||||||
manifest_fields=_platega_presentation_manifest("Platega", "Bitcoin", "PLATEGA_CRYPTO"),
|
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)
|
SPECS = (SBP_SPEC, CRYPTO_SPEC)
|
||||||
|
|||||||
@@ -16,6 +16,10 @@ from bot.middlewares.i18n import JsonI18n
|
|||||||
from bot.services.referral_service import ReferralService
|
from bot.services.referral_service import ReferralService
|
||||||
from bot.services.subscription_service import SubscriptionService
|
from bot.services.subscription_service import SubscriptionService
|
||||||
from config.settings import Settings
|
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 db.dal import payment_dal
|
||||||
|
|
||||||
from .base import (
|
from .base import (
|
||||||
@@ -24,6 +28,8 @@ from .base import (
|
|||||||
ProviderManifestField,
|
ProviderManifestField,
|
||||||
ServiceFactoryContext,
|
ServiceFactoryContext,
|
||||||
WebAppPaymentContext,
|
WebAppPaymentContext,
|
||||||
|
normalize_payment_currency_code,
|
||||||
|
parse_supported_currency_codes,
|
||||||
provider_env_file,
|
provider_env_file,
|
||||||
provider_runtime_enabled,
|
provider_runtime_enabled,
|
||||||
)
|
)
|
||||||
@@ -69,6 +75,7 @@ class SeverPayConfig(ProviderEnvConfig):
|
|||||||
RETURN_URL: Optional[str] = None
|
RETURN_URL: Optional[str] = None
|
||||||
BASE_URL: str = Field(default="https://severpay.io/api/merchant")
|
BASE_URL: str = Field(default="https://severpay.io/api/merchant")
|
||||||
LIFETIME_MINUTES: Optional[int] = None
|
LIFETIME_MINUTES: Optional[int] = None
|
||||||
|
SUPPORTED_CURRENCIES: str = Field(default="RUB,USD")
|
||||||
|
|
||||||
@field_validator("MID", "LIFETIME_MINUTES", mode="before")
|
@field_validator("MID", "LIFETIME_MINUTES", mode="before")
|
||||||
@classmethod
|
@classmethod
|
||||||
@@ -201,9 +208,19 @@ class SeverPayService(HttpClientMixin):
|
|||||||
logging.error("SeverPayService is not configured. Cannot create payment.")
|
logging.error("SeverPayService is not configured. Cannot create payment.")
|
||||||
return False, {"message": "service_not_configured"}
|
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()
|
session = await self._get_session()
|
||||||
url = f"{self.base_url}/payin/create"
|
url = f"{self.base_url}/payin/create"
|
||||||
currency_code = (currency or self.settings.DEFAULT_CURRENCY_SYMBOL or "RUB").upper()
|
|
||||||
|
|
||||||
body = {
|
body = {
|
||||||
"order_id": str(payment_db_id),
|
"order_id": str(payment_db_id),
|
||||||
@@ -428,13 +445,13 @@ async def pay_severpay_callback_handler(
|
|||||||
user_id=callback.from_user.id,
|
user_id=callback.from_user.id,
|
||||||
parts=parts,
|
parts=parts,
|
||||||
subscription_service=severpay_service.subscription_service,
|
subscription_service=severpay_service.subscription_service,
|
||||||
currency="rub",
|
currency=default_currency_key_for_settings(settings),
|
||||||
)
|
)
|
||||||
if not parts:
|
if not parts:
|
||||||
await notify_callback_parse_error(callback, translator)
|
await notify_callback_parse_error(callback, translator)
|
||||||
return
|
return
|
||||||
|
|
||||||
currency_code = settings.DEFAULT_CURRENCY_SYMBOL or "RUB"
|
currency_code = default_payment_currency_code_for_settings(settings)
|
||||||
payment_description = describe_payment(translator, parts)
|
payment_description = describe_payment(translator, parts)
|
||||||
record_payload = build_payment_record_payload(
|
record_payload = build_payment_record_payload(
|
||||||
user_id=callback.from_user.id,
|
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:
|
if not service or not service.configured:
|
||||||
return payment_unavailable()
|
return payment_unavailable()
|
||||||
|
|
||||||
currency = settings.DEFAULT_CURRENCY_SYMBOL or "RUB"
|
currency = ctx.currency or settings.DEFAULT_CURRENCY_SYMBOL or "RUB"
|
||||||
try:
|
try:
|
||||||
payment = await create_webapp_payment_record(
|
payment = await create_webapp_payment_record(
|
||||||
ctx,
|
ctx,
|
||||||
@@ -627,6 +644,18 @@ _CONFIG_MANIFEST = (
|
|||||||
max=4320,
|
max=4320,
|
||||||
attr="LIFETIME_MINUTES",
|
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,
|
config_class=SeverPayConfig,
|
||||||
presentation_class=SeverPayPresentation,
|
presentation_class=SeverPayPresentation,
|
||||||
manifest_fields=_CONFIG_MANIFEST + _PRESENTATION_MANIFEST,
|
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",
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -484,4 +484,6 @@ SPEC = PaymentProviderSpec(
|
|||||||
telegram_emoji="⭐",
|
telegram_emoji="⭐",
|
||||||
presentation_class=StarsPresentation,
|
presentation_class=StarsPresentation,
|
||||||
manifest_fields=_PRESENTATION_MANIFEST,
|
manifest_fields=_PRESENTATION_MANIFEST,
|
||||||
|
supported_currencies=("XTR",),
|
||||||
|
currency_support_note="Telegram Stars use Telegram's XTR currency and separate Stars prices.",
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -19,6 +19,10 @@ from bot.services.referral_service import ReferralService
|
|||||||
from bot.services.subscription_service import SubscriptionService
|
from bot.services.subscription_service import SubscriptionService
|
||||||
from bot.utils.request_security import ip_in_allowlist, request_client_ip
|
from bot.utils.request_security import ip_in_allowlist, request_client_ip
|
||||||
from config.settings import Settings
|
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 db.dal import payment_dal
|
||||||
|
|
||||||
from .base import (
|
from .base import (
|
||||||
@@ -27,6 +31,7 @@ from .base import (
|
|||||||
ProviderManifestField,
|
ProviderManifestField,
|
||||||
ServiceFactoryContext,
|
ServiceFactoryContext,
|
||||||
WebAppPaymentContext,
|
WebAppPaymentContext,
|
||||||
|
normalize_payment_currency_code,
|
||||||
provider_env_file,
|
provider_env_file,
|
||||||
provider_runtime_enabled,
|
provider_runtime_enabled,
|
||||||
)
|
)
|
||||||
@@ -63,6 +68,7 @@ from .shared import (
|
|||||||
|
|
||||||
router = Router(name="user_subscription_payments_wata_router")
|
router = Router(name="user_subscription_payments_wata_router")
|
||||||
_LOG = "wata"
|
_LOG = "wata"
|
||||||
|
WATA_SUPPORTED_CURRENCIES = ("RUB", "USD", "EUR")
|
||||||
_WATA_IN_PROGRESS_STATUSES = {"created", "pending"}
|
_WATA_IN_PROGRESS_STATUSES = {"created", "pending"}
|
||||||
_WATA_LINK_OPENED_STATUSES = {"opened", "open"}
|
_WATA_LINK_OPENED_STATUSES = {"opened", "open"}
|
||||||
_WATA_LINK_DEFAULT_TTL_MINUTES = 15
|
_WATA_LINK_DEFAULT_TTL_MINUTES = 15
|
||||||
@@ -258,13 +264,23 @@ class WataService(HttpClientMixin):
|
|||||||
logging.error("WataService is not configured. Cannot create payment link.")
|
logging.error("WataService is not configured. Cannot create payment link.")
|
||||||
return False, {"message": "service_not_configured"}
|
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()
|
session = await self._get_session()
|
||||||
expires_at = (
|
expires_at = (
|
||||||
datetime.now(timezone.utc) + timedelta(minutes=self.payment_link_ttl_minutes)
|
datetime.now(timezone.utc) + timedelta(minutes=self.payment_link_ttl_minutes)
|
||||||
).replace(microsecond=0)
|
).replace(microsecond=0)
|
||||||
body: Dict[str, Any] = {
|
body: Dict[str, Any] = {
|
||||||
"amount": float(format_decimal_amount(amount)),
|
"amount": float(format_decimal_amount(amount)),
|
||||||
"currency": (currency or self.settings.DEFAULT_CURRENCY_SYMBOL or "RUB").upper(),
|
"currency": currency_code,
|
||||||
"description": description,
|
"description": description,
|
||||||
"orderId": str(payment_db_id),
|
"orderId": str(payment_db_id),
|
||||||
"successRedirectUrl": self.return_url,
|
"successRedirectUrl": self.return_url,
|
||||||
@@ -863,13 +879,13 @@ async def pay_wata_callback_handler(
|
|||||||
user_id=callback.from_user.id,
|
user_id=callback.from_user.id,
|
||||||
parts=parts,
|
parts=parts,
|
||||||
subscription_service=wata_service.subscription_service,
|
subscription_service=wata_service.subscription_service,
|
||||||
currency="rub",
|
currency=default_currency_key_for_settings(settings),
|
||||||
)
|
)
|
||||||
if not parts:
|
if not parts:
|
||||||
await notify_callback_parse_error(callback, translator)
|
await notify_callback_parse_error(callback, translator)
|
||||||
return
|
return
|
||||||
|
|
||||||
currency_code = settings.DEFAULT_CURRENCY_SYMBOL or "RUB"
|
currency_code = default_payment_currency_code_for_settings(settings)
|
||||||
payment_description = describe_payment(translator, parts)
|
payment_description = describe_payment(translator, parts)
|
||||||
|
|
||||||
reuse_amounts = payment_record_amounts(months=parts.months, sale_mode=parts.sale_mode)
|
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:
|
if not service or not service.configured:
|
||||||
return payment_unavailable()
|
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(
|
reuse_amounts = payment_record_amounts(
|
||||||
months=ctx.months,
|
months=ctx.months,
|
||||||
@@ -1190,4 +1206,9 @@ SPEC = PaymentProviderSpec(
|
|||||||
config_class=WataConfig,
|
config_class=WataConfig,
|
||||||
presentation_class=WataPresentation,
|
presentation_class=WataPresentation,
|
||||||
manifest_fields=_CONFIG_MANIFEST + _PRESENTATION_MANIFEST,
|
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",
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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.install_links import ensure_user_install_guide_links
|
||||||
from bot.utils.request_security import ip_in_allowlist, request_client_ip
|
from bot.utils.request_security import ip_in_allowlist, request_client_ip
|
||||||
from config.settings import Settings
|
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.dal import payment_dal, user_billing_dal, user_dal
|
||||||
from db.models import Payment
|
from db.models import Payment
|
||||||
|
|
||||||
@@ -49,6 +53,7 @@ from .base import (
|
|||||||
ProviderManifestField,
|
ProviderManifestField,
|
||||||
ServiceFactoryContext,
|
ServiceFactoryContext,
|
||||||
WebAppPaymentContext,
|
WebAppPaymentContext,
|
||||||
|
normalize_payment_currency_code,
|
||||||
provider_env_file,
|
provider_env_file,
|
||||||
provider_runtime_enabled,
|
provider_runtime_enabled,
|
||||||
)
|
)
|
||||||
@@ -234,6 +239,11 @@ class YooKassaService:
|
|||||||
"internal_message": "Service settings (Settings object) not initialized.",
|
"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 = {}
|
customer_contact_for_receipt = {}
|
||||||
if receipt_email:
|
if receipt_email:
|
||||||
customer_contact_for_receipt["email"] = receipt_email
|
customer_contact_for_receipt["email"] = receipt_email
|
||||||
@@ -885,7 +895,10 @@ async def process_successful_payment(
|
|||||||
i18n=i18n,
|
i18n=i18n,
|
||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
amount=payment_value,
|
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,
|
months_for_admin=int(subscription_months) if sale_mode_base == "subscription" else 0,
|
||||||
traffic_gb_for_admin=(
|
traffic_gb_for_admin=(
|
||||||
traffic_amount_gb if is_traffic_sale_base(sale_mode_base) else None
|
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,
|
user_id=callback.from_user.id,
|
||||||
parts=PaymentCallbackParts(months=months, price=price_rub, sale_mode=sale_mode),
|
parts=PaymentCallbackParts(months=months, price=price_rub, sale_mode=sale_mode),
|
||||||
subscription_service=yookassa_service.subscription_service,
|
subscription_service=yookassa_service.subscription_service,
|
||||||
currency="rub",
|
currency=default_currency_key_for_settings(settings),
|
||||||
)
|
)
|
||||||
if not quoted_parts:
|
if not quoted_parts:
|
||||||
try:
|
try:
|
||||||
@@ -1713,7 +1726,7 @@ async def pay_yk_callback_handler(
|
|||||||
months = quoted_parts.months
|
months = quoted_parts.months
|
||||||
price_rub = quoted_parts.price
|
price_rub = quoted_parts.price
|
||||||
user_id = callback.from_user.id
|
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(
|
autopay_enabled = bool(
|
||||||
settings.yookassa_autopayments_active
|
settings.yookassa_autopayments_active
|
||||||
and _sale_mode_base(sale_mode) == "subscription"
|
and _sale_mode_base(sale_mode) == "subscription"
|
||||||
@@ -1851,7 +1864,7 @@ async def pay_yk_new_card_handler(
|
|||||||
|
|
||||||
months, price_rub, sale_mode = parsed
|
months, price_rub, sale_mode = parsed
|
||||||
user_id = callback.from_user.id
|
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(
|
autopay_enabled = bool(
|
||||||
settings.yookassa_autopayments_active
|
settings.yookassa_autopayments_active
|
||||||
and _sale_mode_base(sale_mode) == "subscription"
|
and _sale_mode_base(sale_mode) == "subscription"
|
||||||
@@ -2154,7 +2167,7 @@ async def pay_yk_use_saved_handler(
|
|||||||
pass
|
pass
|
||||||
return
|
return
|
||||||
|
|
||||||
currency_code_for_yk = "RUB"
|
currency_code_for_yk = default_payment_currency_code_for_settings(settings)
|
||||||
|
|
||||||
await _initiate_yk_payment(
|
await _initiate_yk_payment(
|
||||||
callback,
|
callback,
|
||||||
@@ -2260,7 +2273,7 @@ async def payment_method_bind(
|
|||||||
metadata = {"user_id": str(callback.from_user.id), "bind_only": "1"}
|
metadata = {"user_id": str(callback.from_user.id), "bind_only": "1"}
|
||||||
resp = await yookassa_service.create_payment(
|
resp = await yookassa_service.create_payment(
|
||||||
amount=1.00,
|
amount=1.00,
|
||||||
currency="RUB",
|
currency=default_payment_currency_code_for_settings(settings),
|
||||||
description="Bind card",
|
description="Bind card",
|
||||||
metadata=metadata,
|
metadata=metadata,
|
||||||
receipt_email=yookassa_service.config.DEFAULT_RECEIPT_EMAIL,
|
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"]
|
service: YooKassaService = ctx.request.app["yookassa_service"]
|
||||||
if not service or not service.configured:
|
if not service or not service.configured:
|
||||||
return payment_unavailable()
|
return payment_unavailable()
|
||||||
|
currency = (ctx.currency or "RUB").upper()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
amounts = payment_record_amounts(
|
amounts = payment_record_amounts(
|
||||||
@@ -2744,7 +2758,7 @@ async def create_webapp_payment(ctx: WebAppPaymentContext) -> web.Response:
|
|||||||
payment = await create_webapp_payment_record(
|
payment = await create_webapp_payment_record(
|
||||||
ctx,
|
ctx,
|
||||||
amount=ctx.price,
|
amount=ctx.price,
|
||||||
currency="RUB",
|
currency=currency,
|
||||||
status="pending_yookassa",
|
status="pending_yookassa",
|
||||||
provider="yookassa",
|
provider="yookassa",
|
||||||
)
|
)
|
||||||
@@ -2767,7 +2781,7 @@ async def create_webapp_payment(ctx: WebAppPaymentContext) -> web.Response:
|
|||||||
metadata["tariff_key"] = amounts.tariff_key
|
metadata["tariff_key"] = amounts.tariff_key
|
||||||
response = await service.create_payment(
|
response = await service.create_payment(
|
||||||
amount=ctx.price,
|
amount=ctx.price,
|
||||||
currency="RUB",
|
currency=currency,
|
||||||
description=ctx.description,
|
description=ctx.description,
|
||||||
metadata=metadata,
|
metadata=metadata,
|
||||||
receipt_email=service.config.DEFAULT_RECEIPT_EMAIL,
|
receipt_email=service.config.DEFAULT_RECEIPT_EMAIL,
|
||||||
@@ -2929,4 +2943,10 @@ SPEC = PaymentProviderSpec(
|
|||||||
config_class=YooKassaConfig,
|
config_class=YooKassaConfig,
|
||||||
presentation_class=YooKassaPresentation,
|
presentation_class=YooKassaPresentation,
|
||||||
manifest_fields=_CONFIG_MANIFEST + _PRESENTATION_MANIFEST,
|
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 subprocess
|
||||||
import tempfile
|
import tempfile
|
||||||
import zipfile
|
import zipfile
|
||||||
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from pathlib import Path, PurePosixPath
|
from pathlib import Path, PurePosixPath
|
||||||
from typing import Any, Optional
|
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 (
|
from bot.services.backup_archive import (
|
||||||
BACKUP_APP_ID,
|
BACKUP_APP_ID,
|
||||||
BACKUP_FILENAME_PREFIX,
|
BACKUP_FILENAME_PREFIX,
|
||||||
@@ -29,6 +34,8 @@ from bot.services.backup_worker import (
|
|||||||
DEFAULT_COMPOSE_EXCLUDED_DIRS,
|
DEFAULT_COMPOSE_EXCLUDED_DIRS,
|
||||||
)
|
)
|
||||||
from config.settings import Settings
|
from config.settings import Settings
|
||||||
|
from db.migrator import MIGRATIONS, run_database_migrations
|
||||||
|
from db.models import Base
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -42,6 +49,23 @@ BACKUP_MAX_COMPRESSION_RATIO = 200
|
|||||||
BACKUP_ZIP_BOMB_MIN_BYTES = 100 * 1024 * 1024
|
BACKUP_ZIP_BOMB_MIN_BYTES = 100 * 1024 * 1024
|
||||||
COMPOSE_PRE_RESTORE_PREFIX = "minishop-pre-restore-"
|
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$")
|
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):
|
class BackupArchiveError(ValueError):
|
||||||
@@ -92,6 +116,7 @@ class BackupRestoreResult:
|
|||||||
compose_files_restored: int = 0
|
compose_files_restored: int = 0
|
||||||
compose_target_dir: Optional[str] = None
|
compose_target_dir: Optional[str] = None
|
||||||
compose_pre_restore_archive: 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)
|
warnings: list[str] = field(default_factory=list)
|
||||||
|
|
||||||
def to_payload(self) -> dict[str, Any]:
|
def to_payload(self) -> dict[str, Any]:
|
||||||
@@ -103,6 +128,7 @@ class BackupRestoreResult:
|
|||||||
"compose_files_restored": self.compose_files_restored,
|
"compose_files_restored": self.compose_files_restored,
|
||||||
"compose_target_dir": self.compose_target_dir,
|
"compose_target_dir": self.compose_target_dir,
|
||||||
"compose_pre_restore_archive": self.compose_pre_restore_archive,
|
"compose_pre_restore_archive": self.compose_pre_restore_archive,
|
||||||
|
"database_migrations_applied": self.database_migrations_applied,
|
||||||
"warnings": self.warnings,
|
"warnings": self.warnings,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -242,9 +268,11 @@ class BackupRestoreService:
|
|||||||
compose_pre_restore_archive = self._snapshot_current_compose(compose_target_dir)
|
compose_pre_restore_archive = self._snapshot_current_compose(compose_target_dir)
|
||||||
|
|
||||||
database_restored = False
|
database_restored = False
|
||||||
|
database_migrations_applied: list[str] = []
|
||||||
if db_member is not None:
|
if db_member is not None:
|
||||||
dump_path = self._extract_database_dump(archive, db_member, temp_dir)
|
dump_path = self._extract_database_dump(archive, db_member, temp_dir)
|
||||||
self._run_pg_restore(dump_path)
|
self._run_pg_restore(dump_path)
|
||||||
|
database_migrations_applied = self._run_post_restore_migrations()
|
||||||
database_restored = True
|
database_restored = True
|
||||||
|
|
||||||
compose_files_restored = 0
|
compose_files_restored = 0
|
||||||
@@ -265,9 +293,50 @@ class BackupRestoreService:
|
|||||||
compose_pre_restore_archive=str(compose_pre_restore_archive)
|
compose_pre_restore_archive=str(compose_pre_restore_archive)
|
||||||
if compose_pre_restore_archive
|
if compose_pre_restore_archive
|
||||||
else None,
|
else None,
|
||||||
|
database_migrations_applied=database_migrations_applied,
|
||||||
warnings=warnings,
|
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:
|
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 = str(getattr(self.settings, "BACKUP_PG_RESTORE_PATH", "pg_restore") or "")
|
||||||
pg_restore_path = pg_restore_path or "pg_restore"
|
pg_restore_path = pg_restore_path or "pg_restore"
|
||||||
|
|||||||
@@ -17,8 +17,9 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||||||
|
|
||||||
from bot.middlewares.i18n import JsonI18n
|
from bot.middlewares.i18n import JsonI18n
|
||||||
from bot.services.email_templates import EmailContent, render_login_code
|
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 config.settings import Settings
|
||||||
from db.dal import security_dal
|
from db.dal import security_dal, user_dal
|
||||||
from db.models import EmailVerificationCode
|
from db.models import EmailVerificationCode
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -222,6 +223,28 @@ class EmailAuthService:
|
|||||||
magic_link=magic_link,
|
magic_link=magic_link,
|
||||||
purpose=purpose,
|
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)
|
return EmailCodeRequestResult(ok=True)
|
||||||
|
|
||||||
async def verify_code(
|
async def verify_code(
|
||||||
|
|||||||
@@ -12,11 +12,12 @@ from __future__ import annotations
|
|||||||
import html
|
import html
|
||||||
import re
|
import re
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Optional, Sequence, Tuple
|
from typing import TYPE_CHECKING, Optional, Sequence, Tuple
|
||||||
from urllib.parse import urlsplit
|
from urllib.parse import urlsplit
|
||||||
|
|
||||||
from bot.middlewares.i18n import JsonI18n, get_i18n_instance, normalize_locale_language_code
|
if TYPE_CHECKING:
|
||||||
from config.settings import Settings
|
from bot.middlewares.i18n import JsonI18n
|
||||||
|
from config.settings import Settings
|
||||||
|
|
||||||
_BG = "#05070a"
|
_BG = "#05070a"
|
||||||
_CARD_BG = "#0e1116"
|
_CARD_BG = "#0e1116"
|
||||||
@@ -64,14 +65,16 @@ def _brand_title(settings: Settings) -> str:
|
|||||||
|
|
||||||
|
|
||||||
def _normalize_lang(language_code: Optional[str], settings: Settings) -> str:
|
def _normalize_lang(language_code: Optional[str], settings: Settings) -> str:
|
||||||
return normalize_locale_language_code(
|
value = str(language_code or settings.DEFAULT_LANGUAGE or "ru").strip().lower()
|
||||||
language_code or settings.DEFAULT_LANGUAGE or "ru",
|
return value.replace("_", "-") or "ru"
|
||||||
prefer_known_base=False,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _resolve_i18n(i18n: Optional[JsonI18n]) -> JsonI18n:
|
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:
|
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(
|
def _layout(
|
||||||
*,
|
*,
|
||||||
settings: Settings,
|
settings: Settings,
|
||||||
|
language_code: str,
|
||||||
preheader: str,
|
preheader: str,
|
||||||
heading: str,
|
heading: str,
|
||||||
intro_html: str,
|
intro_html: str,
|
||||||
@@ -98,6 +102,7 @@ def _layout(
|
|||||||
accent = _safe_color(settings.WEBAPP_PRIMARY_COLOR)
|
accent = _safe_color(settings.WEBAPP_PRIMARY_COLOR)
|
||||||
brand_title = html.escape(_brand_title(settings))
|
brand_title = html.escape(_brand_title(settings))
|
||||||
logo_url = _public_logo_url(settings)
|
logo_url = _public_logo_url(settings)
|
||||||
|
html_lang = html.escape((language_code or "en").replace("_", "-"), quote=True)
|
||||||
logo_block = ""
|
logo_block = ""
|
||||||
if logo_url:
|
if logo_url:
|
||||||
logo_block = (
|
logo_block = (
|
||||||
@@ -107,7 +112,7 @@ def _layout(
|
|||||||
)
|
)
|
||||||
|
|
||||||
return f"""<!DOCTYPE html>
|
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>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||||
@@ -307,6 +312,7 @@ def render_login_code(
|
|||||||
|
|
||||||
rendered = _layout(
|
rendered = _layout(
|
||||||
settings=settings,
|
settings=settings,
|
||||||
|
language_code=lang,
|
||||||
preheader=preheader,
|
preheader=preheader,
|
||||||
heading=heading,
|
heading=heading,
|
||||||
intro_html=html.escape(intro),
|
intro_html=html.escape(intro),
|
||||||
@@ -359,6 +365,7 @@ def render_account_merged(
|
|||||||
|
|
||||||
rendered = _layout(
|
rendered = _layout(
|
||||||
settings=settings,
|
settings=settings,
|
||||||
|
language_code=lang,
|
||||||
preheader=preheader,
|
preheader=preheader,
|
||||||
heading=heading,
|
heading=heading,
|
||||||
intro_html=html.escape(intro),
|
intro_html=html.escape(intro),
|
||||||
@@ -492,6 +499,7 @@ def render_payment_success(
|
|||||||
|
|
||||||
rendered = _layout(
|
rendered = _layout(
|
||||||
settings=settings,
|
settings=settings,
|
||||||
|
language_code=lang,
|
||||||
preheader=preheader,
|
preheader=preheader,
|
||||||
heading=heading,
|
heading=heading,
|
||||||
intro_html=html.escape(intro),
|
intro_html=html.escape(intro),
|
||||||
@@ -542,6 +550,7 @@ def render_user_notification(
|
|||||||
|
|
||||||
rendered = _layout(
|
rendered = _layout(
|
||||||
settings=settings,
|
settings=settings,
|
||||||
|
language_code=lang,
|
||||||
preheader=final_subject,
|
preheader=final_subject,
|
||||||
heading=final_heading,
|
heading=final_heading,
|
||||||
intro_html=html.escape(final_intro),
|
intro_html=html.escape(final_intro),
|
||||||
@@ -615,6 +624,7 @@ def render_subscription_expiring(
|
|||||||
|
|
||||||
rendered = _layout(
|
rendered = _layout(
|
||||||
settings=settings,
|
settings=settings,
|
||||||
|
language_code=lang,
|
||||||
preheader=preheader,
|
preheader=preheader,
|
||||||
heading=heading,
|
heading=heading,
|
||||||
intro_html=html.escape(intro),
|
intro_html=html.escape(intro),
|
||||||
@@ -702,6 +712,7 @@ def render_subscription_lifecycle_notification(
|
|||||||
|
|
||||||
rendered = _layout(
|
rendered = _layout(
|
||||||
settings=settings,
|
settings=settings,
|
||||||
|
language_code=lang,
|
||||||
preheader=subject,
|
preheader=subject,
|
||||||
heading=subject,
|
heading=subject,
|
||||||
intro_html=html.escape(intro),
|
intro_html=html.escape(intro),
|
||||||
@@ -738,28 +749,40 @@ def _support_email(
|
|||||||
ticket_url: Optional[str],
|
ticket_url: Optional[str],
|
||||||
cta_label: str,
|
cta_label: str,
|
||||||
) -> EmailContent:
|
) -> EmailContent:
|
||||||
|
i18n = _resolve_i18n(i18n)
|
||||||
lang = _normalize_lang(language, settings)
|
lang = _normalize_lang(language, settings)
|
||||||
brand = _brand_title(settings)
|
brand = _brand_title(settings)
|
||||||
accent = _safe_color(settings.WEBAPP_PRIMARY_COLOR)
|
accent = _safe_color(settings.WEBAPP_PRIMARY_COLOR)
|
||||||
safe_url = (ticket_url or "").strip()
|
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 = (
|
preview_block = (
|
||||||
f'<div style="margin:0 0 16px 0;background:{_BG};border:1px solid {_BORDER};'
|
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"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>'
|
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:
|
if safe_url:
|
||||||
body_parts.append(_cta_button_html(label=cta_label, url=safe_url, accent=accent))
|
body_parts.append(_cta_button_html(label=cta_label, url=safe_url, accent=accent))
|
||||||
rendered = _layout(
|
rendered = _layout(
|
||||||
settings=settings,
|
settings=settings,
|
||||||
|
language_code=lang,
|
||||||
preheader=intro,
|
preheader=intro,
|
||||||
heading=heading,
|
heading=heading,
|
||||||
intro_html=html.escape(intro),
|
intro_html=html.escape(intro),
|
||||||
body_html="".join(body_parts),
|
body_html="".join(body_parts),
|
||||||
footer_html=footer,
|
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:
|
if safe_url:
|
||||||
text_lines.extend(["", safe_url])
|
text_lines.extend(["", safe_url])
|
||||||
return EmailContent(subject=subject, text="\n".join(text_lines), html=rendered)
|
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]],
|
snapshot_rows: Sequence[Tuple[str, str]],
|
||||||
ticket_url: Optional[str],
|
ticket_url: Optional[str],
|
||||||
) -> EmailContent:
|
) -> EmailContent:
|
||||||
|
i18n = _resolve_i18n(i18n)
|
||||||
|
lang = _normalize_lang(language, settings)
|
||||||
rows = [
|
rows = [
|
||||||
("Ticket", f"#{ticket_id}"),
|
("email_support_row_ticket", f"#{ticket_id}"),
|
||||||
("User", user_display),
|
("email_support_row_user", user_display),
|
||||||
("Subject", subject),
|
("email_support_row_subject", subject),
|
||||||
*snapshot_rows,
|
*snapshot_rows,
|
||||||
]
|
]
|
||||||
return _support_email(
|
return _support_email(
|
||||||
settings,
|
settings,
|
||||||
i18n,
|
i18n,
|
||||||
language,
|
lang,
|
||||||
subject=f"New support ticket #{ticket_id}",
|
subject=_t_text(i18n, lang, "email_support_new_ticket_admin_subject", ticket_id=ticket_id),
|
||||||
heading=f"New support ticket #{ticket_id}",
|
heading=_t_text(i18n, lang, "email_support_new_ticket_admin_heading", ticket_id=ticket_id),
|
||||||
intro="A user opened a new support ticket.",
|
intro=_t_text(i18n, lang, "email_support_new_ticket_admin_intro"),
|
||||||
rows=rows,
|
rows=rows,
|
||||||
body_preview=body_preview,
|
body_preview=body_preview,
|
||||||
ticket_url=ticket_url,
|
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]],
|
snapshot_rows: Sequence[Tuple[str, str]],
|
||||||
ticket_url: Optional[str],
|
ticket_url: Optional[str],
|
||||||
) -> EmailContent:
|
) -> EmailContent:
|
||||||
|
i18n = _resolve_i18n(i18n)
|
||||||
|
lang = _normalize_lang(language, settings)
|
||||||
rows = [
|
rows = [
|
||||||
("Ticket", f"#{ticket_id}"),
|
("email_support_row_ticket", f"#{ticket_id}"),
|
||||||
("User", user_display),
|
("email_support_row_user", user_display),
|
||||||
("Subject", subject),
|
("email_support_row_subject", subject),
|
||||||
*snapshot_rows,
|
*snapshot_rows,
|
||||||
]
|
]
|
||||||
return _support_email(
|
return _support_email(
|
||||||
settings,
|
settings,
|
||||||
i18n,
|
i18n,
|
||||||
language,
|
lang,
|
||||||
subject=f"New user reply in ticket #{ticket_id}",
|
subject=_t_text(i18n, lang, "email_support_user_reply_admin_subject", ticket_id=ticket_id),
|
||||||
heading=f"User replied in ticket #{ticket_id}",
|
heading=_t_text(i18n, lang, "email_support_user_reply_admin_heading", ticket_id=ticket_id),
|
||||||
intro="A user sent a new support message.",
|
intro=_t_text(i18n, lang, "email_support_user_reply_admin_intro"),
|
||||||
rows=rows,
|
rows=rows,
|
||||||
body_preview=body_preview,
|
body_preview=body_preview,
|
||||||
ticket_url=ticket_url,
|
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,
|
body_preview: str,
|
||||||
ticket_url: Optional[str],
|
ticket_url: Optional[str],
|
||||||
) -> EmailContent:
|
) -> EmailContent:
|
||||||
|
i18n = _resolve_i18n(i18n)
|
||||||
|
lang = _normalize_lang(language, settings)
|
||||||
return _support_email(
|
return _support_email(
|
||||||
settings,
|
settings,
|
||||||
i18n,
|
i18n,
|
||||||
language,
|
lang,
|
||||||
subject=f"New reply for ticket #{ticket_id}",
|
subject=_t_text(i18n, lang, "email_support_admin_reply_user_subject", ticket_id=ticket_id),
|
||||||
heading=f"New reply for ticket #{ticket_id}",
|
heading=_t_text(i18n, lang, "email_support_admin_reply_user_heading", ticket_id=ticket_id),
|
||||||
intro="Support has replied to your ticket.",
|
intro=_t_text(i18n, lang, "email_support_admin_reply_user_intro"),
|
||||||
rows=[("Ticket", f"#{ticket_id}"), ("Subject", subject)],
|
rows=[
|
||||||
|
("email_support_row_ticket", f"#{ticket_id}"),
|
||||||
|
("email_support_row_subject", subject),
|
||||||
|
],
|
||||||
body_preview=body_preview,
|
body_preview=body_preview,
|
||||||
ticket_url=ticket_url,
|
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 = "",
|
body_preview: str = "",
|
||||||
ticket_url: Optional[str],
|
ticket_url: Optional[str],
|
||||||
) -> EmailContent:
|
) -> EmailContent:
|
||||||
|
i18n = _resolve_i18n(i18n)
|
||||||
|
lang = _normalize_lang(language, settings)
|
||||||
return _support_email(
|
return _support_email(
|
||||||
settings,
|
settings,
|
||||||
i18n,
|
i18n,
|
||||||
language,
|
lang,
|
||||||
subject=f"Ticket #{ticket_id} was closed",
|
subject=_t_text(
|
||||||
heading=f"Ticket #{ticket_id} was closed",
|
i18n, lang, "email_support_ticket_closed_user_subject", ticket_id=ticket_id
|
||||||
intro="Your support ticket has been closed.",
|
),
|
||||||
rows=[("Ticket", f"#{ticket_id}"), ("Subject", subject)],
|
heading=_t_text(
|
||||||
body_preview=body_preview or "The ticket is closed.",
|
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,
|
ticket_url=ticket_url,
|
||||||
cta_label="Open in Mini App",
|
cta_label=_t_text(i18n, lang, "email_support_cta_open_mini_app"),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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,
|
||||||
|
)
|
||||||
@@ -273,10 +273,10 @@ class NotificationService:
|
|||||||
return []
|
return []
|
||||||
rows = []
|
rows = []
|
||||||
for key, label in (
|
for key, label in (
|
||||||
("tariff", "Tariff"),
|
("tariff", "email_support_row_tariff"),
|
||||||
("end_date", "End date"),
|
("end_date", "email_support_row_end_date"),
|
||||||
("remaining", "Remaining"),
|
("remaining", "email_support_row_remaining"),
|
||||||
("panel_status", "Panel status"),
|
("panel_status", "email_support_row_panel_status"),
|
||||||
):
|
):
|
||||||
value = snapshot.get(key)
|
value = snapshot.get(key)
|
||||||
if value:
|
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.middlewares.i18n import JsonI18n
|
||||||
from bot.services.email_auth_service import EmailAuthService
|
from bot.services.email_auth_service import EmailAuthService
|
||||||
from bot.services.email_templates import render_subscription_lifecycle_notification
|
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 (
|
from bot.services.telegram_notifications import (
|
||||||
TELEGRAM_NOTIFICATIONS_BLOCKED,
|
TELEGRAM_NOTIFICATIONS_BLOCKED,
|
||||||
TELEGRAM_NOTIFICATIONS_ENABLED,
|
TELEGRAM_NOTIFICATIONS_ENABLED,
|
||||||
@@ -188,6 +189,18 @@ class SubscriptionLifecycleNotificationService:
|
|||||||
self._channel_key(stage.key, "telegram"),
|
self._channel_key(stage.key, "telegram"),
|
||||||
sent_at=sent_at,
|
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:
|
if user:
|
||||||
status = normalize_telegram_notification_status(
|
status = normalize_telegram_notification_status(
|
||||||
getattr(user, "telegram_notifications_status", None)
|
getattr(user, "telegram_notifications_status", None)
|
||||||
@@ -253,6 +266,18 @@ class SubscriptionLifecycleNotificationService:
|
|||||||
self._channel_key(stage.key, "email"),
|
self._channel_key(stage.key, "email"),
|
||||||
sent_at=sent_at,
|
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
|
return True
|
||||||
|
|
||||||
async def _already_sent(
|
async def _already_sent(
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ from sqlalchemy.orm import selectinload, sessionmaker
|
|||||||
from bot.infra.redis import redis_lock
|
from bot.infra.redis import redis_lock
|
||||||
from bot.keyboards.inline.user_keyboards import get_subscribe_only_markup
|
from bot.keyboards.inline.user_keyboards import get_subscribe_only_markup
|
||||||
from bot.middlewares.i18n import JsonI18n
|
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.panel_api_service import PanelApiService
|
||||||
from bot.services.subscription_lifecycle_notifications import (
|
from bot.services.subscription_lifecycle_notifications import (
|
||||||
SubscriptionLifecycleNotificationService,
|
SubscriptionLifecycleNotificationService,
|
||||||
@@ -331,6 +332,17 @@ class SubscriptionNotificationWorker:
|
|||||||
parse_mode="HTML",
|
parse_mode="HTML",
|
||||||
)
|
)
|
||||||
telegram_sent = True
|
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:
|
except Exception as exc:
|
||||||
status = telegram_notification_status_from_error(exc)
|
status = telegram_notification_status_from_error(exc)
|
||||||
if status and user and user_id:
|
if status and user and user_id:
|
||||||
@@ -355,6 +367,12 @@ class SubscriptionNotificationWorker:
|
|||||||
subject_key="email_trial_traffic_depleted_subject",
|
subject_key="email_trial_traffic_depleted_subject",
|
||||||
message_text=message_text,
|
message_text=message_text,
|
||||||
dashboard_url=(getattr(self.settings, "SUBSCRIPTION_MINI_APP_URL", "") or None),
|
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}
|
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.config_link import prepare_config_links
|
||||||
from bot.utils.date_utils import add_months, month_start
|
from bot.utils.date_utils import add_months, month_start
|
||||||
from config.settings import Settings
|
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 (
|
from db.dal import (
|
||||||
payment_dal,
|
payment_dal,
|
||||||
promo_code_dal,
|
promo_code_dal,
|
||||||
|
|||||||
@@ -70,7 +70,7 @@ class HwidDeviceMixin:
|
|||||||
package_set = tariff.hwid_device_packages
|
package_set = tariff.hwid_device_packages
|
||||||
if not package_set:
|
if not package_set:
|
||||||
return None
|
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)
|
return next((pkg for pkg in packages if int(pkg.count) == int(device_count)), None)
|
||||||
|
|
||||||
def _quote_hwid_package_price(
|
def _quote_hwid_package_price(
|
||||||
@@ -173,7 +173,7 @@ class HwidDeviceMixin:
|
|||||||
valid_from=valid_from,
|
valid_from=valid_from,
|
||||||
valid_until=valid_until,
|
valid_until=valid_until,
|
||||||
now=now,
|
now=now,
|
||||||
currency="stars" if currency == "stars" else "rub",
|
currency=currency,
|
||||||
)
|
)
|
||||||
quote.update(
|
quote.update(
|
||||||
{
|
{
|
||||||
@@ -227,7 +227,11 @@ class HwidDeviceMixin:
|
|||||||
)
|
)
|
||||||
return None
|
return None
|
||||||
packages = (
|
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
|
if tariff.hwid_device_packages
|
||||||
else []
|
else []
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -248,9 +248,9 @@ class SubscriptionLifecycleMixin:
|
|||||||
traffic_used_bytes=used_sub,
|
traffic_used_bytes=used_sub,
|
||||||
)
|
)
|
||||||
update_data["period_start_at"] = None
|
update_data["period_start_at"] = None
|
||||||
update_data["effective_monthly_price_rub"] = (
|
update_data["effective_monthly_price_rub"] = target.period_price(
|
||||||
target.period_price(1, "rub") or target.min_period_price_rub()
|
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:
|
if mode == "recalc_days" and options.get("recalc_days") is not None:
|
||||||
update_data["end_date"] = now + timedelta(days=int(options["recalc_days"]))
|
update_data["end_date"] = now + timedelta(days=int(options["recalc_days"]))
|
||||||
else:
|
else:
|
||||||
|
|||||||
@@ -62,6 +62,9 @@ class PaymentContextMixin:
|
|||||||
async def has_had_any_subscription(self, session: AsyncSession, user_id: int) -> bool:
|
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)
|
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:
|
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)."""
|
"""Return True if user currently has an active subscription (end_date in future)."""
|
||||||
try:
|
try:
|
||||||
@@ -120,7 +123,7 @@ class PaymentContextMixin:
|
|||||||
months=int(months or 0),
|
months=int(months or 0),
|
||||||
traffic_gb=traffic_gb,
|
traffic_gb=traffic_gb,
|
||||||
amount=float(payment_amount or 0),
|
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,
|
end_date_text=end_date_text,
|
||||||
dashboard_url=dashboard_url,
|
dashboard_url=dashboard_url,
|
||||||
provider_label=provider_label,
|
provider_label=provider_label,
|
||||||
|
|||||||
@@ -41,6 +41,22 @@ class RenewalMixin:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
months = sub.duration_months or 1
|
months = sub.duration_months or 1
|
||||||
|
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)
|
amount = self.settings.subscription_options.get(months)
|
||||||
if not amount:
|
if not amount:
|
||||||
logging.error(f"Auto-renew price missing for {months} months")
|
logging.error(f"Auto-renew price missing for {months} months")
|
||||||
@@ -53,7 +69,7 @@ class RenewalMixin:
|
|||||||
}
|
}
|
||||||
resp = await yk.create_payment(
|
resp = await yk.create_payment(
|
||||||
amount=float(amount),
|
amount=float(amount),
|
||||||
currency="RUB",
|
currency=currency,
|
||||||
description=f"Auto-renewal for {months} months",
|
description=f"Auto-renewal for {months} months",
|
||||||
metadata=metadata,
|
metadata=metadata,
|
||||||
payment_method_id=default_pm.provider_payment_method_id,
|
payment_method_id=default_pm.provider_payment_method_id,
|
||||||
|
|||||||
@@ -112,15 +112,14 @@ class TariffMixin:
|
|||||||
regular_unlimited_override: bool,
|
regular_unlimited_override: bool,
|
||||||
traffic_used_bytes: int,
|
traffic_used_bytes: int,
|
||||||
) -> 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 = (
|
floor = (
|
||||||
int(tier_baseline_bytes or 0)
|
int(tier_baseline_bytes or 0)
|
||||||
+ max(0, int(topup_balance_bytes or 0))
|
+ max(0, int(topup_balance_bytes or 0))
|
||||||
+ max(0, int(regular_bonus_bytes or 0))
|
+ max(0, int(regular_bonus_bytes or 0))
|
||||||
)
|
)
|
||||||
if regular_unlimited_override:
|
if regular_unlimited_override:
|
||||||
used = max(0, int(traffic_used_bytes or 0))
|
return 0
|
||||||
return max(floor, used + 512 * (1024**3), 1024**5)
|
|
||||||
return floor
|
return floor
|
||||||
|
|
||||||
async def premium_access_for_tariff(self, tariff: Optional[Tariff]) -> Dict[str, Any]:
|
async def premium_access_for_tariff(self, tariff: Optional[Tariff]) -> Dict[str, Any]:
|
||||||
@@ -331,11 +330,12 @@ class TariffMixin:
|
|||||||
remaining_days = max(0, (sub.end_date - now).days) if sub.end_date else 0
|
remaining_days = max(0, (sub.end_date - now).days) if sub.end_date else 0
|
||||||
effective = float(sub.effective_monthly_price_rub or 0)
|
effective = float(sub.effective_monthly_price_rub or 0)
|
||||||
current_model = current_tariff.billing_model if current_tariff else "period"
|
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":
|
if current_model == "period" and target_tariff.billing_model == "period":
|
||||||
target_monthly = (
|
target_monthly = (
|
||||||
target_tariff.period_price(1, "rub")
|
target_tariff.period_price(1, default_currency)
|
||||||
or target_tariff.min_period_price_rub()
|
or target_tariff.min_period_price(default_currency)
|
||||||
or effective
|
or effective
|
||||||
or 1
|
or 1
|
||||||
)
|
)
|
||||||
@@ -355,11 +355,14 @@ class TariffMixin:
|
|||||||
"remaining_days": remaining_days,
|
"remaining_days": remaining_days,
|
||||||
"recalc_days": max(0, days_after),
|
"recalc_days": max(0, days_after),
|
||||||
"paid_diff_rub": paid_diff,
|
"paid_diff_rub": paid_diff,
|
||||||
|
"paid_diff": paid_diff,
|
||||||
"target_monthly_rub": float(target_monthly),
|
"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":
|
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
|
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
|
converted_gb = math.floor(remaining_value / rub_per_gb) if rub_per_gb else 0
|
||||||
return {
|
return {
|
||||||
@@ -367,9 +370,15 @@ class TariffMixin:
|
|||||||
"remaining_days": remaining_days,
|
"remaining_days": remaining_days,
|
||||||
"converted_gb": max(0, converted_gb),
|
"converted_gb": max(0, converted_gb),
|
||||||
"rub_per_gb": rub_per_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
|
@staticmethod
|
||||||
def _aware_utc(value: Optional[datetime]) -> Optional[datetime]:
|
def _aware_utc(value: Optional[datetime]) -> Optional[datetime]:
|
||||||
@@ -434,6 +443,7 @@ class TariffMixin:
|
|||||||
credit = await self._hwid_conversion_credit(session, sub, at=now)
|
credit = await self._hwid_conversion_credit(session, sub, at=now)
|
||||||
value_rub = float(credit.get("value_rub") or 0)
|
value_rub = float(credit.get("value_rub") or 0)
|
||||||
options["converted_hwid_value_rub"] = round(value_rub, 2)
|
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["convertible_hwid_purchase_ids"] = list(credit.get("purchase_ids") or [])
|
||||||
options["nonconverted_hwid_devices"] = int(credit.get("skipped_devices") or 0)
|
options["nonconverted_hwid_devices"] = int(credit.get("skipped_devices") or 0)
|
||||||
if value_rub <= 0:
|
if value_rub <= 0:
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ class TrialSubscriptionMixin:
|
|||||||
"message_key": "user_not_found_for_trial",
|
"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 {
|
return {
|
||||||
"eligible": False,
|
"eligible": False,
|
||||||
"activated": False,
|
"activated": False,
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ from sqlalchemy.orm import sessionmaker
|
|||||||
|
|
||||||
from bot.infra.redis import redis_lock
|
from bot.infra.redis import redis_lock
|
||||||
from bot.middlewares.i18n import JsonI18n
|
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.panel_api_service import PanelApiService
|
||||||
from bot.services.subscription_service import SubscriptionService
|
from bot.services.subscription_service import SubscriptionService
|
||||||
from bot.services.user_email_notifications import send_user_notification_email
|
from bot.services.user_email_notifications import send_user_notification_email
|
||||||
@@ -111,6 +112,8 @@ class TariffTrafficWorker:
|
|||||||
subject_key: str,
|
subject_key: str,
|
||||||
message_text: str,
|
message_text: str,
|
||||||
kind: str,
|
kind: str,
|
||||||
|
warning_key: str,
|
||||||
|
audit_content: str,
|
||||||
) -> None:
|
) -> None:
|
||||||
try:
|
try:
|
||||||
user = await user_dal.get_user_by_id(session, user_id)
|
user = await user_dal.get_user_by_id(session, user_id)
|
||||||
@@ -131,6 +134,9 @@ class TariffTrafficWorker:
|
|||||||
if kind == "premium"
|
if kind == "premium"
|
||||||
else "email_traffic_warning_regular_cta"
|
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:
|
async def run(self) -> None:
|
||||||
@@ -602,6 +608,15 @@ class TariffTrafficWorker:
|
|||||||
**usage,
|
**usage,
|
||||||
)
|
)
|
||||||
subject_key = "email_traffic_warning_regular_depleted_subject"
|
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:
|
if self.bot:
|
||||||
try:
|
try:
|
||||||
markup = self._traffic_topup_markup(user_lang, "regular")
|
markup = self._traffic_topup_markup(user_lang, "regular")
|
||||||
@@ -611,6 +626,14 @@ class TariffTrafficWorker:
|
|||||||
reply_markup=markup,
|
reply_markup=markup,
|
||||||
parse_mode="HTML",
|
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:
|
except Exception:
|
||||||
logging.exception("Failed to send traffic warning to user %s", sub.user_id)
|
logging.exception("Failed to send traffic warning to user %s", sub.user_id)
|
||||||
await self._send_traffic_warning_email(
|
await self._send_traffic_warning_email(
|
||||||
@@ -619,6 +642,8 @@ class TariffTrafficWorker:
|
|||||||
subject_key=subject_key,
|
subject_key=subject_key,
|
||||||
message_text=text,
|
message_text=text,
|
||||||
kind="regular",
|
kind="regular",
|
||||||
|
warning_key=warning_key,
|
||||||
|
audit_content=audit_content,
|
||||||
)
|
)
|
||||||
if ratio >= 1.0 and not sub.is_throttled:
|
if ratio >= 1.0 and not sub.is_throttled:
|
||||||
logging.info(
|
logging.info(
|
||||||
@@ -1066,6 +1091,11 @@ class TariffTrafficWorker:
|
|||||||
servers=servers,
|
servers=servers,
|
||||||
**usage,
|
**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:
|
if self.bot:
|
||||||
try:
|
try:
|
||||||
markup = self._traffic_topup_markup(user_lang, "premium")
|
markup = self._traffic_topup_markup(user_lang, "premium")
|
||||||
@@ -1075,6 +1105,14 @@ class TariffTrafficWorker:
|
|||||||
reply_markup=markup,
|
reply_markup=markup,
|
||||||
parse_mode="HTML",
|
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:
|
except Exception:
|
||||||
logging.exception(
|
logging.exception(
|
||||||
"Failed to send premium traffic depleted warning to user %s", sub.user_id
|
"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",
|
subject_key="email_traffic_warning_premium_depleted_subject",
|
||||||
message_text=text,
|
message_text=text,
|
||||||
kind="premium",
|
kind="premium",
|
||||||
|
warning_key=warning_key,
|
||||||
|
audit_content=audit_content,
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -1134,6 +1174,11 @@ class TariffTrafficWorker:
|
|||||||
servers=servers,
|
servers=servers,
|
||||||
**usage,
|
**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:
|
if self.bot:
|
||||||
try:
|
try:
|
||||||
markup = self._traffic_topup_markup(user_lang, "premium")
|
markup = self._traffic_topup_markup(user_lang, "premium")
|
||||||
@@ -1143,6 +1188,14 @@ class TariffTrafficWorker:
|
|||||||
reply_markup=markup,
|
reply_markup=markup,
|
||||||
parse_mode="HTML",
|
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:
|
except Exception:
|
||||||
logging.exception(
|
logging.exception(
|
||||||
"Failed to send premium traffic warning to user %s", sub.user_id
|
"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",
|
subject_key="email_traffic_warning_premium_almost_subject",
|
||||||
message_text=text,
|
message_text=text,
|
||||||
kind="premium",
|
kind="premium",
|
||||||
|
warning_key=warning_key,
|
||||||
|
audit_content=audit_content,
|
||||||
)
|
)
|
||||||
|
|
||||||
async def _premium_node_uuids_for_tariff(self, tariff) -> list[str]:
|
async def _premium_node_uuids_for_tariff(self, tariff) -> list[str]:
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ from typing import Any, Optional
|
|||||||
|
|
||||||
from aiogram import Bot
|
from aiogram import Bot
|
||||||
from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError
|
from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError
|
||||||
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup, WebAppInfo
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from bot.middlewares.i18n import JsonI18n
|
from bot.middlewares.i18n import JsonI18n
|
||||||
@@ -128,37 +127,6 @@ async def mark_telegram_notifications_enabled_for_telegram_user(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _translate(
|
|
||||||
i18n: Optional[JsonI18n],
|
|
||||||
language: str,
|
|
||||||
key: str,
|
|
||||||
fallback: str,
|
|
||||||
**kwargs: Any,
|
|
||||||
) -> str:
|
|
||||||
if not i18n:
|
|
||||||
return fallback.format(**kwargs) if kwargs else fallback
|
|
||||||
return i18n.gettext(language, key, **kwargs) or fallback
|
|
||||||
|
|
||||||
|
|
||||||
def _probe_keyboard(
|
|
||||||
settings: Settings,
|
|
||||||
i18n: Optional[JsonI18n],
|
|
||||||
language: str,
|
|
||||||
) -> Optional[InlineKeyboardMarkup]:
|
|
||||||
app_url = str(getattr(settings, "SUBSCRIPTION_MINI_APP_URL", "") or "").strip()
|
|
||||||
if not app_url:
|
|
||||||
return None
|
|
||||||
text = _translate(
|
|
||||||
i18n,
|
|
||||||
language,
|
|
||||||
"telegram_notifications_open_app_button",
|
|
||||||
"Open app",
|
|
||||||
)
|
|
||||||
return InlineKeyboardMarkup(
|
|
||||||
inline_keyboard=[[InlineKeyboardButton(text=text, web_app=WebAppInfo(url=app_url))]]
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def probe_telegram_notifications(
|
async def probe_telegram_notifications(
|
||||||
*,
|
*,
|
||||||
session: AsyncSession,
|
session: AsyncSession,
|
||||||
@@ -187,20 +155,8 @@ async def probe_telegram_notifications(
|
|||||||
"start_link": telegram_notifications_start_link(bot_username),
|
"start_link": telegram_notifications_start_link(bot_username),
|
||||||
}
|
}
|
||||||
|
|
||||||
language = str(getattr(user, "language_code", "") or settings.DEFAULT_LANGUAGE)
|
|
||||||
text = _translate(
|
|
||||||
i18n,
|
|
||||||
language,
|
|
||||||
"telegram_notifications_enabled_message",
|
|
||||||
"Telegram notifications are enabled.",
|
|
||||||
)
|
|
||||||
try:
|
try:
|
||||||
await bot.send_message(
|
await bot.get_chat(int(telegram_id))
|
||||||
int(telegram_id),
|
|
||||||
text,
|
|
||||||
reply_markup=_probe_keyboard(settings, i18n, language),
|
|
||||||
disable_web_page_preview=True,
|
|
||||||
)
|
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
status = telegram_notification_status_from_error(exc)
|
status = telegram_notification_status_from_error(exc)
|
||||||
if status:
|
if status:
|
||||||
@@ -211,7 +167,7 @@ async def probe_telegram_notifications(
|
|||||||
"start_link": telegram_notifications_start_link(bot_username),
|
"start_link": telegram_notifications_start_link(bot_username),
|
||||||
}
|
}
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"Telegram notification probe failed for user %s / telegram %s: %s",
|
"Telegram notification chat probe failed for user %s / telegram %s: %s",
|
||||||
user.user_id,
|
user.user_id,
|
||||||
telegram_id,
|
telegram_id,
|
||||||
exc,
|
exc,
|
||||||
|
|||||||
@@ -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
|
import logging
|
||||||
from typing import Any, Optional
|
from typing import Any, Optional
|
||||||
|
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from bot.middlewares.i18n import JsonI18n
|
from bot.middlewares.i18n import JsonI18n
|
||||||
from bot.services.email_auth_service import EmailAuthService
|
from bot.services.email_auth_service import EmailAuthService
|
||||||
from bot.services.email_templates import render_user_notification
|
from bot.services.email_templates import render_user_notification
|
||||||
|
from bot.services.message_audit import log_user_message_delivery
|
||||||
from config.settings import Settings
|
from config.settings import Settings
|
||||||
|
|
||||||
|
|
||||||
@@ -34,6 +37,9 @@ async def send_user_notification_email(
|
|||||||
subject_kwargs: Optional[dict[str, Any]] = None,
|
subject_kwargs: Optional[dict[str, Any]] = None,
|
||||||
heading_key: Optional[str] = None,
|
heading_key: Optional[str] = None,
|
||||||
intro_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:
|
) -> bool:
|
||||||
if not getattr(settings, "email_auth_configured", False):
|
if not getattr(settings, "email_auth_configured", False):
|
||||||
return False
|
return False
|
||||||
@@ -78,6 +84,15 @@ async def send_user_notification_email(
|
|||||||
email=recipient,
|
email=recipient,
|
||||||
content=content,
|
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
|
return True
|
||||||
except Exception:
|
except Exception:
|
||||||
logging.exception("Failed to send user notification email to %s.", recipient)
|
logging.exception("Failed to send user notification email to %s.", recipient)
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -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)
|
||||||
@@ -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
|
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_URL: Optional[str] = None
|
||||||
PANEL_API_KEY: Optional[str] = None
|
PANEL_API_KEY: Optional[str] = None
|
||||||
USER_TRAFFIC_LIMIT_GB: Optional[float] = Field(default=0.0)
|
USER_TRAFFIC_LIMIT_GB: Optional[float] = Field(default=0.0)
|
||||||
@@ -560,6 +584,17 @@ class Settings(BaseSettings):
|
|||||||
ids = self.ADMIN_IDS
|
ids = self.ADMIN_IDS
|
||||||
return ids[0] if ids else None
|
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
|
@computed_field
|
||||||
@property
|
@property
|
||||||
def trial_traffic_limit_bytes(self) -> int:
|
def trial_traffic_limit_bytes(self) -> int:
|
||||||
@@ -983,6 +1018,7 @@ class Settings(BaseSettings):
|
|||||||
"LOG_SUPPORT_THREAD_ID",
|
"LOG_SUPPORT_THREAD_ID",
|
||||||
"BACKUP_CHAT_ID",
|
"BACKUP_CHAT_ID",
|
||||||
"BACKUP_THREAD_ID",
|
"BACKUP_THREAD_ID",
|
||||||
|
"REQUIRED_CHANNEL_ID",
|
||||||
mode="before",
|
mode="before",
|
||||||
)
|
)
|
||||||
@classmethod
|
@classmethod
|
||||||
@@ -1025,6 +1061,28 @@ class Settings(BaseSettings):
|
|||||||
return None
|
return None
|
||||||
return v
|
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
|
# Notification types
|
||||||
LOG_NEW_USERS: bool = Field(
|
LOG_NEW_USERS: bool = Field(
|
||||||
default=True, description="Send notifications for new user registrations"
|
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")
|
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(
|
model_config = SettingsConfigDict(
|
||||||
env_file=".env", env_file_encoding="utf-8", extra="ignore", populate_by_name=True
|
env_file=".env", env_file_encoding="utf-8", extra="ignore", populate_by_name=True
|
||||||
)
|
)
|
||||||
@@ -1066,6 +1155,11 @@ def get_settings() -> Settings:
|
|||||||
logging.warning(
|
logging.warning(
|
||||||
"CRITICAL: PANEL_API_URL is not set. Panel integration will not work."
|
"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"):
|
if not os.getenv("WEBAPP_SESSION_SECRET"):
|
||||||
logging.warning(
|
logging.warning(
|
||||||
"WEBAPP_SESSION_SECRET is not set. A generated secret will be used for this process only." # noqa: E501
|
"WEBAPP_SESSION_SECRET is not set. A generated secret will be used for this process only." # noqa: E501
|
||||||
|
|||||||
@@ -1,14 +1,53 @@
|
|||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
from pathlib import Path
|
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"]
|
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):
|
class TrafficPackage(BaseModel):
|
||||||
gb: float
|
gb: float
|
||||||
price: float
|
price: float
|
||||||
@@ -61,26 +100,76 @@ class HwidDevicePackage(BaseModel):
|
|||||||
return float(self.price) * months_int
|
return float(self.price) * months_int
|
||||||
|
|
||||||
|
|
||||||
class PackageSet(BaseModel):
|
class PackageSet(RootModel[Dict[str, List[TrafficPackage]]]):
|
||||||
rub: List[TrafficPackage] = Field(default_factory=list)
|
root: Dict[str, List[TrafficPackage]] = Field(default_factory=dict)
|
||||||
stars: List[TrafficPackage] = Field(default_factory=list)
|
|
||||||
|
@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]:
|
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:
|
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):
|
class HwidDevicePackageSet(RootModel[Dict[str, List[HwidDevicePackage]]]):
|
||||||
rub: List[HwidDevicePackage] = Field(default_factory=list)
|
root: Dict[str, List[HwidDevicePackage]] = Field(default_factory=dict)
|
||||||
stars: List[HwidDevicePackage] = Field(default_factory=list)
|
|
||||||
|
@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]:
|
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:
|
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):
|
class Tariff(BaseModel):
|
||||||
@@ -93,6 +182,7 @@ class Tariff(BaseModel):
|
|||||||
enabled: bool = True
|
enabled: bool = True
|
||||||
|
|
||||||
monthly_gb: Optional[float] = None
|
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_rub: Dict[str, float] = Field(default_factory=dict)
|
||||||
prices_stars: 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)
|
referral_bonus_days_inviter: Dict[str, int] = Field(default_factory=dict)
|
||||||
@@ -101,6 +191,7 @@ class Tariff(BaseModel):
|
|||||||
topup_packages: Optional[PackageSet] = None
|
topup_packages: Optional[PackageSet] = None
|
||||||
|
|
||||||
traffic_packages: Optional[PackageSet] = None
|
traffic_packages: Optional[PackageSet] = None
|
||||||
|
conversion_rate_per_gb: Optional[float] = None
|
||||||
conversion_rate_rub_per_gb: Optional[float] = None
|
conversion_rate_rub_per_gb: Optional[float] = None
|
||||||
hwid_device_limit: Optional[int] = None
|
hwid_device_limit: Optional[int] = None
|
||||||
hwid_device_packages: Optional[HwidDevicePackageSet] = 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:
|
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")
|
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.billing_model == "period":
|
||||||
if self.monthly_gb is None or self.monthly_gb < 0:
|
if self.monthly_gb is None or self.monthly_gb < 0:
|
||||||
raise ValueError(f"period tariff {self.key}: monthly_gb must be >= 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:
|
for months in self.enabled_periods:
|
||||||
if months <= 0:
|
if months <= 0:
|
||||||
raise ValueError(f"period tariff {self.key}: enabled periods must be positive")
|
raise ValueError(f"period tariff {self.key}: enabled periods must be positive")
|
||||||
rub_price = self.prices_rub.get(str(months), 0) or 0
|
period_prices = [
|
||||||
stars_price = self.prices_stars.get(str(months), 0) or 0
|
float(prices.get(str(months), 0) or 0) for prices in self.prices.values()
|
||||||
if rub_price <= 0 and stars_price <= 0:
|
]
|
||||||
|
if not any(price > 0 for price in period_prices):
|
||||||
raise ValueError(
|
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
|
return self
|
||||||
|
|
||||||
if not self.traffic_packages or not self.traffic_packages.has_any():
|
if not self.traffic_packages or not self.traffic_packages.has_any():
|
||||||
raise ValueError(f"traffic tariff {self.key}: traffic_packages is required")
|
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:
|
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 must be > 0")
|
|
||||||
if not self.traffic_packages.rub and self.conversion_rate_rub_per_gb is None:
|
|
||||||
raise ValueError(
|
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
|
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(
|
def _normalize_referral_bonus_map(
|
||||||
self, values: Dict[str, int], field_name: str
|
self, values: Dict[str, int], field_name: str
|
||||||
) -> Dict[str, int]:
|
) -> Dict[str, int]:
|
||||||
@@ -196,7 +337,7 @@ class Tariff(BaseModel):
|
|||||||
return int(float(self.monthly_gb) * (1024**3))
|
return int(float(self.monthly_gb) * (1024**3))
|
||||||
|
|
||||||
def period_price(self, months: int, currency: Currency = "rub") -> Optional[float]:
|
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))
|
value = source.get(str(months))
|
||||||
return float(value) if value is not None else None
|
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)))
|
value = self.referral_bonus_days_referee.get(str(int(months)))
|
||||||
return int(value) if value is not None else None
|
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 = [
|
prices = [
|
||||||
float(self.prices_rub[str(months)])
|
float(source[str(months)])
|
||||||
for months in self.enabled_periods
|
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
|
return min(prices) if prices else None
|
||||||
|
|
||||||
def min_traffic_package_rub(self) -> Optional[TrafficPackage]:
|
def min_period_price_rub(self) -> Optional[float]:
|
||||||
packages = self.traffic_packages.rub if self.traffic_packages else []
|
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
|
return min(packages, key=lambda pkg: pkg.price) if packages else None
|
||||||
|
|
||||||
def rub_per_gb_for_conversion(self) -> float:
|
def min_traffic_package_rub(self) -> Optional[TrafficPackage]:
|
||||||
if self.conversion_rate_rub_per_gb:
|
return self.min_traffic_package("rub")
|
||||||
return float(self.conversion_rate_rub_per_gb)
|
|
||||||
packages = self.traffic_packages.rub if self.traffic_packages else []
|
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)
|
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:
|
def has_hwid_device_packages(self) -> bool:
|
||||||
return bool(self.hwid_device_packages and self.hwid_device_packages.has_any())
|
return bool(self.hwid_device_packages and self.hwid_device_packages.has_any())
|
||||||
|
|
||||||
@@ -244,11 +401,15 @@ class Tariff(BaseModel):
|
|||||||
|
|
||||||
class TariffsConfig(BaseModel):
|
class TariffsConfig(BaseModel):
|
||||||
default_tariff: str
|
default_tariff: str
|
||||||
|
default_currency: str = DEFAULT_TARIFF_CURRENCY
|
||||||
topup_packages_default: Optional[PackageSet] = None
|
topup_packages_default: Optional[PackageSet] = None
|
||||||
tariffs: List[Tariff]
|
tariffs: List[Tariff]
|
||||||
|
|
||||||
@model_validator(mode="after")
|
@model_validator(mode="after")
|
||||||
def validate_config(self) -> "TariffsConfig":
|
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]
|
keys = [tariff.key for tariff in self.tariffs]
|
||||||
if len(keys) != len(set(keys)):
|
if len(keys) != len(set(keys)):
|
||||||
raise ValueError("tariff keys must be unique")
|
raise ValueError("tariff keys must be unique")
|
||||||
@@ -277,6 +438,10 @@ class TariffsConfig(BaseModel):
|
|||||||
def default(self) -> Tariff:
|
def default(self) -> Tariff:
|
||||||
return self.require(self.default_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]:
|
def topup_packages_for(self, tariff: Tariff) -> Optional[PackageSet]:
|
||||||
if tariff.billing_model == "traffic":
|
if tariff.billing_model == "traffic":
|
||||||
return tariff.traffic_packages
|
return tariff.traffic_packages
|
||||||
|
|||||||
@@ -4,12 +4,12 @@ import secrets
|
|||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
from typing import Any, Dict, List, Optional
|
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.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy.future import select
|
from sqlalchemy.future import select
|
||||||
from sqlalchemy.orm import selectinload
|
from sqlalchemy.orm import selectinload
|
||||||
|
|
||||||
from db.models import Subscription, SubscriptionNotification
|
from db.models import Subscription, SubscriptionNotification, User
|
||||||
|
|
||||||
INSTALL_SHARE_TOKEN_BYTES = 16
|
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:
|
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)
|
stmt = delete(Subscription).where(Subscription.user_id == user_id)
|
||||||
result = await session.execute(stmt)
|
result = await session.execute(stmt)
|
||||||
if result.rowcount > 0:
|
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
|
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(
|
async def get_subscriptions_near_expiration(
|
||||||
session: AsyncSession, days_threshold: int
|
session: AsyncSession, days_threshold: int
|
||||||
) -> List[Subscription]:
|
) -> List[Subscription]:
|
||||||
|
|||||||
+176
-14
@@ -95,6 +95,39 @@ async def get_user_by_id(session: AsyncSession, user_id: int) -> Optional[User]:
|
|||||||
return result.scalar_one_or_none()
|
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]:
|
async def get_user_by_username(session: AsyncSession, username: str) -> Optional[User]:
|
||||||
clean_username = username.lstrip("@").lower()
|
clean_username = username.lstrip("@").lower()
|
||||||
stmt = select(User).where(func.lower(User.username) == clean_username)
|
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(
|
async def _has_active_panel_subscription(
|
||||||
session: AsyncSession, user_id: int, panel_user_uuid: str
|
session: AsyncSession, user_id: int, panel_user_uuid: str
|
||||||
) -> bool:
|
) -> bool:
|
||||||
@@ -666,17 +713,33 @@ async def get_enhanced_user_statistics(session: AsyncSession) -> Dict[str, Any]:
|
|||||||
active_today = int(user_counts[2] or 0)
|
active_today = int(user_counts[2] or 0)
|
||||||
referral_users = int(user_counts[3] or 0)
|
referral_users = int(user_counts[3] or 0)
|
||||||
|
|
||||||
subscription_counts_stmt = (
|
provider_value = func.lower(func.coalesce(Subscription.provider, ""))
|
||||||
|
panel_status_value = func.upper(func.coalesce(Subscription.status_from_panel, ""))
|
||||||
|
trial_subscription_condition = or_(
|
||||||
|
provider_value == "trial",
|
||||||
|
panel_status_value == "TRIAL",
|
||||||
|
)
|
||||||
|
paid_subscription_condition = and_(
|
||||||
|
provider_value != "",
|
||||||
|
provider_value != "trial",
|
||||||
|
panel_status_value != "TRIAL",
|
||||||
|
)
|
||||||
|
free_subscription_condition = and_(
|
||||||
|
provider_value == "",
|
||||||
|
panel_status_value != "TRIAL",
|
||||||
|
)
|
||||||
|
|
||||||
|
active_subscription_flags_sq = (
|
||||||
select(
|
select(
|
||||||
func.count(
|
Subscription.user_id.label("user_id"),
|
||||||
func.distinct(
|
func.max(case((paid_subscription_condition, 1), else_=0)).label(
|
||||||
case((Subscription.provider.is_not(None), Subscription.user_id), else_=None)
|
"has_paid_subscription"
|
||||||
)
|
|
||||||
),
|
),
|
||||||
func.count(
|
func.max(case((trial_subscription_condition, 1), else_=0)).label(
|
||||||
func.distinct(
|
"has_trial_subscription"
|
||||||
case((Subscription.provider.is_(None), Subscription.user_id), else_=None)
|
),
|
||||||
)
|
func.max(case((free_subscription_condition, 1), else_=0)).label(
|
||||||
|
"has_free_subscription"
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.join(User, Subscription.user_id == User.user_id)
|
.join(User, Subscription.user_id == User.user_id)
|
||||||
@@ -686,27 +749,66 @@ async def get_enhanced_user_statistics(session: AsyncSession) -> Dict[str, Any]:
|
|||||||
Subscription.end_date > now,
|
Subscription.end_date > now,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
.group_by(Subscription.user_id)
|
||||||
|
.subquery()
|
||||||
|
)
|
||||||
|
|
||||||
|
subscription_counts_stmt = select(
|
||||||
|
func.count(active_subscription_flags_sq.c.user_id),
|
||||||
|
func.coalesce(func.sum(active_subscription_flags_sq.c.has_paid_subscription), 0),
|
||||||
|
func.coalesce(
|
||||||
|
func.sum(
|
||||||
|
case(
|
||||||
|
(
|
||||||
|
active_subscription_flags_sq.c.has_paid_subscription == 0,
|
||||||
|
active_subscription_flags_sq.c.has_trial_subscription,
|
||||||
|
),
|
||||||
|
else_=0,
|
||||||
|
)
|
||||||
|
),
|
||||||
|
0,
|
||||||
|
),
|
||||||
|
func.coalesce(
|
||||||
|
func.sum(
|
||||||
|
case(
|
||||||
|
(
|
||||||
|
and_(
|
||||||
|
active_subscription_flags_sq.c.has_paid_subscription == 0,
|
||||||
|
active_subscription_flags_sq.c.has_trial_subscription == 0,
|
||||||
|
),
|
||||||
|
active_subscription_flags_sq.c.has_free_subscription,
|
||||||
|
),
|
||||||
|
else_=0,
|
||||||
|
)
|
||||||
|
),
|
||||||
|
0,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
subscription_counts = (await session.execute(subscription_counts_stmt)).one()
|
subscription_counts = (await session.execute(subscription_counts_stmt)).one()
|
||||||
paid_subs_users = int(subscription_counts[0] or 0)
|
active_subscription_users = int(subscription_counts[0] or 0)
|
||||||
trial_users = int(subscription_counts[1] or 0)
|
paid_subs_users = int(subscription_counts[1] or 0)
|
||||||
|
trial_users = int(subscription_counts[2] or 0)
|
||||||
|
free_subscription_users = int(subscription_counts[3] or 0)
|
||||||
|
|
||||||
# Inactive users (no active subscription)
|
inactive_users = total_users - active_subscription_users
|
||||||
inactive_users = total_users - paid_subs_users - trial_users - banned_users
|
expired_subscription_users = await count_users_with_expired_subscription(session)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"total_users": total_users,
|
"total_users": total_users,
|
||||||
"banned_users": banned_users,
|
"banned_users": banned_users,
|
||||||
"active_today": active_today,
|
"active_today": active_today,
|
||||||
|
"active_subscriptions": active_subscription_users,
|
||||||
"paid_subscriptions": paid_subs_users,
|
"paid_subscriptions": paid_subs_users,
|
||||||
"trial_users": trial_users,
|
"trial_users": trial_users,
|
||||||
|
"free_subscription_users": free_subscription_users,
|
||||||
"inactive_users": max(0, inactive_users),
|
"inactive_users": max(0, inactive_users),
|
||||||
|
"expired_subscription_users": expired_subscription_users,
|
||||||
"referral_users": referral_users,
|
"referral_users": referral_users,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
async def get_user_ids_with_active_subscription(session: AsyncSession) -> List[int]:
|
async def get_user_ids_with_active_subscription(session: AsyncSession) -> List[int]:
|
||||||
"""Return non-banned user IDs who have an active subscription (paid or trial)."""
|
"""Return non-banned user IDs who have any active subscription."""
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
now = datetime.now(timezone.utc)
|
now = datetime.now(timezone.utc)
|
||||||
@@ -755,6 +857,66 @@ async def get_user_ids_without_active_subscription(session: AsyncSession) -> Lis
|
|||||||
return result.scalars().all()
|
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:
|
async def delete_user_and_relations(session: AsyncSession, user_id: int) -> bool:
|
||||||
"""Completely remove a user and all dependent records from the database.
|
"""Completely remove a user and all dependent records from the database.
|
||||||
|
|
||||||
|
|||||||
@@ -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}"))
|
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] = [
|
MIGRATIONS: List[Migration] = [
|
||||||
Migration(
|
Migration(
|
||||||
id="0001_add_channel_subscription_fields",
|
id="0001_add_channel_subscription_fields",
|
||||||
@@ -1233,6 +1242,11 @@ MIGRATIONS: List[Migration] = [
|
|||||||
description="Track whether the bot can message Telegram-linked users",
|
description="Track whether the bot can message Telegram-linked users",
|
||||||
upgrade=_migration_0032_add_telegram_notification_status,
|
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,
|
||||||
|
),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ class User(Base):
|
|||||||
referred_by_id = Column(BigInteger, ForeignKey("users.user_id"), nullable=True)
|
referred_by_id = Column(BigInteger, ForeignKey("users.user_id"), nullable=True)
|
||||||
lifetime_used_traffic_bytes = Column(BigInteger, nullable=True)
|
lifetime_used_traffic_bytes = Column(BigInteger, nullable=True)
|
||||||
lifetime_used_traffic_synced_at = Column(DateTime(timezone=True), 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_verified = Column(Boolean, nullable=True)
|
||||||
channel_subscription_checked_at = Column(DateTime(timezone=True), nullable=True)
|
channel_subscription_checked_at = Column(DateTime(timezone=True), nullable=True)
|
||||||
channel_subscription_verified_for = Column(BigInteger, nullable=True)
|
channel_subscription_verified_for = Column(BigInteger, nullable=True)
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ from bot.services.backup_worker import BackupWorker
|
|||||||
from bot.services.locale_override_service import load_locale_overrides
|
from bot.services.locale_override_service import load_locale_overrides
|
||||||
from bot.services.subscription_notification_worker import SubscriptionNotificationWorker
|
from bot.services.subscription_notification_worker import SubscriptionNotificationWorker
|
||||||
from bot.services.tariff_worker import TariffTrafficWorker
|
from bot.services.tariff_worker import TariffTrafficWorker
|
||||||
|
from bot.services.telemetry_worker import TelemetryWorker
|
||||||
from bot.utils.message_queue import init_queue_manager
|
from bot.utils.message_queue import init_queue_manager
|
||||||
from config.settings import get_settings
|
from config.settings import get_settings
|
||||||
|
|
||||||
@@ -209,6 +210,8 @@ async def main() -> None:
|
|||||||
)
|
)
|
||||||
backup_worker = BackupWorker(settings, bot, session_factory=session_factory)
|
backup_worker = BackupWorker(settings, bot, session_factory=session_factory)
|
||||||
tasks.append(asyncio.create_task(backup_worker.run(), name="BackupWorker"))
|
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)))
|
tasks.append(asyncio.create_task(_panel_sync_loop(settings, session_factory, i18n, services)))
|
||||||
for idx in range(max(1, settings.WEBHOOK_QUEUE_CONCURRENCY)):
|
for idx in range(max(1, settings.WEBHOOK_QUEUE_CONCURRENCY)):
|
||||||
tasks.append(
|
tasks.append(
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
{
|
{
|
||||||
"default_tariff": "standard",
|
"default_tariff": "standard",
|
||||||
|
"default_currency": "rub",
|
||||||
"tariffs": [
|
"tariffs": [
|
||||||
{
|
{
|
||||||
"key": "standard",
|
"key": "standard",
|
||||||
|
|||||||
@@ -71,6 +71,7 @@ export default defineConfig({
|
|||||||
items: [
|
items: [
|
||||||
{ label: 'Переменные окружения', slug: 'configuration/env-vars' },
|
{ label: 'Переменные окружения', slug: 'configuration/env-vars' },
|
||||||
{ label: 'Безопасность', slug: 'configuration/security' },
|
{ label: 'Безопасность', slug: 'configuration/security' },
|
||||||
|
{ label: 'Телеметрия', slug: 'configuration/telemetry' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
+11
-10
@@ -1,11 +1,12 @@
|
|||||||
# Cloudflare Pages rewrites for non-materialized static docs demo runtime routes.
|
# Cloudflare Pages rewrites for non-materialized static docs demo runtime routes.
|
||||||
# Use the directory entrypoint so Pages clean URLs do not loop between app.html and app.
|
# Use the generated directory index instead of app.html so Pages clean URLs do not
|
||||||
/demo/runtime/home/* /demo/runtime/app/ 200
|
# loop between extensionless and .html variants.
|
||||||
/demo/runtime/install/* /demo/runtime/app/ 200
|
/demo/runtime/home/* /demo/runtime/app/index.html 200
|
||||||
/demo/runtime/trial/* /demo/runtime/app/ 200
|
/demo/runtime/install/* /demo/runtime/app/index.html 200
|
||||||
/demo/runtime/invite/* /demo/runtime/app/ 200
|
/demo/runtime/trial/* /demo/runtime/app/index.html 200
|
||||||
/demo/runtime/devices/* /demo/runtime/app/ 200
|
/demo/runtime/invite/* /demo/runtime/app/index.html 200
|
||||||
/demo/runtime/support/* /demo/runtime/app/ 200
|
/demo/runtime/devices/* /demo/runtime/app/index.html 200
|
||||||
/demo/runtime/settings/* /demo/runtime/app/ 200
|
/demo/runtime/support/* /demo/runtime/app/index.html 200
|
||||||
/demo/runtime/login/* /demo/runtime/app/ 200
|
/demo/runtime/settings/* /demo/runtime/app/index.html 200
|
||||||
/demo/runtime/admin/* /demo/runtime/app/ 200
|
/demo/runtime/login/* /demo/runtime/app/index.html 200
|
||||||
|
/demo/runtime/admin/* /demo/runtime/app/index.html 200
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ const stateMocks = new Set([
|
|||||||
"devices",
|
"devices",
|
||||||
"notifications",
|
"notifications",
|
||||||
"auth",
|
"auth",
|
||||||
|
"emails",
|
||||||
]);
|
]);
|
||||||
const routeMocks = new Set([...stateMocks, "guides", "install"]);
|
const routeMocks = new Set([...stateMocks, "guides", "install"]);
|
||||||
const params = new URLSearchParams(window.location.search);
|
const params = new URLSearchParams(window.location.search);
|
||||||
@@ -83,6 +84,7 @@ const routeFromParams = () => {
|
|||||||
let initialRoute = routeFromParams();
|
let initialRoute = routeFromParams();
|
||||||
const mockForRoute = (route) => {
|
const mockForRoute = (route) => {
|
||||||
const normalized = normalizePath(route);
|
const normalized = normalizePath(route);
|
||||||
|
if (normalized === "/emails") return "emails";
|
||||||
if (normalized === "/devices") return "devices";
|
if (normalized === "/devices") return "devices";
|
||||||
if (normalized === "/login" || normalized.startsWith("/login/"))
|
if (normalized === "/login" || normalized.startsWith("/login/"))
|
||||||
return "auth";
|
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";
|
initialRoute = "/home";
|
||||||
const normalizedUrl = new URL(window.location.href);
|
const normalizedUrl = new URL(window.location.href);
|
||||||
normalizedUrl.pathname = publicPathFromRoute(initialRoute);
|
normalizedUrl.pathname = publicPathFromRoute(initialRoute);
|
||||||
@@ -134,12 +139,14 @@ if (initialMock === "trial" && initialRoute === "/trial") {
|
|||||||
} else {
|
} else {
|
||||||
canonicalizeInitialPublicUrl(initialRoute);
|
canonicalizeInitialPublicUrl(initialRoute);
|
||||||
}
|
}
|
||||||
params.set("mock", initialMock);
|
if (initialMock !== "emails") {
|
||||||
params.delete("path");
|
params.set("mock", initialMock);
|
||||||
params.delete("screen");
|
params.delete("path");
|
||||||
params.delete("admin_section");
|
params.delete("screen");
|
||||||
params.set("path", initialRoute);
|
params.delete("admin_section");
|
||||||
frame.src = `${runtimeBase}/app/?${params.toString()}${window.location.hash || ""}`;
|
params.set("path", initialRoute);
|
||||||
|
frame.src = `${runtimeBase}/app/?${params.toString()}${window.location.hash || ""}`;
|
||||||
|
}
|
||||||
|
|
||||||
const routeFromRuntimeUrl = (url) => {
|
const routeFromRuntimeUrl = (url) => {
|
||||||
if (url.origin !== window.location.origin) return "";
|
if (url.origin !== window.location.origin) return "";
|
||||||
@@ -154,6 +161,7 @@ const routeFromRuntimeUrl = (url) => {
|
|||||||
return runtimePath;
|
return runtimePath;
|
||||||
};
|
};
|
||||||
const routeForStateMock = (mock) => {
|
const routeForStateMock = (mock) => {
|
||||||
|
if (mock === "emails") return "/emails";
|
||||||
if (mock === "devices") return "/devices";
|
if (mock === "devices") return "/devices";
|
||||||
if (mock === "auth") return "/login";
|
if (mock === "auth") return "/login";
|
||||||
return "/home";
|
return "/home";
|
||||||
@@ -169,8 +177,18 @@ const topbar = document.querySelector(".demo-topbar");
|
|||||||
const toggle = document.querySelector(".demo-topbar__toggle");
|
const toggle = document.querySelector(".demo-topbar__toggle");
|
||||||
const hide = document.querySelector(".demo-topbar__hide");
|
const hide = document.querySelector(".demo-topbar__hide");
|
||||||
const stateSelect = document.querySelector(".demo-topbar__state-select");
|
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 = () => {
|
const syncParentUrlFromFrame = () => {
|
||||||
|
if (frame.hidden) return;
|
||||||
try {
|
try {
|
||||||
const frameUrl = new URL(frame.contentWindow.location.href);
|
const frameUrl = new URL(frame.contentWindow.location.href);
|
||||||
const route = routeFromRuntimeUrl(frameUrl);
|
const route = routeFromRuntimeUrl(frameUrl);
|
||||||
@@ -208,19 +226,21 @@ const setCollapsed = (collapsed) => {
|
|||||||
|
|
||||||
toggle?.addEventListener("click", () => setCollapsed(false));
|
toggle?.addEventListener("click", () => setCollapsed(false));
|
||||||
hide?.addEventListener("click", () => setCollapsed(true));
|
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", () => {
|
stateSelect?.addEventListener("change", () => {
|
||||||
const mock = normalizeStateMock(stateSelect.value);
|
const mock = normalizeStateMock(stateSelect.value);
|
||||||
const nextParams = new URLSearchParams(window.location.search);
|
const nextParams = new URLSearchParams(window.location.search);
|
||||||
nextParams.delete("path");
|
nextParams.delete("path");
|
||||||
nextParams.delete("screen");
|
nextParams.delete("screen");
|
||||||
nextParams.delete("admin_section");
|
nextParams.delete("admin_section");
|
||||||
if (mock === defaultMock) nextParams.delete("mock");
|
if (mock === defaultMock || mock === "emails") nextParams.delete("mock");
|
||||||
else nextParams.set("mock", mock);
|
else nextParams.set("mock", mock);
|
||||||
|
|
||||||
const query = nextParams.toString();
|
const query = nextParams.toString();
|
||||||
const stateRoute = routeForStateMock(mock);
|
const stateRoute = routeForStateMock(mock);
|
||||||
const publicUrl = `${demoBase}${stateRoute}${query ? `?${query}` : ""}`;
|
const publicUrl = `${demoBase}${stateRoute}${query ? `?${query}` : ""}`;
|
||||||
window.history.replaceState(null, "", publicUrl);
|
window.history.replaceState(null, "", publicUrl);
|
||||||
frame.src = runtimeSrc(stateRoute, nextParams);
|
setDemoMode(mock);
|
||||||
|
if (mock !== "emails") frame.src = runtimeSrc(stateRoute, nextParams);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -191,6 +191,7 @@ await runNpm(["--prefix", frontendRoot, "run", "build:docs-demo"]);
|
|||||||
|
|
||||||
await rm(runtimeDir, { recursive: true, force: true });
|
await rm(runtimeDir, { recursive: true, force: true });
|
||||||
await mkdir(runtimeDir, { recursive: true });
|
await mkdir(runtimeDir, { recursive: true });
|
||||||
|
await mkdir(path.join(runtimeDir, "app"), { recursive: true });
|
||||||
|
|
||||||
const html = await appHtml();
|
const html = await appHtml();
|
||||||
|
|
||||||
@@ -204,7 +205,7 @@ await Promise.all([
|
|||||||
path.join(runtimeDir, "default-brand"),
|
path.join(runtimeDir, "default-brand"),
|
||||||
),
|
),
|
||||||
copyDirectory(themesDir, path.join(runtimeDir, "themes"), copyThemeFile),
|
copyDirectory(themesDir, path.join(runtimeDir, "themes"), copyThemeFile),
|
||||||
writeFile(path.join(runtimeDir, "app.html"), html, "utf8"),
|
writeFile(path.join(runtimeDir, "app", "index.html"), html, "utf8"),
|
||||||
installGuidesConfigPayload().then((payload) =>
|
installGuidesConfigPayload().then((payload) =>
|
||||||
writeFile(
|
writeFile(
|
||||||
path.join(runtimeDir, "subscription-guides-config.json"),
|
path.join(runtimeDir, "subscription-guides-config.json"),
|
||||||
|
|||||||
@@ -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))
|
||||||
@@ -19,7 +19,13 @@ async function copyHtml(source, route) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const demoShell = path.join(distRoot, "demo", "index.html");
|
const demoShell = path.join(distRoot, "demo", "index.html");
|
||||||
const runtimeApp = path.join(distRoot, "demo", "runtime", "app.html");
|
const runtimeApp = path.join(
|
||||||
|
distRoot,
|
||||||
|
"demo",
|
||||||
|
"runtime",
|
||||||
|
"app",
|
||||||
|
"index.html",
|
||||||
|
);
|
||||||
|
|
||||||
for (const route of demoRoutes) {
|
for (const route of demoRoutes) {
|
||||||
await copyHtml(demoShell, route);
|
await copyHtml(demoShell, route);
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ export const demoPublicRouteAliases = ["app"];
|
|||||||
export const demoPublicRoutes = [
|
export const demoPublicRoutes = [
|
||||||
...demoPublicRouteAliases,
|
...demoPublicRouteAliases,
|
||||||
...demoUserRoutes,
|
...demoUserRoutes,
|
||||||
|
"emails",
|
||||||
"admin",
|
"admin",
|
||||||
...demoAdminRoutes.map((route) => `admin/${route}`),
|
...demoAdminRoutes.map((route) => `admin/${route}`),
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -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);
|
||||||
@@ -1,4 +1,6 @@
|
|||||||
---
|
---
|
||||||
|
import { emailPreviews } from '../lib/emailPreviews.mjs';
|
||||||
|
|
||||||
const defaultDemoSrc = '/demo/runtime/app/?path=/home&mock=tariffs';
|
const defaultDemoSrc = '/demo/runtime/app/?path=/home&mock=tariffs';
|
||||||
const docsHref = '/getting-started/demo/';
|
const docsHref = '/getting-started/demo/';
|
||||||
---
|
---
|
||||||
@@ -44,6 +46,10 @@ const docsHref = '/getting-started/demo/';
|
|||||||
background: #05080f;
|
background: #05080f;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
body[data-demo-mode='emails'] {
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
.demo-topbar {
|
.demo-topbar {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
@@ -151,6 +157,152 @@ const docsHref = '/getting-started/demo/';
|
|||||||
background: #05080f;
|
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) {
|
@media (max-width: 42rem) {
|
||||||
body {
|
body {
|
||||||
display: block;
|
display: block;
|
||||||
@@ -269,6 +421,28 @@ const docsHref = '/getting-started/demo/';
|
|||||||
.demo-frame {
|
.demo-frame {
|
||||||
height: 100dvh;
|
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>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
@@ -296,6 +470,7 @@ const docsHref = '/getting-started/demo/';
|
|||||||
<option value="devices">Лимит и докупка устройств</option>
|
<option value="devices">Лимит и докупка устройств</option>
|
||||||
<option value="notifications">Telegram-уведомления</option>
|
<option value="notifications">Telegram-уведомления</option>
|
||||||
<option value="auth">Вход и регистрация</option>
|
<option value="auth">Вход и регистрация</option>
|
||||||
|
<option value="emails">Email-письма</option>
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
<div class="demo-topbar__actions">
|
<div class="demo-topbar__actions">
|
||||||
@@ -313,6 +488,57 @@ const docsHref = '/getting-started/demo/';
|
|||||||
src={defaultDemoSrc}
|
src={defaultDemoSrc}
|
||||||
loading="eager"
|
loading="eager"
|
||||||
></iframe>
|
></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>
|
<script is:inline src="/demo/demo-shell.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -1,14 +0,0 @@
|
|||||||
import { readFile } from "node:fs/promises";
|
|
||||||
import path from "node:path";
|
|
||||||
|
|
||||||
const runtimeAppHtml = path.join(process.cwd(), "public", "demo", "runtime", "app.html");
|
|
||||||
|
|
||||||
export const prerender = true;
|
|
||||||
|
|
||||||
export async function GET() {
|
|
||||||
return new Response(await readFile(runtimeAppHtml, "utf8"), {
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "text/html; charset=utf-8",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -117,6 +117,10 @@
|
|||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `PANEL_API_URL` | URL API панели, например `https://panel.example.com/api`. |
|
| `PANEL_API_URL` | URL API панели, например `https://panel.example.com/api`. |
|
||||||
| `PANEL_API_KEY` | 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 и вставьте то же значение сюда или в админку. |
|
| `PANEL_WEBHOOK_SECRET` | Секрет проверки Remnawave webhook. Задайте его в Remnawave Panel и вставьте то же значение сюда или в админку. |
|
||||||
| `USER_SQUAD_UUIDS` | Internal Squads по умолчанию для legacy-режима без JSON-каталога. |
|
| `USER_SQUAD_UUIDS` | Internal Squads по умолчанию для legacy-режима без JSON-каталога. |
|
||||||
| `USER_EXTERNAL_SQUAD_UUID` | Необязательный External Squad. |
|
| `USER_EXTERNAL_SQUAD_UUID` | Необязательный External Squad. |
|
||||||
|
|||||||
@@ -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
@@ -22,10 +22,10 @@ JSON-каталог может содержать несколько тариф
|
|||||||
- добавление, редактирование и удаление тарифов;
|
- добавление, редактирование и удаление тарифов;
|
||||||
- включение и выключение тарифа на витрине;
|
- включение и выключение тарифа на витрине;
|
||||||
- выбор тарифа по умолчанию;
|
- выбор тарифа по умолчанию;
|
||||||
- настройка тарифов на срок (`period`): месячный лимит, периоды, RUB/Stars цены, реферальные бонусы и пакеты докупки трафика;
|
- настройка тарифов на срок (`period`): месячный лимит, периоды, цены в платежной валюте/Stars, реферальные бонусы и пакеты докупки трафика;
|
||||||
- настройка тарифов по трафику (`traffic`): пакеты GB, RUB/Stars цены, курс конвертации;
|
- настройка тарифов по трафику (`traffic`): пакеты GB, цены в платежной валюте/Stars, курс конвертации;
|
||||||
- настройка базовых Internal Squads из списка Remnawave;
|
- настройка базовых Internal Squads из списка Remnawave;
|
||||||
- настройка premium-раздела: названия RU/EN, premium Internal Squads, месячный premium-лимит и RUB/Stars пакеты докупки premium-трафика;
|
- настройка premium-раздела: названия RU/EN, premium Internal Squads, месячный premium-лимит и пакеты докупки premium-трафика в платежной валюте/Stars;
|
||||||
- настройка базового HWID-лимита и пакетов докупки устройств.
|
- настройка базового HWID-лимита и пакетов докупки устройств.
|
||||||
|
|
||||||
После сохранения изменения применяются к новым запросам Web App сразу, потому что конфиг тарифов загружается из JSON при обращении. Уже созданные подписки сохраняют свой `tariff_key`; при удалении или отключении тарифа проверьте, что активные подписки с этим ключом не требуют дальнейшего продления или смены.
|
После сохранения изменения применяются к новым запросам Web App сразу, потому что конфиг тарифов загружается из JSON при обращении. Уже созданные подписки сохраняют свой `tariff_key`; при удалении или отключении тарифа проверьте, что активные подписки с этим ключом не требуют дальнейшего продления или смены.
|
||||||
@@ -44,6 +44,31 @@ JSON-каталог может содержать несколько тариф
|
|||||||
|
|
||||||
В режиме без JSON-каталога наличие `TRAFFIC_PACKAGES` или `STARS_TRAFFIC_PACKAGES` переключает витрину на продажу трафика вместо подписок на срок.
|
В режиме без 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-каталога
|
## Структура JSON-каталога
|
||||||
|
|
||||||
Минимальная структура:
|
Минимальная структура:
|
||||||
@@ -51,6 +76,7 @@ JSON-каталог может содержать несколько тариф
|
|||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"default_tariff": "standard",
|
"default_tariff": "standard",
|
||||||
|
"default_currency": "rub",
|
||||||
"tariffs": [
|
"tariffs": [
|
||||||
{
|
{
|
||||||
"key": "standard",
|
"key": "standard",
|
||||||
@@ -90,6 +116,7 @@ JSON-каталог может содержать несколько тариф
|
|||||||
| Поле | Назначение |
|
| Поле | Назначение |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `default_tariff` | Тариф по умолчанию для первичного выбора и привязки активных подписок без `tariff_key`. |
|
| `default_tariff` | Тариф по умолчанию для первичного выбора и привязки активных подписок без `tariff_key`. |
|
||||||
|
| `default_currency` | Валюта цен по умолчанию для JSON-каталога. По умолчанию `rub`; `stars` запрещен, потому что Stars используют отдельные цены. |
|
||||||
| `tariffs[].key` | Стабильный ключ тарифа. Используется в платежах, подписках и смене тарифа. |
|
| `tariffs[].key` | Стабильный ключ тарифа. Используется в платежах, подписках и смене тарифа. |
|
||||||
| `tariffs[].names` | Названия тарифа по языкам. |
|
| `tariffs[].names` | Названия тарифа по языкам. |
|
||||||
| `tariffs[].descriptions` | Описания тарифа по языкам. |
|
| `tariffs[].descriptions` | Описания тарифа по языкам. |
|
||||||
@@ -108,7 +135,8 @@ JSON-каталог может содержать несколько тариф
|
|||||||
| Поле | Назначение |
|
| Поле | Назначение |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `monthly_gb` | Базовый месячный лимит трафика тарифа. `0` означает безлимит. |
|
| `monthly_gb` | Базовый месячный лимит трафика тарифа. `0` означает безлимит. |
|
||||||
| `prices_rub` | Цены периодов в рублях, ключ - количество месяцев. |
|
| `prices` | Generic-цены периодов по валютам, например `{ "usd": { "1": 4.99 } }`. |
|
||||||
|
| `prices_rub` | Legacy-цены периодов в рублях, ключ - количество месяцев. Эквивалент `prices.rub`. |
|
||||||
| `prices_stars` | Цены периодов в Telegram Stars. |
|
| `prices_stars` | Цены периодов в Telegram Stars. |
|
||||||
| `referral_bonus_days_inviter` | Бонус пригласившему в днях для каждого периода. Ключ - количество месяцев, как в `enabled_periods`. |
|
| `referral_bonus_days_inviter` | Бонус пригласившему в днях для каждого периода. Ключ - количество месяцев, как в `enabled_periods`. |
|
||||||
| `referral_bonus_days_referee` | Бонус приглашенному в днях для каждого периода. Ключ - количество месяцев, как в `enabled_periods`. |
|
| `referral_bonus_days_referee` | Бонус приглашенному в днях для каждого периода. Ключ - количество месяцев, как в `enabled_periods`. |
|
||||||
@@ -119,10 +147,11 @@ JSON-каталог может содержать несколько тариф
|
|||||||
|
|
||||||
| Поле | Назначение |
|
| Поле | Назначение |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `traffic_packages` | Пакеты трафика в GB для рублей и Telegram Stars. |
|
| `traffic_packages` | Пакеты трафика в GB по валютам каталога и Telegram Stars. |
|
||||||
| `conversion_rate_rub_per_gb` | Курс для конвертации оставшихся дней period-тарифа в GB при смене на traffic-тариф. |
|
| `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`)
|
## Тарифы на срок (`period`)
|
||||||
|
|
||||||
@@ -248,10 +277,10 @@ limit_after = current_used + balance_after
|
|||||||
- при безлимитном базовом лимите докупка устройств не применяется;
|
- при безлимитном базовом лимите докупка устройств не применяется;
|
||||||
- полная цена HWID-пакета берется из `prices[duration_months]`; если периода нет, используется fallback `price * duration_months`;
|
- полная цена HWID-пакета берется из `prices[duration_months]`; если периода нет, используется fallback `price * duration_months`;
|
||||||
- фактическая цена докупки считается пропорционально оплачиваемому окну `valid_from -> valid_until` относительно периода подписки и фиксируется в платежe;
|
- фактическая цена докупки считается пропорционально оплачиваемому окну `valid_from -> valid_until` относительно периода подписки и фиксируется в платежe;
|
||||||
- для Telegram Stars цена округляется вверх до целого Stars, для RUB — вверх до копеек; `min_price` защищает от микроплатежей в конце периода;
|
- для Telegram Stars цена округляется вверх до целого Stars, для платежной валюты — вверх до копеек; `min_price` защищает от микроплатежей в конце периода;
|
||||||
- при продлении подписки докупленные устройства не продлеваются автоматически: старая докупка действует до прежнего `end_date`, а для нового срока создается отдельная `hwid_devices_renewal`-покупка;
|
- при продлении подписки докупленные устройства не продлеваются автоматически: старая докупка действует до прежнего `end_date`, а для нового срока создается отдельная `hwid_devices_renewal`-покупка;
|
||||||
- `traffic`-тарифы не показывают и не принимают докупку HWID-устройств, потому что у них нет срока подписки;
|
- `traffic`-тарифы не показывают и не принимают докупку HWID-устройств, потому что у них нет срока подписки;
|
||||||
- при смене тарифа базовый лимит берется из целевого тарифа, а неиспользованная RUB-стоимость HWID-докупок конвертируется в дни нового period-тарифа или GB traffic-тарифа; XTR/Stars-докупки не конвертируются без явного курса и продолжают жить по своему `valid_until`;
|
- при смене тарифа базовый лимит берется из целевого тарифа, а неиспользованная стоимость HWID-докупок в платежной валюте конвертируется в дни нового period-тарифа или GB traffic-тарифа; XTR/Stars-докупки не конвертируются без явного курса и продолжают жить по своему `valid_until`;
|
||||||
- история докупок пишется в `hwid_device_purchases`;
|
- история докупок пишется в `hwid_device_purchases`;
|
||||||
- платеж хранит количество устройств в `payments.purchased_hwid_devices`.
|
- платеж хранит количество устройств в `payments.purchased_hwid_devices`.
|
||||||
|
|
||||||
@@ -265,9 +294,9 @@ limit_after = current_used + balance_after
|
|||||||
|
|
||||||
| Переход | Поведение |
|
| Переход | Поведение |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `period -> period` | Остаток оплаченных дней оценивается по `effective_monthly_price_rub`, затем пересчитывается в дни целевого тарифа через месячную цену целевого тарифа. Неиспользованная RUB-стоимость HWID-докупок добавляется к этому расчету как дополнительные дни. Количество дней округляется вниз. |
|
| `period -> period` | Остаток оплаченных дней оценивается по legacy-полю `effective_monthly_price_rub`, где хранится месячная цена в платежной валюте каталога, затем пересчитывается в дни целевого тарифа через месячную цену целевого тарифа. Неиспользованная стоимость HWID-докупок в платежной валюте добавляется к этому расчету как дополнительные дни. Количество дней округляется вниз. |
|
||||||
| `period -> period` с доплатой | Если целевой тариф дороже, может быть создан платеж `tariff_upgrade`; неиспользованная RUB-стоимость HWID-докупок уменьшает сумму доплаты. После оплаты применяется целевой тариф, а конвертированные HWID-окна закрываются. |
|
| `period -> period` с доплатой | Если целевой тариф дороже, может быть создан платеж `tariff_upgrade`; неиспользованная стоимость HWID-докупок в платежной валюте уменьшает сумму доплаты. После оплаты применяется целевой тариф, а конвертированные HWID-окна закрываются. |
|
||||||
| `period -> traffic` | Остаток оплаченных дней и неиспользованная RUB-стоимость HWID-докупок конвертируются в GB по `conversion_rate_rub_per_gb` или минимальной RUB-цене GB из пакетов целевого тарифа. |
|
| `period -> traffic` | Остаток оплаченных дней и неиспользованная стоимость HWID-докупок в платежной валюте конвертируются в GB по `conversion_rate_per_gb` или минимальной цене GB из пакетов целевого тарифа. |
|
||||||
| `traffic -> period` | Пользователь выбирает и оплачивает период целевого тарифа; остаток GB сохраняется как `topup_balance_bytes` поверх лимита period-тарифа. |
|
| `traffic -> period` | Пользователь выбирает и оплачивает период целевого тарифа; остаток GB сохраняется как `topup_balance_bytes` поверх лимита period-тарифа. |
|
||||||
|
|
||||||
При смене тарифа бот меняет:
|
При смене тарифа бот меняет:
|
||||||
|
|||||||
@@ -23,7 +23,7 @@
|
|||||||
- использует entrypoint `frontend/src/docsDemoEntry.js`, где подключены моковые данные и mock API;
|
- использует entrypoint `frontend/src/docsDemoEntry.js`, где подключены моковые данные и mock API;
|
||||||
- дополнительно собирает обычный admin-бандл, чтобы админка работала внутри демо;
|
- дополнительно собирает обычный admin-бандл, чтобы админка работала внутри демо;
|
||||||
- копирует JS/CSS, темы, default-brand ассеты, локали и конфиг гайдов подключения в `docs-site/public/demo/runtime/`;
|
- копирует JS/CSS, темы, default-brand ассеты, локали и конфиг гайдов подключения в `docs-site/public/demo/runtime/`;
|
||||||
- генерирует `app.html`, который грузит demo runtime и встроенные переводы;
|
- генерирует `app/index.html`, который грузит demo runtime и встроенные переводы;
|
||||||
- после Astro build материализует публичные страницы `/demo/home`, `/demo/install`, `/demo/admin/stats` и другие основные demo routes как статические `index.html`;
|
- после Astro build материализует публичные страницы `/demo/home`, `/demo/install`, `/demo/admin/stats` и другие основные demo routes как статические `index.html`;
|
||||||
- Cloudflare Pages rewrite-правила остаются только для внутреннего `/demo/runtime/*`, чтобы iframe мог использовать обычный History API без влияния на остальные страницы документации;
|
- Cloudflare Pages rewrite-правила остаются только для внутреннего `/demo/runtime/*`, чтобы iframe мог использовать обычный History API без влияния на остальные страницы документации;
|
||||||
- страницы `/demo/home`, `/demo/install`, `/demo/admin/*` и другие demo routes служат полноэкранной обвязкой с верхней панелью возврата в документацию, а внешняя страница синхронизирует читаемый адрес демо.
|
- страницы `/demo/home`, `/demo/install`, `/demo/admin/*` и другие demo routes служат полноэкранной обвязкой с верхней панелью возврата в документацию, а внешняя страница синхронизирует читаемый адрес демо.
|
||||||
|
|||||||
@@ -409,3 +409,37 @@ app.example.com {
|
|||||||
```bash
|
```bash
|
||||||
APP_ENV_FILE=.env.staging docker compose --env-file .env.staging up -d --build
|
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 на другом сервере, локальные
|
||||||
|
порты можно оставить стандартными.
|
||||||
|
|||||||
Generated
+16
-76
@@ -24,7 +24,7 @@
|
|||||||
"lucide-svelte": "^1.0.1",
|
"lucide-svelte": "^1.0.1",
|
||||||
"prettier": "^3.7.4",
|
"prettier": "^3.7.4",
|
||||||
"prettier-plugin-svelte": "^3.4.0",
|
"prettier-plugin-svelte": "^3.4.0",
|
||||||
"svelte": "^5.55.5",
|
"svelte": "^5.56.0",
|
||||||
"svelte-eslint-parser": "^1.4.1",
|
"svelte-eslint-parser": "^1.4.1",
|
||||||
"tailwind-merge": "^3.6.0",
|
"tailwind-merge": "^3.6.0",
|
||||||
"tailwindcss": "4.3.0",
|
"tailwindcss": "4.3.0",
|
||||||
@@ -947,9 +947,6 @@
|
|||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -971,9 +968,6 @@
|
|||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -995,9 +989,6 @@
|
|||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1019,9 +1010,6 @@
|
|||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1043,9 +1031,6 @@
|
|||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1067,9 +1052,6 @@
|
|||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1239,9 +1221,6 @@
|
|||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1259,9 +1238,6 @@
|
|||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1279,9 +1255,6 @@
|
|||||||
"ppc64"
|
"ppc64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1299,9 +1272,6 @@
|
|||||||
"s390x"
|
"s390x"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1319,9 +1289,6 @@
|
|||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1339,9 +1306,6 @@
|
|||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1429,9 +1393,9 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@sveltejs/acorn-typescript": {
|
"node_modules/@sveltejs/acorn-typescript": {
|
||||||
"version": "1.0.9",
|
"version": "1.0.10",
|
||||||
"resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.9.tgz",
|
"resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.10.tgz",
|
||||||
"integrity": "sha512-lVJX6qEgs/4DOcRTpo56tmKzVPtoWAaVbL4hfO7t7NVwl9AAXzQR6cihesW1BmNMPl+bK6dreu2sOKBP2Q9CIA==",
|
"integrity": "sha512-4WfKk68eTih+MiJD4fSbxN7E8kVBmTMPWHUPYjvl2N0rMs53YLTT8/YjKU5Dtnz5LqDjl7LEw4U7lXR2W3J5WA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
@@ -1620,9 +1584,6 @@
|
|||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1640,9 +1601,6 @@
|
|||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1660,9 +1618,6 @@
|
|||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1680,9 +1635,6 @@
|
|||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2096,9 +2048,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/devalue": {
|
"node_modules/devalue": {
|
||||||
"version": "5.7.1",
|
"version": "5.8.1",
|
||||||
"resolved": "https://registry.npmjs.org/devalue/-/devalue-5.7.1.tgz",
|
"resolved": "https://registry.npmjs.org/devalue/-/devalue-5.8.1.tgz",
|
||||||
"integrity": "sha512-MUbZ586EgQqdRnC4yDrlod3BEdyvE4TapGYHMW2CiaW+KkkFmWEFqBUaLltEZCGi0iFXCEjRF0OjF0DV2QHjOA==",
|
"integrity": "sha512-4CXDYRBGqN+57wVJkuXBYmpAVUSg3L6JAQa/DFqm238G73E1wuyc/JhGQJzN7vUf/CMphYau2zXbfWzDR5aTEw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
@@ -2421,9 +2373,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/esrap": {
|
"node_modules/esrap": {
|
||||||
"version": "2.2.5",
|
"version": "2.2.9",
|
||||||
"resolved": "https://registry.npmjs.org/esrap/-/esrap-2.2.5.tgz",
|
"resolved": "https://registry.npmjs.org/esrap/-/esrap-2.2.9.tgz",
|
||||||
"integrity": "sha512-/yLB1538mag+dn0wsePTe8C0rDIjUOaJpMs2McodSzmM2msWcZsBSdRtg6HOBt0A/r82BN+Md3pgwSc/uWt2Ig==",
|
"integrity": "sha512-4KijP+NxCWthMCUC3qHbE6n4vCjqgJS1uAYKhuT/GWfFTf1Qyive2TgOjep+gzbSzRfnNyaN/UU9YmdOt8Eg0A==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -2899,9 +2851,6 @@
|
|||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2923,9 +2872,6 @@
|
|||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2947,9 +2893,6 @@
|
|||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2971,9 +2914,6 @@
|
|||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -3640,24 +3580,24 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/svelte": {
|
"node_modules/svelte": {
|
||||||
"version": "5.55.5",
|
"version": "5.56.0",
|
||||||
"resolved": "https://registry.npmjs.org/svelte/-/svelte-5.55.5.tgz",
|
"resolved": "https://registry.npmjs.org/svelte/-/svelte-5.56.0.tgz",
|
||||||
"integrity": "sha512-2uCs/LZ9us+AktdzYJM8OcxQ8qnPS1kpaO7syGT/MgO+6Qr1Ybl+TqPq+97u7PHqmmMlye5ZkoyXONy5mjjAbw==",
|
"integrity": "sha512-kTXr26t1bchFp28ROrb957LtbujpBmBDibmqMGziVpUs7awBi96TGgX6SovrA8BNoEUDVRK2Fb9FkeYlGspoVg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@jridgewell/remapping": "^2.3.4",
|
"@jridgewell/remapping": "^2.3.4",
|
||||||
"@jridgewell/sourcemap-codec": "^1.5.0",
|
"@jridgewell/sourcemap-codec": "^1.5.0",
|
||||||
"@sveltejs/acorn-typescript": "^1.0.5",
|
"@sveltejs/acorn-typescript": "^1.0.10",
|
||||||
"@types/estree": "^1.0.5",
|
"@types/estree": "^1.0.5",
|
||||||
"@types/trusted-types": "^2.0.7",
|
"@types/trusted-types": "^2.0.7",
|
||||||
"acorn": "^8.12.1",
|
"acorn": "^8.12.1",
|
||||||
"aria-query": "5.3.1",
|
"aria-query": "5.3.1",
|
||||||
"axobject-query": "^4.1.0",
|
"axobject-query": "^4.1.0",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"devalue": "^5.6.4",
|
"devalue": "^5.8.1",
|
||||||
"esm-env": "^1.2.1",
|
"esm-env": "^1.2.1",
|
||||||
"esrap": "^2.2.4",
|
"esrap": "^2.2.9",
|
||||||
"is-reference": "^3.0.3",
|
"is-reference": "^3.0.3",
|
||||||
"locate-character": "^3.0.0",
|
"locate-character": "^3.0.0",
|
||||||
"magic-string": "^0.30.11",
|
"magic-string": "^0.30.11",
|
||||||
|
|||||||
@@ -34,7 +34,7 @@
|
|||||||
"lucide-svelte": "^1.0.1",
|
"lucide-svelte": "^1.0.1",
|
||||||
"prettier": "^3.7.4",
|
"prettier": "^3.7.4",
|
||||||
"prettier-plugin-svelte": "^3.4.0",
|
"prettier-plugin-svelte": "^3.4.0",
|
||||||
"svelte": "^5.55.5",
|
"svelte": "^5.56.0",
|
||||||
"svelte-eslint-parser": "^1.4.1",
|
"svelte-eslint-parser": "^1.4.1",
|
||||||
"tailwind-merge": "^3.6.0",
|
"tailwind-merge": "^3.6.0",
|
||||||
"tailwindcss": "4.3.0",
|
"tailwindcss": "4.3.0",
|
||||||
|
|||||||
+23
-2
@@ -183,6 +183,7 @@
|
|||||||
let languageClickGuardArmed = false;
|
let languageClickGuardArmed = false;
|
||||||
let languageClickGuardTimer = null;
|
let languageClickGuardTimer = null;
|
||||||
let languageClickGuardArmTimer = null;
|
let languageClickGuardArmTimer = null;
|
||||||
|
let guestLanguage = "";
|
||||||
let emailAvatarUrl = "";
|
let emailAvatarUrl = "";
|
||||||
let avatarHashToken = "";
|
let avatarHashToken = "";
|
||||||
let token = MOCK ? "local-preview" : "";
|
let token = MOCK ? "local-preview" : "";
|
||||||
@@ -213,12 +214,13 @@
|
|||||||
const i18n = createI18n({
|
const i18n = createI18n({
|
||||||
messages: I18N,
|
messages: I18N,
|
||||||
defaultLang: "ru",
|
defaultLang: "ru",
|
||||||
getLang: () => user?.language_code || CFG.language || "ru",
|
getLang: () => user?.language_code || guestLanguage || CFG.language || "ru",
|
||||||
});
|
});
|
||||||
const normalizeLangCode = i18n.normalizeLangCode;
|
const normalizeLangCode = i18n.normalizeLangCode;
|
||||||
const t = i18n.t;
|
const t = i18n.t;
|
||||||
const termUnitLabel = i18n.termUnitLabel;
|
const termUnitLabel = i18n.termUnitLabel;
|
||||||
const languageName = i18n.languageName;
|
const languageName = i18n.languageName;
|
||||||
|
guestLanguage = normalizeLangCode(CFG.language || "ru");
|
||||||
const apiClient = createApiClient({
|
const apiClient = createApiClient({
|
||||||
apiBase: CFG.apiBase,
|
apiBase: CFG.apiBase,
|
||||||
csrfCookieName: CSRF_COOKIE_NAME,
|
csrfCookieName: CSRF_COOKIE_NAME,
|
||||||
@@ -460,7 +462,7 @@
|
|||||||
activeTab = "settings";
|
activeTab = "settings";
|
||||||
}
|
}
|
||||||
$: referral = data?.referral || MOCK_SOURCE.data.referral;
|
$: 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(
|
$: languageCodes = uniqueLanguageCodes(
|
||||||
WEBAPP_LANGUAGE_ORDER,
|
WEBAPP_LANGUAGE_ORDER,
|
||||||
CFG.languages,
|
CFG.languages,
|
||||||
@@ -939,6 +941,13 @@
|
|||||||
}, 260);
|
}, 260);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function updateGuestLanguage(nextValue) {
|
||||||
|
const language = normalizeLangCode(nextValue);
|
||||||
|
setLanguageMenuOpen(false);
|
||||||
|
if (!language || language === currentLang) return;
|
||||||
|
guestLanguage = language;
|
||||||
|
}
|
||||||
|
|
||||||
function readTelegramMiniAppInitDataFromLocation() {
|
function readTelegramMiniAppInitDataFromLocation() {
|
||||||
return telegramSdk.readInitDataFromLocation();
|
return telegramSdk.readInitDataFromLocation();
|
||||||
}
|
}
|
||||||
@@ -1219,6 +1228,7 @@
|
|||||||
const emailHint = readEmailCodeLoginDeeplink();
|
const emailHint = readEmailCodeLoginDeeplink();
|
||||||
if (!emailHint) return;
|
if (!emailHint) return;
|
||||||
emailLoginDeeplinkConsumed = true;
|
emailLoginDeeplinkConsumed = true;
|
||||||
|
authStore.clearPendingEmailCode();
|
||||||
authStore.update((s) => ({
|
authStore.update((s) => ({
|
||||||
...s,
|
...s,
|
||||||
email: emailHint,
|
email: emailHint,
|
||||||
@@ -1670,6 +1680,9 @@
|
|||||||
screen = "login";
|
screen = "login";
|
||||||
activeTab = "home";
|
activeTab = "home";
|
||||||
setPasswordLoginMode(isPasswordLoginPath(), true);
|
setPasswordLoginMode(isPasswordLoginPath(), true);
|
||||||
|
authStore.restorePendingEmailCode((nextScreen) => {
|
||||||
|
screen = nextScreen;
|
||||||
|
});
|
||||||
void startEmailCodeLoginFromDeeplink();
|
void startEmailCodeLoginFromDeeplink();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2208,7 +2221,15 @@
|
|||||||
{telegramLoginUnavailableMessage}
|
{telegramLoginUnavailableMessage}
|
||||||
{privacyPolicyUrl}
|
{privacyPolicyUrl}
|
||||||
{userAgreementUrl}
|
{userAgreementUrl}
|
||||||
|
{currentLang}
|
||||||
|
{currentLanguageOption}
|
||||||
|
{languageOptions}
|
||||||
|
{languageMenuOpen}
|
||||||
|
{languageClickGuard}
|
||||||
|
{languageClickGuardArmed}
|
||||||
{t}
|
{t}
|
||||||
|
{setLanguageMenuOpen}
|
||||||
|
updateLoginLanguage={updateGuestLanguage}
|
||||||
requestEmailCode={() => authStore.requestEmailCode((s) => (screen = s))}
|
requestEmailCode={() => authStore.requestEmailCode((s) => (screen = s))}
|
||||||
loginWithEmailPassword={authStore.loginWithEmailPassword}
|
loginWithEmailPassword={authStore.loginWithEmailPassword}
|
||||||
verifyEmailCode={authStore.verifyEmailCode}
|
verifyEmailCode={authStore.verifyEmailCode}
|
||||||
|
|||||||
@@ -228,6 +228,7 @@
|
|||||||
let adminLanguageClickGuardArmed = false;
|
let adminLanguageClickGuardArmed = false;
|
||||||
let adminLanguageClickGuardTimer = null;
|
let adminLanguageClickGuardTimer = null;
|
||||||
let adminLanguageClickGuardArmTimer = null;
|
let adminLanguageClickGuardArmTimer = null;
|
||||||
|
$: adminLanguageGuardActive = isCompact && (adminLanguageMenuOpen || adminLanguageClickGuard);
|
||||||
|
|
||||||
function readReduceMotion() {
|
function readReduceMotion() {
|
||||||
return (
|
return (
|
||||||
@@ -295,6 +296,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function changeLanguage(value) {
|
function changeLanguage(value) {
|
||||||
|
adminLanguageMenuOpen = false;
|
||||||
|
clearAdminLanguageClickGuard();
|
||||||
onLanguageChange(value, { section: "admin", adminSection: active });
|
onLanguageChange(value, { section: "admin", adminSection: active });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -472,8 +475,6 @@
|
|||||||
function setAdminLanguageMenuOpen(open) {
|
function setAdminLanguageMenuOpen(open) {
|
||||||
adminLanguageMenuOpen = Boolean(open);
|
adminLanguageMenuOpen = Boolean(open);
|
||||||
clearAdminLanguageClickGuard();
|
clearAdminLanguageClickGuard();
|
||||||
// Desktop doesn't need the click-guard overlay and it can block
|
|
||||||
// option clicks in portaled select content.
|
|
||||||
if (!isCompact) return;
|
if (!isCompact) return;
|
||||||
if (adminLanguageMenuOpen) {
|
if (adminLanguageMenuOpen) {
|
||||||
adminLanguageClickGuard = true;
|
adminLanguageClickGuard = true;
|
||||||
@@ -557,7 +558,11 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</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}
|
{#if sidebarOpen}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -568,7 +573,7 @@
|
|||||||
on:click={() => (sidebarOpen = false)}
|
on:click={() => (sidebarOpen = false)}
|
||||||
></button>
|
></button>
|
||||||
{/if}
|
{/if}
|
||||||
{#if isCompact && (adminLanguageMenuOpen || adminLanguageClickGuard)}
|
{#if adminLanguageGuardActive}
|
||||||
<button
|
<button
|
||||||
class="language-select-guard"
|
class="language-select-guard"
|
||||||
class:language-select-guard--armed={adminLanguageClickGuardArmed}
|
class:language-select-guard--armed={adminLanguageClickGuardArmed}
|
||||||
@@ -578,7 +583,6 @@
|
|||||||
on:click={closeAdminLanguageFromGuard}
|
on:click={closeAdminLanguageFromGuard}
|
||||||
></button>
|
></button>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<aside class="admin-sidebar" aria-label={at("sidebar_navigation", {}, "Навигация админки")}>
|
<aside class="admin-sidebar" aria-label={at("sidebar_navigation", {}, "Навигация админки")}>
|
||||||
<div class="admin-sidebar-brand">
|
<div class="admin-sidebar-brand">
|
||||||
<BrandMark class="admin-brand-mark" {brand} />
|
<BrandMark class="admin-brand-mark" {brand} />
|
||||||
@@ -785,6 +789,7 @@
|
|||||||
<UsersSection
|
<UsersSection
|
||||||
{at}
|
{at}
|
||||||
{fmtDateShort}
|
{fmtDateShort}
|
||||||
|
{fmtMoney}
|
||||||
{panelStatusBadge}
|
{panelStatusBadge}
|
||||||
{resolvedAvatarUrl}
|
{resolvedAvatarUrl}
|
||||||
{userDisplayName}
|
{userDisplayName}
|
||||||
|
|||||||
@@ -347,6 +347,7 @@
|
|||||||
support: "Поддержка",
|
support: "Поддержка",
|
||||||
devices: "Устройства",
|
devices: "Устройства",
|
||||||
subscription_guides: "Connection guides",
|
subscription_guides: "Connection guides",
|
||||||
|
system: "Система",
|
||||||
};
|
};
|
||||||
return adminText(`settings_section_${id}`, {}, map[id] || id);
|
return adminText(`settings_section_${id}`, {}, map[id] || id);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -692,17 +692,25 @@
|
|||||||
|
|
||||||
<Card.Root>
|
<Card.Root>
|
||||||
<Card.Header>
|
<Card.Header>
|
||||||
<Card.Description>{at("stats_label_paid_subs", {}, "")}</Card.Description>
|
<Card.Description>{at("stats_label_active_subs", {}, "")}</Card.Description>
|
||||||
<Card.Title>{users.paid_subscriptions ?? 0}</Card.Title>
|
<Card.Title>{users.active_subscriptions ?? 0}</Card.Title>
|
||||||
<Card.Action>
|
<Card.Action>
|
||||||
<Badge variant="outline">{users.trial_users ?? 0}</Badge>
|
<Badge variant="outline"
|
||||||
|
>{users.total_users
|
||||||
|
? Math.round(((users.active_subscriptions ?? 0) / (users.total_users || 1)) * 100)
|
||||||
|
: 0}%</Badge
|
||||||
|
>
|
||||||
</Card.Action>
|
</Card.Action>
|
||||||
</Card.Header>
|
</Card.Header>
|
||||||
<Card.Footer class="admin-cn-card-footer--stack">
|
<Card.Footer class="admin-cn-card-footer--stack">
|
||||||
<div class="admin-cn-card-footer-primary">
|
<div class="admin-cn-card-footer-primary">
|
||||||
{at("stats_trend_trials", { count: users.trial_users ?? 0 }, "")}
|
{at("stats_trend_paid", { count: users.paid_subscriptions ?? 0 }, "")}
|
||||||
|
· {at("stats_trend_free", { count: users.free_subscription_users ?? 0 }, "")}
|
||||||
|
· {at("stats_trend_trials", { count: users.trial_users ?? 0 }, "")}
|
||||||
|
</div>
|
||||||
|
<div class="admin-cn-card-footer-muted">
|
||||||
|
{at("stats_card_active_subs_caption", {}, "")}
|
||||||
</div>
|
</div>
|
||||||
<div class="admin-cn-card-footer-muted">{at("stats_card_paid_caption", {}, "")}</div>
|
|
||||||
</Card.Footer>
|
</Card.Footer>
|
||||||
</Card.Root>
|
</Card.Root>
|
||||||
|
|
||||||
@@ -720,7 +728,11 @@
|
|||||||
</Card.Header>
|
</Card.Header>
|
||||||
<Card.Footer class="admin-cn-card-footer--stack">
|
<Card.Footer class="admin-cn-card-footer--stack">
|
||||||
<div class="admin-cn-card-footer-primary">
|
<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>
|
||||||
<div class="admin-cn-card-footer-muted">{at("stats_card_inactive_caption", {}, "")}</div>
|
<div class="admin-cn-card-footer-muted">{at("stats_card_inactive_caption", {}, "")}</div>
|
||||||
</Card.Footer>
|
</Card.Footer>
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
import { Plus, Save, Trash2, X } from "$components/ui/icons.js";
|
import { Plus, Save, Trash2, X } from "$components/ui/icons.js";
|
||||||
import { AdminButton, AdminSelect } from "$components/patterns/admin/index.js";
|
import { AdminButton, AdminSelect } from "$components/patterns/admin/index.js";
|
||||||
import { getContext } from "svelte";
|
import { getContext } from "svelte";
|
||||||
import { normalizeUuidList } from "../../lib/admin/tariffDraft.js";
|
import { normalizeCurrencyKey, normalizeUuidList } from "../../lib/admin/tariffDraft.js";
|
||||||
|
|
||||||
export let at;
|
export let at;
|
||||||
const tariffsStore = getContext("tariffsStore");
|
const tariffsStore = getContext("tariffsStore");
|
||||||
@@ -19,6 +19,7 @@
|
|||||||
tariffDeleteTarget,
|
tariffDeleteTarget,
|
||||||
panelSquadsLoading,
|
panelSquadsLoading,
|
||||||
panelSquads,
|
panelSquads,
|
||||||
|
tariffsCatalog,
|
||||||
} = $tariffsStore);
|
} = $tariffsStore);
|
||||||
|
|
||||||
$: billingModelOptions = [
|
$: billingModelOptions = [
|
||||||
@@ -29,6 +30,33 @@
|
|||||||
value: squad.uuid,
|
value: squad.uuid,
|
||||||
label: squad.name,
|
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>
|
</script>
|
||||||
|
|
||||||
<Dialog
|
<Dialog
|
||||||
@@ -241,7 +269,7 @@
|
|||||||
</Label.Root>
|
</Label.Root>
|
||||||
{:else}
|
{:else}
|
||||||
<Label.Root class="admin-field-label">
|
<Label.Root class="admin-field-label">
|
||||||
<span>{at("tariff_label_conversion", {}, "Курс конвертации, ₽ за 1 GB")}</span>
|
<span>{conversionCurrencyLabel}</span>
|
||||||
<small
|
<small
|
||||||
>{at(
|
>{at(
|
||||||
"tariff_hint_conversion",
|
"tariff_hint_conversion",
|
||||||
@@ -388,7 +416,7 @@
|
|||||||
<AdminButton
|
<AdminButton
|
||||||
size="sm"
|
size="sm"
|
||||||
onclick={() => tariffsStore.addDraftRow("premiumTopupRubRows", { gb: 10, price: "" })}
|
onclick={() => tariffsStore.addDraftRow("premiumTopupRubRows", { gb: 10, price: "" })}
|
||||||
><Plus size={12} /> {at("tariff_btn_package_rub", {}, "Пакет ₽")}</AdminButton
|
><Plus size={12} /> {currencyPackageLabel}</AdminButton
|
||||||
>
|
>
|
||||||
<AdminButton
|
<AdminButton
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -400,11 +428,11 @@
|
|||||||
</header>
|
</header>
|
||||||
<div class="admin-package-columns">
|
<div class="admin-package-columns">
|
||||||
<div class="admin-row-editor">
|
<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}
|
{#if tariffDraft.premiumTopupRubRows.length}
|
||||||
<div class="admin-row-editor-line admin-row-editor-header">
|
<div class="admin-row-editor-line admin-row-editor-header">
|
||||||
<span>{at("tariff_col_volume_gb", {}, "Объём, GB")}</span>
|
<span>{at("tariff_col_volume_gb", {}, "Объём, GB")}</span>
|
||||||
<span>{at("tariff_col_price_rub", {}, "Цена, ₽")}</span>
|
<span>{currencyPriceColumnLabel}</span>
|
||||||
<span></span>
|
<span></span>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -426,7 +454,7 @@
|
|||||||
step="0.01"
|
step="0.01"
|
||||||
placeholder="199"
|
placeholder="199"
|
||||||
bind:value={row.price}
|
bind:value={row.price}
|
||||||
aria-label={at("tariff_label_price_rub", {}, "Цена premium-пакета в рублях")}
|
aria-label={currencyPriceAriaLabel}
|
||||||
/>
|
/>
|
||||||
<AdminButton
|
<AdminButton
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -526,7 +554,7 @@
|
|||||||
<div class="admin-row-editor">
|
<div class="admin-row-editor">
|
||||||
<div class="admin-row-editor-line admin-row-editor-6 admin-row-editor-header">
|
<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_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_price_stars_full", {}, "Цена, ⭐ Stars")}</span>
|
||||||
<span>{at("tariff_col_ref_inviter", {}, "Бонус приглашающему")}</span>
|
<span>{at("tariff_col_ref_inviter", {}, "Бонус приглашающему")}</span>
|
||||||
<span>{at("tariff_col_ref_referee", {}, "Бонус приглашённому")}</span>
|
<span>{at("tariff_col_ref_referee", {}, "Бонус приглашённому")}</span>
|
||||||
@@ -549,7 +577,7 @@
|
|||||||
step="0.01"
|
step="0.01"
|
||||||
placeholder="299"
|
placeholder="299"
|
||||||
bind:value={row.rub}
|
bind:value={row.rub}
|
||||||
aria-label={at("tariff_label_price_rub", {}, "Цена в рублях")}
|
aria-label={currencyPriceAriaLabel}
|
||||||
/>
|
/>
|
||||||
<Input
|
<Input
|
||||||
class="input"
|
class="input"
|
||||||
@@ -608,7 +636,7 @@
|
|||||||
<AdminButton
|
<AdminButton
|
||||||
size="sm"
|
size="sm"
|
||||||
onclick={() => tariffsStore.addDraftRow("trafficRubRows", { gb: 10, price: "" })}
|
onclick={() => tariffsStore.addDraftRow("trafficRubRows", { gb: 10, price: "" })}
|
||||||
><Plus size={12} /> {at("tariff_btn_package_rub", {}, "Пакет ₽")}</AdminButton
|
><Plus size={12} /> {currencyPackageLabel}</AdminButton
|
||||||
>
|
>
|
||||||
<AdminButton
|
<AdminButton
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -619,12 +647,11 @@
|
|||||||
</header>
|
</header>
|
||||||
<div class="admin-package-columns">
|
<div class="admin-package-columns">
|
||||||
<div class="admin-row-editor">
|
<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}
|
{#if tariffDraft.trafficRubRows.length}
|
||||||
<div class="admin-row-editor-line admin-row-editor-header">
|
<div class="admin-row-editor-line admin-row-editor-header">
|
||||||
<span>{at("tariff_col_volume_gb", {}, "Объём, GB")}</span>
|
<span>{at("tariff_col_volume_gb", {}, "Объём, GB")}</span>
|
||||||
<span>{at("tariff_col_price_rub", {}, "Цена, ₽")}</span>
|
<span>{currencyPriceColumnLabel}</span>
|
||||||
<span></span>
|
<span></span>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -646,7 +673,7 @@
|
|||||||
step="0.01"
|
step="0.01"
|
||||||
placeholder="299"
|
placeholder="299"
|
||||||
bind:value={row.price}
|
bind:value={row.price}
|
||||||
aria-label={at("tariff_label_price_rub", {}, "Цена пакета в рублях")}
|
aria-label={currencyPriceAriaLabel}
|
||||||
/>
|
/>
|
||||||
<AdminButton
|
<AdminButton
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -722,7 +749,7 @@
|
|||||||
<AdminButton
|
<AdminButton
|
||||||
size="sm"
|
size="sm"
|
||||||
onclick={() => tariffsStore.addDraftRow("topupRubRows", { gb: 10, price: "" })}
|
onclick={() => tariffsStore.addDraftRow("topupRubRows", { gb: 10, price: "" })}
|
||||||
><Plus size={12} /> {at("tariff_btn_package_rub", {}, "Пакет ₽")}</AdminButton
|
><Plus size={12} /> {currencyPackageLabel}</AdminButton
|
||||||
>
|
>
|
||||||
<AdminButton
|
<AdminButton
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -733,12 +760,11 @@
|
|||||||
</header>
|
</header>
|
||||||
<div class="admin-package-columns">
|
<div class="admin-package-columns">
|
||||||
<div class="admin-row-editor">
|
<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}
|
{#if tariffDraft.topupRubRows.length}
|
||||||
<div class="admin-row-editor-line admin-row-editor-header">
|
<div class="admin-row-editor-line admin-row-editor-header">
|
||||||
<span>{at("tariff_col_volume_gb", {}, "Объём, GB")}</span>
|
<span>{at("tariff_col_volume_gb", {}, "Объём, GB")}</span>
|
||||||
<span>{at("tariff_col_price_rub", {}, "Цена, ₽")}</span>
|
<span>{currencyPriceColumnLabel}</span>
|
||||||
<span></span>
|
<span></span>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -760,7 +786,7 @@
|
|||||||
step="0.01"
|
step="0.01"
|
||||||
placeholder="149"
|
placeholder="149"
|
||||||
bind:value={row.price}
|
bind:value={row.price}
|
||||||
aria-label={at("tariff_label_price_rub", {}, "Цена пакета в рублях")}
|
aria-label={currencyPriceAriaLabel}
|
||||||
/>
|
/>
|
||||||
<AdminButton
|
<AdminButton
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -847,7 +873,7 @@
|
|||||||
<AdminButton
|
<AdminButton
|
||||||
size="sm"
|
size="sm"
|
||||||
onclick={() => tariffsStore.addDraftRow("hwidRubRows", { count: 1, price: "" })}
|
onclick={() => tariffsStore.addDraftRow("hwidRubRows", { count: 1, price: "" })}
|
||||||
><Plus size={12} /> {at("tariff_btn_package_rub", {}, "Пакет ₽")}</AdminButton
|
><Plus size={12} /> {currencyPackageLabel}</AdminButton
|
||||||
>
|
>
|
||||||
<AdminButton
|
<AdminButton
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -858,11 +884,11 @@
|
|||||||
</header>
|
</header>
|
||||||
<div class="admin-package-columns">
|
<div class="admin-package-columns">
|
||||||
<div class="admin-row-editor">
|
<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}
|
{#if tariffDraft.hwidRubRows.length}
|
||||||
<div class="admin-row-editor-line admin-row-editor-header">
|
<div class="admin-row-editor-line admin-row-editor-header">
|
||||||
<span>{at("tariff_col_hwid_count", {}, "+ устройств")}</span>
|
<span>{at("tariff_col_hwid_count", {}, "+ устройств")}</span>
|
||||||
<span>{at("tariff_col_price_rub", {}, "Цена, ₽")}</span>
|
<span>{currencyPriceColumnLabel}</span>
|
||||||
<span></span>
|
<span></span>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -888,7 +914,7 @@
|
|||||||
step="0.01"
|
step="0.01"
|
||||||
placeholder="99"
|
placeholder="99"
|
||||||
bind:value={row.price}
|
bind:value={row.price}
|
||||||
aria-label={at("tariff_label_price_rub", {}, "Цена пакета в рублях")}
|
aria-label={currencyPriceAriaLabel}
|
||||||
/>
|
/>
|
||||||
<AdminButton
|
<AdminButton
|
||||||
size="sm"
|
size="sm"
|
||||||
|
|||||||
@@ -17,6 +17,7 @@
|
|||||||
AdminSelect,
|
AdminSelect,
|
||||||
} from "$components/patterns/admin/index.js";
|
} from "$components/patterns/admin/index.js";
|
||||||
import { Accordion, Switch } from "$components/ui/primitives.js";
|
import { Accordion, Switch } from "$components/ui/primitives.js";
|
||||||
|
import { normalizeCurrencyKey } from "$lib/admin/tariffDraft.js";
|
||||||
|
|
||||||
export let at;
|
export let at;
|
||||||
export let fmtMoney;
|
export let fmtMoney;
|
||||||
@@ -88,6 +89,7 @@
|
|||||||
tariffsPath,
|
tariffsPath,
|
||||||
tariffsSaving,
|
tariffsSaving,
|
||||||
panelSquads,
|
panelSquads,
|
||||||
|
providerCurrencySupport,
|
||||||
panelSquadsLoading,
|
panelSquadsLoading,
|
||||||
} = $tariffsStore);
|
} = $tariffsStore);
|
||||||
$: ({ settingsSections, settingsDirty, settingsSaving } = $settingsStore);
|
$: ({ settingsSections, settingsDirty, settingsSaving } = $settingsStore);
|
||||||
@@ -111,25 +113,30 @@
|
|||||||
let selectedTrialSquad = "";
|
let selectedTrialSquad = "";
|
||||||
let trialSquadSelectKey = 0;
|
let trialSquadSelectKey = 0;
|
||||||
let tariffSettingsOpen = [];
|
let tariffSettingsOpen = [];
|
||||||
|
let defaultCurrencyDraft = "RUB";
|
||||||
|
|
||||||
function tariffName(tariff) {
|
function tariffName(tariff) {
|
||||||
return tariff?.names?.ru || tariff?.names?.en || tariff?.key || "—";
|
return tariff?.names?.ru || tariff?.names?.en || tariff?.key || "—";
|
||||||
}
|
}
|
||||||
|
|
||||||
function tariffPriceSummary(tariff) {
|
function tariffPriceSummary(tariff) {
|
||||||
|
const currency = normalizeCurrencyKey(tariffsCatalog.default_currency || "rub");
|
||||||
|
const currencyCode = currency.toUpperCase();
|
||||||
if (tariff.billing_model === "traffic") {
|
if (tariff.billing_model === "traffic") {
|
||||||
const rub = tariff.traffic_packages?.rub || [];
|
const packages = tariff.traffic_packages?.[currency] || [];
|
||||||
const first = rub[0];
|
const first = packages[0];
|
||||||
return first
|
return first
|
||||||
? `${first.gb} GB ${at("at", {}, "за")} ${fmtMoney(first.price, "RUB")}`
|
? `${first.gb} GB ${at("at", {}, "за")} ${fmtMoney(first.price, currencyCode)}`
|
||||||
: at("tariff_traffic_packages", {}, "Пакеты трафика");
|
: at("tariff_traffic_packages", {}, "Пакеты трафика");
|
||||||
}
|
}
|
||||||
const months = [...(tariff.enabled_periods || [])].sort((a, b) => a - b);
|
const months = [...(tariff.enabled_periods || [])].sort((a, b) => a - b);
|
||||||
return months
|
return months
|
||||||
.map((month) => {
|
.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)];
|
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} ⭐`;
|
if (stars) return `${month} ${at("months_short", {}, "мес.")} ${stars} ⭐`;
|
||||||
return `${month} ${at("months_short", {}, "мес.")}`;
|
return `${month} ${at("months_short", {}, "мес.")}`;
|
||||||
})
|
})
|
||||||
@@ -203,6 +210,50 @@
|
|||||||
trialSquadSelectKey += 1;
|
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) {
|
function removeTrialSquad(uuid) {
|
||||||
setCsvList(
|
setCsvList(
|
||||||
"TRIAL_SQUAD_UUIDS",
|
"TRIAL_SQUAD_UUIDS",
|
||||||
@@ -626,11 +677,137 @@
|
|||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
|
|
||||||
<article class="admin-card">
|
<div class="admin-tariff-management">
|
||||||
<header class="admin-card-head">
|
<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>
|
||||||
|
</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 class="admin-card admin-tariff-list-card">
|
||||||
|
<header class="admin-card-head admin-tariff-list-head">
|
||||||
<div>
|
<div>
|
||||||
<h3>{at("tariffs_title", {}, "Каталог тарифов")}</h3>
|
<h3>{at("tariffs_title", {}, "Каталог тарифов")}</h3>
|
||||||
<small>{tariffsPath || "data/tariffs.json"}</small>
|
<small>
|
||||||
|
{at("tariffs_catalog_subtitle", {}, "Периоды, цены, трафик и доступы пользователей.")}
|
||||||
|
</small>
|
||||||
|
<code class="admin-tariff-path">{tariffsPath || "data/tariffs.json"}</code>
|
||||||
</div>
|
</div>
|
||||||
<div class="admin-editor-section-actions">
|
<div class="admin-editor-section-actions">
|
||||||
<AdminButton
|
<AdminButton
|
||||||
@@ -695,10 +872,12 @@
|
|||||||
: at("tariff_model_periods", {}, "Периоды")}</span
|
: at("tariff_model_periods", {}, "Периоды")}</span
|
||||||
>
|
>
|
||||||
<span>{tariffPriceSummary(tariff)}</span>
|
<span>{tariffPriceSummary(tariff)}</span>
|
||||||
<span>{at("tariff_squads", {}, "Squads")}: {(tariff.squad_uuids || []).length}</span
|
<span
|
||||||
|
>{at("tariff_squads", {}, "Squads")}: {(tariff.squad_uuids || []).length}</span
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
>{at("tariff_premium", {}, "Premium")}: {(tariff.premium_squad_uuids || []).length
|
>{at("tariff_premium", {}, "Premium")}: {(tariff.premium_squad_uuids || [])
|
||||||
|
.length
|
||||||
? `${tariff.premium_monthly_gb || 0} GB`
|
? `${tariff.premium_monthly_gb || 0} GB`
|
||||||
: "—"}</span
|
: "—"}</span
|
||||||
>
|
>
|
||||||
@@ -749,6 +928,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
|
</div>
|
||||||
|
|
||||||
<Accordion.Root
|
<Accordion.Root
|
||||||
type="multiple"
|
type="multiple"
|
||||||
|
|||||||
@@ -23,6 +23,7 @@
|
|||||||
Trash2,
|
Trash2,
|
||||||
UserMinus,
|
UserMinus,
|
||||||
UserPlus,
|
UserPlus,
|
||||||
|
UsersRound,
|
||||||
} from "$components/ui/icons.js";
|
} from "$components/ui/icons.js";
|
||||||
import { getContext } from "svelte";
|
import { getContext } from "svelte";
|
||||||
|
|
||||||
@@ -63,6 +64,12 @@
|
|||||||
userDeleteOpen,
|
userDeleteOpen,
|
||||||
userBanConfirmOpen,
|
userBanConfirmOpen,
|
||||||
userMessageConfirmOpen,
|
userMessageConfirmOpen,
|
||||||
|
userReferralsOpen,
|
||||||
|
userReferralsLoading,
|
||||||
|
userReferrals,
|
||||||
|
userReferralsTotal,
|
||||||
|
userReferralsPage,
|
||||||
|
userReferralsPageSize,
|
||||||
premiumUnlimitedDraft,
|
premiumUnlimitedDraft,
|
||||||
userDetailTab,
|
userDetailTab,
|
||||||
userLogs,
|
userLogs,
|
||||||
@@ -75,8 +82,13 @@
|
|||||||
|
|
||||||
$: userLogsHasMore =
|
$: userLogsHasMore =
|
||||||
Number(userLogsTotal || 0) > (Number(userLogsPage || 0) + 1) * Number(userLogsPageSize || 20);
|
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) : "";
|
$: openedUserAvatarUrl = openedUser ? resolvedAvatarUrl(openedUser) : "";
|
||||||
|
$: referralInviter = openedUserDetail?.referral?.inviter || null;
|
||||||
|
$: referralInviteesTotal = Number(openedUserDetail?.referral?.invitees_total || 0);
|
||||||
$: openedUserTelegramProfileLink = openedUser ? userTelegramProfileLink(openedUser) : "";
|
$: openedUserTelegramProfileLink = openedUser ? userTelegramProfileLink(openedUser) : "";
|
||||||
$: openedUserTelegramProfileLinkKind = openedUser ? userTelegramProfileLinkKind(openedUser) : "";
|
$: openedUserTelegramProfileLinkKind = openedUser ? userTelegramProfileLinkKind(openedUser) : "";
|
||||||
$: openedUserTelegramProfileHint =
|
$: openedUserTelegramProfileHint =
|
||||||
@@ -119,6 +131,12 @@
|
|||||||
}
|
}
|
||||||
openTelegramProfileLink(openedUserTelegramProfileLink);
|
openTelegramProfileLink(openedUserTelegramProfileLink);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function openRelatedUser(user) {
|
||||||
|
if (!user?.user_id) return;
|
||||||
|
usersStore.closeUserReferrals();
|
||||||
|
usersStore.openUser(user);
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Dialog
|
<Dialog
|
||||||
@@ -225,6 +243,41 @@
|
|||||||
"—"}</strong
|
"—"}</strong
|
||||||
>
|
>
|
||||||
</li>
|
</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>
|
</ul>
|
||||||
|
|
||||||
{#if openedUserDetail.subscription_url || openedUserDetail.referral?.bot_link || openedUserDetail.referral?.webapp_link}
|
{#if openedUserDetail.subscription_url || openedUserDetail.referral?.bot_link || openedUserDetail.referral?.webapp_link}
|
||||||
@@ -934,6 +987,101 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</Dialog>
|
</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
|
<Dialog
|
||||||
open={avatarPreviewOpen}
|
open={avatarPreviewOpen}
|
||||||
title={avatarPreviewName || at("user_avatar_title", {}, "Аватар")}
|
title={avatarPreviewName || at("user_avatar_title", {}, "Аватар")}
|
||||||
@@ -1159,6 +1307,62 @@
|
|||||||
gap: 8px;
|
gap: 8px;
|
||||||
margin-top: 6px;
|
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) {
|
:global(.admin-avatar-dialog) {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-rows: auto minmax(0, 1fr);
|
grid-template-rows: auto minmax(0, 1fr);
|
||||||
|
|||||||
@@ -1,5 +1,15 @@
|
|||||||
<script>
|
<script>
|
||||||
import { Input } from "$components/ui/index.js";
|
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 { Label } from "$components/ui/primitives.js";
|
||||||
import {
|
import {
|
||||||
AdminBadge,
|
AdminBadge,
|
||||||
@@ -15,6 +25,7 @@
|
|||||||
|
|
||||||
export let at = (key) => key;
|
export let at = (key) => key;
|
||||||
export let fmtDateShort = (value) => value;
|
export let fmtDateShort = (value) => value;
|
||||||
|
export let fmtMoney = (value) => value;
|
||||||
export let panelStatusBadge = () => ({});
|
export let panelStatusBadge = () => ({});
|
||||||
export let resolvedAvatarUrl = () => "";
|
export let resolvedAvatarUrl = () => "";
|
||||||
export let userDisplayName = () => "";
|
export let userDisplayName = () => "";
|
||||||
@@ -36,6 +47,7 @@
|
|||||||
} = $usersStore);
|
} = $usersStore);
|
||||||
|
|
||||||
const USERS_PAGE_SIZE = 25;
|
const USERS_PAGE_SIZE = 25;
|
||||||
|
let usersFilterSheetOpen = false;
|
||||||
$: usersHasMore = users.length === USERS_PAGE_SIZE;
|
$: usersHasMore = users.length === USERS_PAGE_SIZE;
|
||||||
|
|
||||||
const USERS_FILTER_OPTIONS = [
|
const USERS_FILTER_OPTIONS = [
|
||||||
@@ -49,16 +61,31 @@
|
|||||||
{ value: "panel_linked", label: at("filter_panel_linked", {}, "С панелью") },
|
{ value: "panel_linked", label: at("filter_panel_linked", {}, "С панелью") },
|
||||||
];
|
];
|
||||||
|
|
||||||
const USERS_SORT_OPTIONS = [
|
const SORT_COLUMNS = {
|
||||||
{ value: "registered_desc", label: at("sort_registered_desc", {}, "Сначала новые") },
|
user: { asc: "name_asc", desc: "name_desc", defaultDirection: "asc" },
|
||||||
{ value: "registered_asc", label: at("sort_registered_asc", {}, "Сначала старые") },
|
premium: { asc: "premium_ratio_asc", desc: "premium_ratio_desc", defaultDirection: "desc" },
|
||||||
{ value: "name_asc", label: at("sort_name_asc", {}, "Имя ↑") },
|
paymentsTotal: {
|
||||||
{ value: "name_desc", label: at("sort_name_desc", {}, "Имя ↓") },
|
asc: "payments_total_asc",
|
||||||
{ value: "id_asc", label: at("sort_id_asc", {}, "ID ↑") },
|
desc: "payments_total_desc",
|
||||||
{ value: "id_desc", label: at("sort_id_desc", {}, "ID ↓") },
|
defaultDirection: "desc",
|
||||||
{ value: "premium_ratio_asc", label: at("sort_premium_ratio_asc", {}, "Премиум % ↑") },
|
},
|
||||||
{ value: "premium_ratio_desc", label: at("sort_premium_ratio_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 = [
|
const USERS_PANEL_STATUS_OPTIONS = [
|
||||||
{ value: "all", label: at("panel_status_all", {}, "Все статусы") },
|
{ value: "all", label: at("panel_status_all", {}, "Все статусы") },
|
||||||
@@ -79,6 +106,29 @@
|
|||||||
{ value: "critical", label: at("premium_traffic_filter_critical", {}, "Премиум: исчерпан") },
|
{ 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 */
|
/** @param {Record<string, unknown> | null | undefined} pt */
|
||||||
function premiumTrafficBadgeVariant(pt) {
|
function premiumTrafficBadgeVariant(pt) {
|
||||||
if (!pt || pt.state === "none") return "muted";
|
if (!pt || pt.state === "none") return "muted";
|
||||||
@@ -94,18 +144,160 @@
|
|||||||
return trafficOfLabel(pt.used_bytes, pt.limit_bytes);
|
return trafficOfLabel(pt.used_bytes, pt.limit_bytes);
|
||||||
}
|
}
|
||||||
|
|
||||||
$: userTableHeaders = [
|
function userTableColumns() {
|
||||||
at("user", {}, "Пользователь"),
|
return [
|
||||||
at("premium_traffic_filter_label", {}, "Премиум трафик"),
|
{ key: "user", label: at("user", {}, "Пользователь"), sort: SORT_COLUMNS.user },
|
||||||
at("status", {}, "Статус"),
|
{
|
||||||
at("users_col_registration", {}, "Регистрация"),
|
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(() => {
|
onMount(() => {
|
||||||
usersStore.loadUsers();
|
usersStore.loadUsers();
|
||||||
});
|
});
|
||||||
</script>
|
</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 admin-toolbar-users">
|
||||||
<div class="admin-toolbar-search">
|
<div class="admin-toolbar-search">
|
||||||
<Input
|
<Input
|
||||||
@@ -119,11 +311,28 @@
|
|||||||
/>
|
/>
|
||||||
<AdminButton
|
<AdminButton
|
||||||
variant="primary"
|
variant="primary"
|
||||||
|
class="admin-users-search-button"
|
||||||
onclick={() => {
|
onclick={() => {
|
||||||
usersStore.updateState({ usersPage: 0 });
|
usersStore.updateState({ usersPage: 0 });
|
||||||
usersStore.loadUsers();
|
usersStore.loadUsers();
|
||||||
}}>{at("find", {}, "Найти")}</AdminButton
|
}}>{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>
|
||||||
|
|
||||||
<div class="admin-toolbar-controls">
|
<div class="admin-toolbar-controls">
|
||||||
@@ -171,33 +380,56 @@
|
|||||||
/>
|
/>
|
||||||
</Label.Root>
|
</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">
|
<div class="admin-toolbar-summary">
|
||||||
<span class="admin-toolbar-field-label">{at("total", {}, "Всего")}</span>
|
<span class="admin-toolbar-field-label">{at("total", {}, "Всего")}</span>
|
||||||
<strong>{usersTotal}</strong>
|
<strong>{usersTotal}</strong>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{@render renderActiveUserFilterChips()}
|
||||||
</div>
|
</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}
|
{#if usersLoading}
|
||||||
<AdminTableSkeleton
|
<AdminTableSkeleton
|
||||||
headers={userTableHeaders}
|
headers={userTableHeaders}
|
||||||
rows={USERS_PAGE_SIZE}
|
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}
|
{:else if !users.length}
|
||||||
<AdminEmptyState tone="card"
|
<AdminEmptyState tone="card"
|
||||||
@@ -208,10 +440,35 @@
|
|||||||
<AdminTable class="admin-users-table">
|
<AdminTable class="admin-users-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>{at("user", {}, "Пользователь")}</th>
|
{#each userTableColumns() as column (column.key)}
|
||||||
<th>{at("premium_traffic_filter_label", {}, "Премиум трафик")}</th>
|
<th aria-sort={column.sort ? sortState(column.sort) : undefined}>
|
||||||
<th>{at("status", {}, "Статус")}</th>
|
{#if column.sort}
|
||||||
<th>{at("users_col_registration", {}, "Регистрация")}</th>
|
<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>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@@ -264,9 +521,41 @@
|
|||||||
>
|
>
|
||||||
{/if}
|
{/if}
|
||||||
</td>
|
</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", {}, "Статус")}>
|
<td data-label={at("status", {}, "Статус")}>
|
||||||
<AdminBadge variant={badge.variant}>{badge.label}</AdminBadge>
|
<AdminBadge variant={badge.variant}>{badge.label}</AdminBadge>
|
||||||
</td>
|
</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
|
<td
|
||||||
class="admin-users-cell-date admin-cell-mono"
|
class="admin-users-cell-date admin-cell-mono"
|
||||||
data-label={at("users_col_registration", {}, "Регистрация")}
|
data-label={at("users_col_registration", {}, "Регистрация")}
|
||||||
@@ -297,6 +586,161 @@
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<style>
|
<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 {
|
.admin-users-cell-user-inner {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -353,6 +797,30 @@
|
|||||||
font-variant-numeric: tabular-nums;
|
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 {
|
.admin-users-cell-date {
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
@@ -363,4 +831,76 @@
|
|||||||
outline: 2px solid var(--admin-ring);
|
outline: 2px solid var(--admin-ring);
|
||||||
outline-offset: -2px;
|
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>
|
</style>
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ export function createBroadcastStore({ api, onToast, at }) {
|
|||||||
{ value: "all", label: at("broadcast_target_all", {}, "Все активные") },
|
{ value: "all", label: at("broadcast_target_all", {}, "Все активные") },
|
||||||
{ value: "active", label: at("broadcast_target_active", {}, "С подпиской") },
|
{ value: "active", label: at("broadcast_target_active", {}, "С подпиской") },
|
||||||
{ value: "inactive", label: at("broadcast_target_inactive", {}, "Без подписки") },
|
{ value: "inactive", label: at("broadcast_target_inactive", {}, "Без подписки") },
|
||||||
|
{ value: "expired", label: at("broadcast_target_expired", {}, "Expired subscription") },
|
||||||
];
|
];
|
||||||
|
|
||||||
async function runBroadcast() {
|
async function runBroadcast() {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
cloneCatalog,
|
cloneCatalog,
|
||||||
draftFromTariff,
|
draftFromTariff,
|
||||||
tariffFromDraft as tariffFromDraftFn,
|
tariffFromDraft as tariffFromDraftFn,
|
||||||
|
normalizeCurrencyKey,
|
||||||
normalizeUuidList,
|
normalizeUuidList,
|
||||||
} from "../tariffDraft.js";
|
} from "../tariffDraft.js";
|
||||||
|
|
||||||
@@ -11,6 +12,7 @@ export function createTariffsStore({ api, onTariffsSaved, flash, at }) {
|
|||||||
const state = writable({
|
const state = writable({
|
||||||
tariffsCatalog: {
|
tariffsCatalog: {
|
||||||
default_tariff: "",
|
default_tariff: "",
|
||||||
|
default_currency: "rub",
|
||||||
topup_packages_default: { rub: [], stars: [] },
|
topup_packages_default: { rub: [], stars: [] },
|
||||||
tariffs: [],
|
tariffs: [],
|
||||||
},
|
},
|
||||||
@@ -23,13 +25,15 @@ export function createTariffsStore({ api, onTariffsSaved, flash, at }) {
|
|||||||
tariffDeleteTarget: null,
|
tariffDeleteTarget: null,
|
||||||
tariffDraft: emptyTariffDraft(),
|
tariffDraft: emptyTariffDraft(),
|
||||||
panelSquads: [],
|
panelSquads: [],
|
||||||
|
providerCurrencySupport: [],
|
||||||
panelSquadsLoading: false,
|
panelSquadsLoading: false,
|
||||||
selectedBaseSquad: "",
|
selectedBaseSquad: "",
|
||||||
selectedPremiumSquad: "",
|
selectedPremiumSquad: "",
|
||||||
tariffEditorTab: "general",
|
tariffEditorTab: "general",
|
||||||
});
|
});
|
||||||
|
|
||||||
const tariffFromDraft = (draft) => tariffFromDraftFn(draft);
|
const tariffFromDraft = (draft, defaultCurrency = "rub") =>
|
||||||
|
tariffFromDraftFn(draft, defaultCurrency);
|
||||||
|
|
||||||
async function loadTariffs() {
|
async function loadTariffs() {
|
||||||
state.update((s) => ({ ...s, tariffsLoading: true }));
|
state.update((s) => ({ ...s, tariffsLoading: true }));
|
||||||
@@ -41,6 +45,7 @@ export function createTariffsStore({ api, onTariffsSaved, flash, at }) {
|
|||||||
...s,
|
...s,
|
||||||
tariffsCatalog: cloneCatalog(data.catalog),
|
tariffsCatalog: cloneCatalog(data.catalog),
|
||||||
tariffsPath: data.path || "",
|
tariffsPath: data.path || "",
|
||||||
|
providerCurrencySupport: data.provider_currency_support || [],
|
||||||
}));
|
}));
|
||||||
} else {
|
} else {
|
||||||
flash(data?.message || data?.error || at("load_failed", {}, "Не удалось загрузить тарифы"));
|
flash(data?.message || data?.error || at("load_failed", {}, "Не удалось загрузить тарифы"));
|
||||||
@@ -119,6 +124,7 @@ export function createTariffsStore({ api, onTariffsSaved, flash, at }) {
|
|||||||
...s,
|
...s,
|
||||||
tariffsCatalog: cloneCatalog(res.catalog),
|
tariffsCatalog: cloneCatalog(res.catalog),
|
||||||
tariffsPath: res.path || currentPath,
|
tariffsPath: res.path || currentPath,
|
||||||
|
providerCurrencySupport: res.provider_currency_support || s.providerCurrencySupport || [],
|
||||||
tariffEditorOpen: false,
|
tariffEditorOpen: false,
|
||||||
tariffDeleteOpen: false,
|
tariffDeleteOpen: false,
|
||||||
tariffDeleteTarget: null,
|
tariffDeleteTarget: null,
|
||||||
@@ -139,7 +145,10 @@ export function createTariffsStore({ api, onTariffsSaved, flash, at }) {
|
|||||||
state.update((s) => ({
|
state.update((s) => ({
|
||||||
...s,
|
...s,
|
||||||
tariffEditingKey: "",
|
tariffEditingKey: "",
|
||||||
tariffDraft: emptyTariffDraft(),
|
tariffDraft: {
|
||||||
|
...emptyTariffDraft(),
|
||||||
|
defaultCurrency: s.tariffsCatalog.default_currency || "rub",
|
||||||
|
},
|
||||||
tariffEditorTab: "general",
|
tariffEditorTab: "general",
|
||||||
selectedBaseSquad: "",
|
selectedBaseSquad: "",
|
||||||
selectedPremiumSquad: "",
|
selectedPremiumSquad: "",
|
||||||
@@ -151,7 +160,7 @@ export function createTariffsStore({ api, onTariffsSaved, flash, at }) {
|
|||||||
state.update((s) => ({
|
state.update((s) => ({
|
||||||
...s,
|
...s,
|
||||||
tariffEditingKey: tariff.key,
|
tariffEditingKey: tariff.key,
|
||||||
tariffDraft: draftFromTariff(tariff),
|
tariffDraft: draftFromTariff(tariff, s.tariffsCatalog.default_currency || "rub"),
|
||||||
tariffEditorTab: "general",
|
tariffEditorTab: "general",
|
||||||
selectedBaseSquad: "",
|
selectedBaseSquad: "",
|
||||||
selectedPremiumSquad: "",
|
selectedPremiumSquad: "",
|
||||||
@@ -165,7 +174,7 @@ export function createTariffsStore({ api, onTariffsSaved, flash, at }) {
|
|||||||
s = st;
|
s = st;
|
||||||
return st;
|
return st;
|
||||||
});
|
});
|
||||||
const tariff = tariffFromDraft(s.tariffDraft);
|
const tariff = tariffFromDraft(s.tariffDraft, s.tariffsCatalog.default_currency || "rub");
|
||||||
if (!tariff.key) {
|
if (!tariff.key) {
|
||||||
flash(at("tariff_error_key_required", {}, "Укажите ключ тарифа"));
|
flash(at("tariff_error_key_required", {}, "Укажите ключ тарифа"));
|
||||||
return;
|
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() {
|
async function deleteTariff() {
|
||||||
let s;
|
let s;
|
||||||
state.update((st) => {
|
state.update((st) => {
|
||||||
@@ -295,6 +322,7 @@ export function createTariffsStore({ api, onTariffsSaved, flash, at }) {
|
|||||||
saveTariffDraft,
|
saveTariffDraft,
|
||||||
toggleTariffEnabled,
|
toggleTariffEnabled,
|
||||||
setDefaultTariff,
|
setDefaultTariff,
|
||||||
|
setDefaultCurrency,
|
||||||
deleteTariff,
|
deleteTariff,
|
||||||
addDraftRow,
|
addDraftRow,
|
||||||
removeDraftRow,
|
removeDraftRow,
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ export function createUsersStore({ api, onToast, at, routePrefix = "" }) {
|
|||||||
usersFilter: "all",
|
usersFilter: "all",
|
||||||
usersPanelStatus: "all",
|
usersPanelStatus: "all",
|
||||||
usersPremiumTraffic: "all",
|
usersPremiumTraffic: "all",
|
||||||
usersSort: "registered_desc",
|
usersSort: "",
|
||||||
usersLoading: false,
|
usersLoading: false,
|
||||||
|
|
||||||
openedUser: null,
|
openedUser: null,
|
||||||
@@ -25,6 +25,13 @@ export function createUsersStore({ api, onToast, at, routePrefix = "" }) {
|
|||||||
userDeleteOpen: false,
|
userDeleteOpen: false,
|
||||||
userBanConfirmOpen: false,
|
userBanConfirmOpen: false,
|
||||||
userMessageConfirmOpen: false,
|
userMessageConfirmOpen: false,
|
||||||
|
userReferralsOpen: false,
|
||||||
|
userReferralsLoading: false,
|
||||||
|
userReferrals: [],
|
||||||
|
userReferralsTotal: 0,
|
||||||
|
userReferralsPage: 0,
|
||||||
|
userReferralsPageSize: USERS_PAGE_SIZE,
|
||||||
|
userReferralsInviter: null,
|
||||||
userDetailTab: "profile",
|
userDetailTab: "profile",
|
||||||
premiumUnlimitedDraft: false,
|
premiumUnlimitedDraft: false,
|
||||||
premiumBonusGbDraft: "",
|
premiumBonusGbDraft: "",
|
||||||
@@ -96,7 +103,7 @@ export function createUsersStore({ api, onToast, at, routePrefix = "" }) {
|
|||||||
if (s.usersPremiumTraffic && s.usersPremiumTraffic !== "all") {
|
if (s.usersPremiumTraffic && s.usersPremiumTraffic !== "all") {
|
||||||
params.set("premium_traffic", s.usersPremiumTraffic);
|
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()}`);
|
const data = await api(`/admin/users?${params.toString()}`);
|
||||||
if (data?.ok) {
|
if (data?.ok) {
|
||||||
state.update((st) => ({
|
state.update((st) => ({
|
||||||
@@ -126,6 +133,12 @@ export function createUsersStore({ api, onToast, at, routePrefix = "" }) {
|
|||||||
userExtendDays: 30,
|
userExtendDays: 30,
|
||||||
userDetailLoading: true,
|
userDetailLoading: true,
|
||||||
userDetailTab: "subscription",
|
userDetailTab: "subscription",
|
||||||
|
userReferralsOpen: false,
|
||||||
|
userReferralsLoading: false,
|
||||||
|
userReferrals: [],
|
||||||
|
userReferralsTotal: 0,
|
||||||
|
userReferralsPage: 0,
|
||||||
|
userReferralsInviter: null,
|
||||||
userLogs: [],
|
userLogs: [],
|
||||||
userLogsTotal: 0,
|
userLogsTotal: 0,
|
||||||
userLogsPage: 0,
|
userLogsPage: 0,
|
||||||
@@ -175,6 +188,12 @@ export function createUsersStore({ api, onToast, at, routePrefix = "" }) {
|
|||||||
userDeleteOpen: false,
|
userDeleteOpen: false,
|
||||||
userBanConfirmOpen: false,
|
userBanConfirmOpen: false,
|
||||||
userMessageConfirmOpen: false,
|
userMessageConfirmOpen: false,
|
||||||
|
userReferralsOpen: false,
|
||||||
|
userReferralsLoading: false,
|
||||||
|
userReferrals: [],
|
||||||
|
userReferralsTotal: 0,
|
||||||
|
userReferralsPage: 0,
|
||||||
|
userReferralsInviter: null,
|
||||||
userLogs: [],
|
userLogs: [],
|
||||||
userLogsTotal: 0,
|
userLogsTotal: 0,
|
||||||
userLogsPage: 0,
|
userLogsPage: 0,
|
||||||
@@ -231,6 +250,58 @@ export function createUsersStore({ api, onToast, at, routePrefix = "" }) {
|
|||||||
loadUserLogs(page);
|
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", {}, "Скопировано")) {
|
function copyToClipboard(text, successMessage = at("link_copied", {}, "Скопировано")) {
|
||||||
if (!text) return;
|
if (!text) return;
|
||||||
if (typeof navigator !== "undefined" && navigator?.clipboard?.writeText) {
|
if (typeof navigator !== "undefined" && navigator?.clipboard?.writeText) {
|
||||||
@@ -398,8 +469,11 @@ export function createUsersStore({ api, onToast, at, routePrefix = "" }) {
|
|||||||
state.update((st) => ({ ...st, userActionBusy: true }));
|
state.update((st) => ({ ...st, userActionBusy: true }));
|
||||||
try {
|
try {
|
||||||
const res = await api(`/admin/users/${s.openedUser.user_id}/reset-trial`, { method: "POST" });
|
const res = await api(`/admin/users/${s.openedUser.user_id}/reset-trial`, { method: "POST" });
|
||||||
if (res?.ok) onToast(at("trial_reset", {}, "Триал сброшен"));
|
if (res?.ok) {
|
||||||
else onToast(res?.error || at("error", {}, "Ошибка"));
|
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 {
|
} finally {
|
||||||
state.update((st) => ({ ...st, userActionBusy: false }));
|
state.update((st) => ({ ...st, userActionBusy: false }));
|
||||||
}
|
}
|
||||||
@@ -564,5 +638,8 @@ export function createUsersStore({ api, onToast, at, routePrefix = "" }) {
|
|||||||
grantTraffic,
|
grantTraffic,
|
||||||
loadUserLogs,
|
loadUserLogs,
|
||||||
setUserLogsPage,
|
setUserLogsPage,
|
||||||
|
openUserReferrals,
|
||||||
|
closeUserReferrals,
|
||||||
|
setUserReferralsPage,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { structuredCloneSafe } from "./format.js";
|
|||||||
|
|
||||||
export function emptyTariffDraft() {
|
export function emptyTariffDraft() {
|
||||||
return {
|
return {
|
||||||
|
defaultCurrency: "rub",
|
||||||
key: "",
|
key: "",
|
||||||
nameRu: "",
|
nameRu: "",
|
||||||
nameEn: "",
|
nameEn: "",
|
||||||
@@ -40,11 +41,22 @@ export function emptyTariffDraft() {
|
|||||||
export function cloneCatalog(catalog) {
|
export function cloneCatalog(catalog) {
|
||||||
return structuredCloneSafe({
|
return structuredCloneSafe({
|
||||||
default_tariff: catalog?.default_tariff || "",
|
default_tariff: catalog?.default_tariff || "",
|
||||||
|
default_currency: normalizeCurrencyKey(catalog?.default_currency || "rub"),
|
||||||
topup_packages_default: catalog?.topup_packages_default || { rub: [], stars: [] },
|
topup_packages_default: catalog?.topup_packages_default || { rub: [], stars: [] },
|
||||||
tariffs: catalog?.tariffs || [],
|
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) {
|
export function rowsFromPackages(packageSet, currency, valueKey) {
|
||||||
return (packageSet?.[currency] || []).map((pkg) => ({
|
return (packageSet?.[currency] || []).map((pkg) => ({
|
||||||
[valueKey]: pkg[valueKey],
|
[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([
|
const months = new Set([
|
||||||
...(tariff.enabled_periods || []),
|
...(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),
|
...Object.keys(tariff.prices_stars || {}).map(Number),
|
||||||
]);
|
]);
|
||||||
const periodRows = [...months]
|
const periodRows = [...months]
|
||||||
@@ -65,7 +80,10 @@ export function draftFromTariff(tariff) {
|
|||||||
.sort((a, b) => a - b)
|
.sort((a, b) => a - b)
|
||||||
.map((month) => ({
|
.map((month) => ({
|
||||||
months: 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)] ?? "",
|
stars: tariff.prices_stars?.[String(month)] ?? "",
|
||||||
referral_inviter: tariff.referral_bonus_days_inviter?.[String(month)] ?? "",
|
referral_inviter: tariff.referral_bonus_days_inviter?.[String(month)] ?? "",
|
||||||
referral_referee: tariff.referral_bonus_days_referee?.[String(month)] ?? "",
|
referral_referee: tariff.referral_bonus_days_referee?.[String(month)] ?? "",
|
||||||
@@ -73,6 +91,7 @@ export function draftFromTariff(tariff) {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
...emptyTariffDraft(),
|
...emptyTariffDraft(),
|
||||||
|
defaultCurrency: currency,
|
||||||
key: tariff.key || "",
|
key: tariff.key || "",
|
||||||
nameRu: tariff.names?.ru || "",
|
nameRu: tariff.names?.ru || "",
|
||||||
nameEn: tariff.names?.en || "",
|
nameEn: tariff.names?.en || "",
|
||||||
@@ -89,13 +108,13 @@ export function draftFromTariff(tariff) {
|
|||||||
hwid_device_limit: tariff.hwid_device_limit ?? "",
|
hwid_device_limit: tariff.hwid_device_limit ?? "",
|
||||||
conversion_rate_rub_per_gb: tariff.conversion_rate_rub_per_gb ?? "",
|
conversion_rate_rub_per_gb: tariff.conversion_rate_rub_per_gb ?? "",
|
||||||
periodRows: periodRows.length ? periodRows : emptyTariffDraft().periodRows,
|
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"),
|
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"),
|
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"),
|
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"),
|
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);
|
.filter((row) => row[valueKey] > 0 && row.price !== null && row.price >= 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function packageSetFromRows(rubRows, starsRows, valueKey) {
|
export function packageSetFromRows(rubRows, starsRows, valueKey, defaultCurrency = "rub") {
|
||||||
const rub = packagesFromRows(rubRows, valueKey);
|
const currency = normalizeCurrencyKey(defaultCurrency);
|
||||||
|
const defaultCurrencyPackages = packagesFromRows(rubRows, valueKey);
|
||||||
const stars = packagesFromRows(starsRows, 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) {
|
export function normalizeUuidList(value) {
|
||||||
@@ -150,7 +174,8 @@ export function normalizeUuidList(value) {
|
|||||||
.filter(Boolean);
|
.filter(Boolean);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function tariffFromDraft(draft) {
|
export function tariffFromDraft(draft, fallbackCurrency = "rub") {
|
||||||
|
const defaultCurrency = normalizeCurrencyKey(draft.defaultCurrency || fallbackCurrency);
|
||||||
const key = draft.key.trim();
|
const key = draft.key.trim();
|
||||||
const names = compactMap({ ru: draft.nameRu.trim(), en: draft.nameEn.trim() });
|
const names = compactMap({ ru: draft.nameRu.trim(), en: draft.nameEn.trim() });
|
||||||
const descriptions = compactMap({
|
const descriptions = compactMap({
|
||||||
@@ -174,14 +199,20 @@ export function tariffFromDraft(draft) {
|
|||||||
|
|
||||||
const hwidLimit = parseIntNumber(draft.hwid_device_limit);
|
const hwidLimit = parseIntNumber(draft.hwid_device_limit);
|
||||||
if (hwidLimit !== null) tariff.hwid_device_limit = hwidLimit;
|
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;
|
if (hwidPackages) tariff.hwid_device_packages = hwidPackages;
|
||||||
const premiumMonthlyGb = parseNumber(draft.premium_monthly_gb);
|
const premiumMonthlyGb = parseNumber(draft.premium_monthly_gb);
|
||||||
if (premiumMonthlyGb !== null) tariff.premium_monthly_gb = premiumMonthlyGb;
|
if (premiumMonthlyGb !== null) tariff.premium_monthly_gb = premiumMonthlyGb;
|
||||||
const premiumTopupPackages = packageSetFromRows(
|
const premiumTopupPackages = packageSetFromRows(
|
||||||
draft.premiumTopupRubRows,
|
draft.premiumTopupRubRows,
|
||||||
draft.premiumTopupStarsRows,
|
draft.premiumTopupStarsRows,
|
||||||
"gb"
|
"gb",
|
||||||
|
defaultCurrency
|
||||||
);
|
);
|
||||||
if (premiumTopupPackages) tariff.premium_topup_packages = premiumTopupPackages;
|
if (premiumTopupPackages) tariff.premium_topup_packages = premiumTopupPackages;
|
||||||
|
|
||||||
@@ -204,7 +235,12 @@ export function tariffFromDraft(draft) {
|
|||||||
.sort((a, b) => a.months - b.months);
|
.sort((a, b) => a.months - b.months);
|
||||||
tariff.monthly_gb = parseNumber(draft.monthly_gb, 0);
|
tariff.monthly_gb = parseNumber(draft.monthly_gb, 0);
|
||||||
tariff.enabled_periods = rows.map((row) => row.months);
|
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(
|
tariff.prices_stars = Object.fromEntries(
|
||||||
rows.map((row) => [String(row.months), row.stars || 0])
|
rows.map((row) => [String(row.months), row.stars || 0])
|
||||||
);
|
);
|
||||||
@@ -218,10 +254,20 @@ export function tariffFromDraft(draft) {
|
|||||||
.filter((row) => row.referral_referee !== null)
|
.filter((row) => row.referral_referee !== null)
|
||||||
.map((row) => [String(row.months), row.referral_referee])
|
.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;
|
if (topupPackages) tariff.topup_packages = topupPackages;
|
||||||
} else {
|
} else {
|
||||||
const trafficPackages = packageSetFromRows(draft.trafficRubRows, draft.trafficStarsRows, "gb");
|
const trafficPackages = packageSetFromRows(
|
||||||
|
draft.trafficRubRows,
|
||||||
|
draft.trafficStarsRows,
|
||||||
|
"gb",
|
||||||
|
defaultCurrency
|
||||||
|
);
|
||||||
if (trafficPackages) tariff.traffic_packages = trafficPackages;
|
if (trafficPackages) tariff.traffic_packages = trafficPackages;
|
||||||
const conversion = parseNumber(draft.conversion_rate_rub_per_gb);
|
const conversion = parseNumber(draft.conversion_rate_rub_per_gb);
|
||||||
if (conversion !== null) tariff.conversion_rate_rub_per_gb = conversion;
|
if (conversion !== null) tariff.conversion_rate_rub_per_gb = conversion;
|
||||||
|
|||||||
@@ -7,7 +7,10 @@
|
|||||||
export let ariaLabel = "";
|
export let ariaLabel = "";
|
||||||
export let placeholder = "";
|
export let placeholder = "";
|
||||||
export let disabled = false;
|
export let disabled = false;
|
||||||
|
export let side = "bottom";
|
||||||
|
export let align = "start";
|
||||||
export let sideOffset = 6;
|
export let sideOffset = 6;
|
||||||
|
export let collisionPadding = 12;
|
||||||
export let onValueChange = () => {};
|
export let onValueChange = () => {};
|
||||||
let className = "";
|
let className = "";
|
||||||
export { className as class };
|
export { className as class };
|
||||||
@@ -29,7 +32,7 @@
|
|||||||
<ChevronDown size={14} class="admin-select-icon" />
|
<ChevronDown size={14} class="admin-select-icon" />
|
||||||
</Select.Trigger>
|
</Select.Trigger>
|
||||||
<Select.Portal>
|
<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">
|
<Select.Viewport class="admin-select-viewport">
|
||||||
{#each items as item (item.value)}
|
{#each items as item (item.value)}
|
||||||
<Select.Item value={item.value} label={item.label} class="admin-select-item">
|
<Select.Item value={item.value} label={item.label} class="admin-select-item">
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user