diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 027a381..3d550d9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,8 +1,8 @@ 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. +# from feature/* branches): lint + format checks, a demo settings-manifest +# drift guard, and a no-push image build to prove the Docker images still build. on: pull_request: @@ -56,6 +56,30 @@ jobs: run: npm run format:check working-directory: frontend + demo-manifest: + name: Demo settings manifest in sync + 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" + cache: pip + cache-dependency-path: backend/requirements.txt + + - name: Install backend deps + pytest + run: pip install -r backend/requirements.txt pytest + + # Fails if admin_settings_manifest.py changed without regenerating the + # demo snapshot. Fix: `python scripts/export_settings_manifest.py` then + # `npx --prefix frontend prettier --write \ + # src/lib/webapp/settingsManifest.generated.json`, and commit the result. + - name: Check demo settings manifest is in sync + run: python -m pytest tests/test_settings_manifest_demo_sync.py -q + build: name: Docker build uses: ./.github/workflows/_docker-build-push.yml diff --git a/README.md b/README.md index d1c581f..374e55a 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ Remnawave Minishop - Telegram-бот и Web App (Mini App) для продажи - Web App / Mini App с входом через Telegram или email; - встроенные инструкции установки в Mini App: личный экран `/install` и публичная ссылка `/s/` для передачи инструкции; - пробный период, промокоды и реферальная программа; -- оплата через YooKassa, FreeKassa, Platega, SeverPay, Wata, CryptoPay, Heleket и Telegram Stars; +- оплата через YooKassa, FreeKassa, Platega, SeverPay, Wata, CryptoPay, Heleket, PayKilla и Telegram Stars; - тикеты поддержки в Web App и внешняя ссылка на поддержку; - раздел "Мои устройства" при включенном `MY_DEVICES_SECTION_ENABLED`. @@ -43,8 +43,8 @@ Remnawave Minishop - Telegram-бот и Web App (Mini App) для продажи - [Telegram-авторизация](docs/features/telegram-auth.md) и [вход по email](docs/features/email-login.md) - настройка BotFather/OAuth и SMTP-логина. - [Поддержка пользователей / тикеты](docs/features/support.md) - тикеты в Mini App, входящий список админки, уведомления, лимиты и внешняя ссылка поддержки. - [Темы Web App](docs/features/webapp-themes.md) - кастомные темы, настройка внешнего вида, логотипы, CSS/ассеты и пайплайн создания новой темы. -- [Миграции](docs/migrations/index.md) - готовые сценарии переноса с других ботов; сейчас описан `remnawave-tg-shop`. -- [Миграция с remnawave-tg-shop](docs/migrations/remnawave-tg-shop.md) - готовый сценарий для legacy-стека. +- [Миграции](docs/migrations/index.md) - готовые сценарии переноса с `remnawave-tg-shop` и Remnashop. +- [Миграция с remnawave-tg-shop](docs/migrations/remnawave-tg-shop.md) и [Remnashop](docs/migrations/remnashop.md) - сценарии через общий install wizard. ## Совместимость @@ -98,10 +98,10 @@ docker compose logs -f backend worker frontend Для каталога тарифов используется `TARIFFS_CONFIG_PATH` со значением по умолчанию `data/tariffs.json`. Пример формата лежит в [data/tariffs.example.json](data/tariffs.example.json), подробности - в [docs/features/tariffs.md](docs/features/tariffs.md). -В compose-примерах `/app/data` монтируется из папки `./data` рядом с `docker-compose.yml`. Заранее создайте каталог и отдайте его пользователю контейнера. Это нужно для сохранения `data/tariffs.json`, каталога тем `data/themes`, кеша логотипа Web App и animated emoji: +В compose-примерах `/app/data` монтируется из папки `./data` рядом с `docker-compose.yml`. Заранее создайте каталог и отдайте его пользователю контейнера. Это нужно для сохранения `data/tariffs.json`, каталога тем `data/themes` и кеша логотипа Web App: ```bash -mkdir -p data/themes data/webapp-logo data/webapp-emoji +mkdir -p data/themes data/webapp-logo touch data/locales-overrides.json chown -R 10001:10001 data chmod -R u+rwX data diff --git a/backend/bot/app/web/admin_api_impl/broadcast.py b/backend/bot/app/web/admin_api_impl/broadcast.py index b19bc0a..7daae95 100644 --- a/backend/bot/app/web/admin_api_impl/broadcast.py +++ b/backend/bot/app/web/admin_api_impl/broadcast.py @@ -9,7 +9,7 @@ async def admin_broadcast_route(request: web.Request) -> web.Response: target = str(payload.get("target") or "all").strip().lower() if not text: return _error(400, "empty_text") - if target not in {"all", "active", "inactive", "expired"}: + if target not in {"all", "active", "inactive", "expired", "never"}: target = "all" queue_manager = get_queue_manager() @@ -24,6 +24,8 @@ async def admin_broadcast_route(request: web.Request) -> web.Response: 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) + elif target == "never": + user_ids = await user_dal.get_user_ids_without_any_subscription(session) else: user_ids = await user_dal.get_all_active_user_ids_for_broadcast(session) @@ -54,3 +56,20 @@ async def admin_broadcast_route(request: web.Request) -> web.Response: ) return _ok({"queued": sent, "failed": failed, "target": target}) + + +async def admin_broadcast_audience_counts_route(request: web.Request) -> web.Response: + """Return how many users each broadcast audience currently resolves to.""" + _require_admin_user_id(request) + + async_session_factory: sessionmaker = request.app["async_session_factory"] + async with async_session_factory() as session: + counts = { + "all": len(await user_dal.get_all_active_user_ids_for_broadcast(session)), + "active": len(await user_dal.get_user_ids_with_active_subscription(session)), + "inactive": len(await user_dal.get_user_ids_without_active_subscription(session)), + "expired": len(await user_dal.get_user_ids_with_expired_subscription(session)), + "never": len(await user_dal.get_user_ids_without_any_subscription(session)), + } + + return _ok({"counts": counts}) diff --git a/backend/bot/app/web/admin_api_impl/common.py b/backend/bot/app/web/admin_api_impl/common.py index b43ac82..907914d 100644 --- a/backend/bot/app/web/admin_api_impl/common.py +++ b/backend/bot/app/web/admin_api_impl/common.py @@ -94,6 +94,9 @@ def _serialize_subscription(sub: Subscription) -> Dict[str, Any]: regular_unlimited_override = bool(getattr(sub, "regular_unlimited_override", False)) premium_unlimited_override = bool(getattr(sub, "premium_unlimited_override", False)) premium_limit_bytes = _premium_limit_bytes_from_subscription(sub) + provider = sub.provider + is_trial = str(provider or "").strip().lower() == "trial" + display_label = "Trial" if is_trial else sub.tariff_key return { "subscription_id": int(sub.subscription_id), "panel_user_uuid": sub.panel_user_uuid, @@ -117,9 +120,13 @@ def _serialize_subscription(sub: Subscription) -> Dict[str, Any]: "regular_unlimited_override": regular_unlimited_override, "premium_unlimited_override": premium_unlimited_override, "premium_is_limited": bool(sub.premium_is_limited), + "hwid_device_limit": getattr(sub, "hwid_device_limit", None), + "extra_hwid_devices": int(getattr(sub, "extra_hwid_devices", 0) or 0), "tariff_key": sub.tariff_key, + "display_label": display_label, + "is_trial": is_trial, "auto_renew_enabled": bool(sub.auto_renew_enabled), - "provider": sub.provider, + "provider": provider, "is_throttled": bool(sub.is_throttled), } diff --git a/backend/bot/app/web/admin_api_impl/routes.py b/backend/bot/app/web/admin_api_impl/routes.py index 6b0a10c..d9dd43a 100644 --- a/backend/bot/app/web/admin_api_impl/routes.py +++ b/backend/bot/app/web/admin_api_impl/routes.py @@ -30,6 +30,10 @@ def setup_admin_routes(app: web.Application) -> None: "/api/admin/users/{user_id:-?\\d+}/regular-traffic-override", admin_user_regular_traffic_override_route, ) + router.add_post( + "/api/admin/users/{user_id:-?\\d+}/hwid-device-limit", + admin_user_hwid_device_limit_route, + ) router.add_post( "/api/admin/users/{user_id:-?\\d+}/traffic-grant", admin_user_traffic_grant_route, @@ -57,6 +61,7 @@ def setup_admin_routes(app: web.Application) -> None: router.add_post("/api/admin/support/tickets/{id:\\d+}/read", admin_support_ticket_read_route) router.add_get("/api/admin/support/stats", admin_support_stats_route) + router.add_get("/api/admin/broadcast/audience-counts", admin_broadcast_audience_counts_route) router.add_post("/api/admin/broadcast", admin_broadcast_route) router.add_post("/api/admin/sync", admin_sync_route) diff --git a/backend/bot/app/web/admin_api_impl/themes.py b/backend/bot/app/web/admin_api_impl/themes.py index 21efbe4..b68ed08 100644 --- a/backend/bot/app/web/admin_api_impl/themes.py +++ b/backend/bot/app/web/admin_api_impl/themes.py @@ -136,10 +136,6 @@ def _favicon_digest(url: str) -> Optional[str]: return match.group(1) if match else None -def _emoji_to_codepoints(value: str) -> str: - return "_".join(f"{ord(char):x}" for char in str(value or "").strip()) - - def prune_unused_appearance_assets(settings: Settings) -> None: keep_logos = { filename @@ -156,15 +152,6 @@ def prune_unused_appearance_assets(settings: Settings) -> None: ] if digest } - keep_emoji_prefixes = set() - if ( - getattr(settings, "WEBAPP_LOGO_USE_EMOJI", False) - and str(getattr(settings, "WEBAPP_LOGO_EMOJI_FONT", "") or "").strip() - == "noto-color-animated" - ): - codepoints = _emoji_to_codepoints(getattr(settings, "WEBAPP_LOGO_EMOJI", "")) - if codepoints: - keep_emoji_prefixes.add(f"{codepoints}.512.") for path in WEBAPP_UPLOADED_LOGO_DIR.glob("logo-*"): if path.is_file() and path.name not in keep_logos: @@ -184,10 +171,9 @@ def prune_unused_appearance_assets(settings: Settings) -> None: except OSError: logger.warning("Failed to remove unused webapp favicon set %s", path, exc_info=True) + # Emoji logos were removed; purge any leftover animated-emoji cache files. for path in WEBAPP_EMOJI_CACHE_DIR.glob("*.512.*"): - if path.is_file() and not any( - path.name.startswith(prefix) for prefix in keep_emoji_prefixes - ): + if path.is_file(): try: path.unlink() except OSError: @@ -389,7 +375,6 @@ async def admin_appearance_logo_upload_route(request: web.Request) -> web.Respon request, { "WEBAPP_LOGO_URL": logo_url, - "WEBAPP_LOGO_USE_EMOJI": False, **( {"WEBAPP_LOGO_FAVICON_URL": favicon_payload["favicon_url"]} if favicon_payload.get("favicon_url") diff --git a/backend/bot/app/web/admin_api_impl/users.py b/backend/bot/app/web/admin_api_impl/users.py index 31587c4..c6a061d 100644 --- a/backend/bot/app/web/admin_api_impl/users.py +++ b/backend/bot/app/web/admin_api_impl/users.py @@ -745,6 +745,24 @@ def _user_search_condition(query: str): return or_(*conditions) +def _serialize_trial_summary(user: User, trial_subs: List[Subscription]) -> Dict[str, Any]: + first_trial_sub = trial_subs[0] if trial_subs else None + latest_trial_sub = trial_subs[-1] if trial_subs else None + first_start = getattr(first_trial_sub, "start_date", None) + latest_start = getattr(latest_trial_sub, "start_date", None) + latest_end = getattr(latest_trial_sub, "end_date", None) + reset_at = getattr(user, "trial_eligibility_reset_at", None) + return { + "used": bool(trial_subs), + "count": len(trial_subs), + "first_activated_at": first_start.isoformat() if first_start else None, + "latest_activated_at": latest_start.isoformat() if latest_start else None, + "latest_end_date": latest_end.isoformat() if latest_end else None, + "active": bool(latest_trial_sub and getattr(latest_trial_sub, "is_active", False)), + "last_reset_at": reset_at.isoformat() if reset_at else None, + } + + async def admin_user_detail_route(request: web.Request) -> web.Response: _require_admin_user_id(request) target_id = int(request.match_info["user_id"]) @@ -764,6 +782,15 @@ async def admin_user_detail_route(request: web.Request) -> web.Response: .limit(20) ) latest_subs = (await session.execute(latest_subs_stmt)).scalars().all() + trial_subs_stmt = ( + select(Subscription) + .where( + Subscription.user_id == target_id, + sa_func.lower(sa_func.coalesce(Subscription.provider, "")) == "trial", + ) + .order_by(Subscription.start_date.asc().nullslast(), Subscription.end_date.asc()) + ) + trial_subs = (await session.execute(trial_subs_stmt)).scalars().all() total_paid = await payment_dal.get_user_total_paid(session, target_id) recent_payments_stmt = ( select(Payment) @@ -830,12 +857,14 @@ async def admin_user_detail_route(request: web.Request) -> web.Response: serialized_inviter = ( _serialize_admin_user_with_avatar(inviter, avatar_keys) if inviter is not None else None ) + trial_payload = _serialize_trial_summary(user, trial_subs) return _ok( { "user": serialized_user, "active_subscription": _serialize_subscription(active_sub) if active_sub else None, "subscriptions": [_serialize_subscription(s) for s in (latest_subs or [])], + "trial": trial_payload, "total_paid": float(total_paid), "recent_payments": [_serialize_payment(p) for p in recent_payments], "log_count": int(log_count or 0), @@ -1318,6 +1347,78 @@ async def admin_user_regular_traffic_override_route(request: web.Request) -> web return _ok({"subscription": _serialize_subscription(active)}) +async def admin_user_hwid_device_limit_route(request: web.Request) -> web.Response: + """Override the user's base HWID device limit. + + ``hwid_device_limit == 0`` means unlimited; ``NULL`` means the tariff/.env + default is used. Purchased extra devices remain tracked separately and are + added when syncing the effective panel limit. + """ + actor_id = _require_admin_user_id(request) + target_id = int(request.match_info["user_id"]) + settings: Settings = request.app["settings"] + payload = await _read_json(request) + + unlimited = bool(payload.get("unlimited")) + use_default = bool(payload.get("use_default") or payload.get("reset_to_default")) + limit_raw = payload.get("hwid_device_limit", payload.get("limit")) + + if unlimited: + hwid_device_limit: Optional[int] = 0 + elif use_default or limit_raw is None or limit_raw == "": + hwid_device_limit = None + else: + try: + hwid_device_limit = int(limit_raw) + except (TypeError, ValueError): + return _error( + 400, + "invalid_hwid_device_limit", + "hwid_device_limit must be a non-negative integer", + ) + if hwid_device_limit < 0 or hwid_device_limit > 1_000_000: + return _error( + 400, + "invalid_hwid_device_limit", + "hwid_device_limit must be an integer from 0 to 1000000", + ) + + subscription_service = request.app.get("subscription_service") + + async_session_factory: sessionmaker = request.app["async_session_factory"] + async with async_session_factory() as session: + active = await subscription_dal.get_active_subscription_by_user_id(session, target_id) + if not active: + return _error(404, "no_active_subscription") + + active.hwid_device_limit = hwid_device_limit + + effective_limit = None + if subscription_service is not None: + effective_limit = await subscription_service.sync_hwid_device_limit_to_panel( + session, target_id + ) + + await message_log_dal.create_message_log( + session, + { + "user_id": actor_id, + "event_type": "admin_hwid_device_limit_webapp", + "content": ( + f"hwid_device_limit={hwid_device_limit!r} " + f"effective_hwid_device_limit={effective_limit!r}" + ), + "is_admin_event": True, + "target_user_id": target_id, + }, + ) + await session.commit() + await session.refresh(active) + + await _invalidate_after_admin_user_mutation(settings, target_id) + return _ok({"subscription": _serialize_subscription(active)}) + + async def admin_user_traffic_grant_route(request: web.Request) -> web.Response: """Credit regular or premium traffic to a user without a payment. @@ -1414,6 +1515,8 @@ async def admin_user_extend_route(request: web.Request) -> web.Response: return _error(400, "invalid_days") if days <= 0: return _error(400, "invalid_days") + extend_hwid_devices = payload.get("extend_hwid_devices") + extend_hwid_devices = True if extend_hwid_devices is None else bool(extend_hwid_devices) subscription_service = request.app.get("subscription_service") if subscription_service is None: @@ -1426,6 +1529,7 @@ async def admin_user_extend_route(request: web.Request) -> web.Response: target_id, days, "admin_extend_subscription_webapp", + extend_hwid_devices=extend_hwid_devices, ) if not new_end: await session.rollback() @@ -1436,7 +1540,10 @@ async def admin_user_extend_route(request: web.Request) -> web.Response: { "user_id": actor_id, "event_type": "admin_extend_subscription_webapp", - "content": f"+{days}d -> {new_end.isoformat()}", + "content": ( + f"+{days}d -> {new_end.isoformat()} " + f"(hwid={'yes' if extend_hwid_devices else 'no'})" + ), "is_admin_event": True, "target_user_id": target_id, }, diff --git a/backend/bot/app/web/admin_api_impl/webapp_runtime.py b/backend/bot/app/web/admin_api_impl/webapp_runtime.py index 83a8f57..00d98e3 100644 --- a/backend/bot/app/web/admin_api_impl/webapp_runtime.py +++ b/backend/bot/app/web/admin_api_impl/webapp_runtime.py @@ -13,9 +13,6 @@ WEBAPP_APPEARANCE_SETTING_KEYS = frozenset( { "WEBAPP_TITLE", "WEBAPP_LOGO_URL", - "WEBAPP_LOGO_USE_EMOJI", - "WEBAPP_LOGO_EMOJI", - "WEBAPP_LOGO_EMOJI_FONT", "WEBAPP_FAVICON_URL", "WEBAPP_FAVICON_USE_CUSTOM", "WEBAPP_LOGO_FAVICON_URL", diff --git a/backend/bot/app/web/admin_settings_manifest.py b/backend/bot/app/web/admin_settings_manifest.py index 9c86789..a4c4c57 100644 --- a/backend/bot/app/web/admin_settings_manifest.py +++ b/backend/bot/app/web/admin_settings_manifest.py @@ -65,7 +65,6 @@ SETTINGS_MANIFEST: List[SettingField] = [ "SUPPORT_LINK", "url", "general", "Ссылка поддержки", "Куда вести пользователей за помощью." ), SettingField("SERVER_STATUS_URL", "url", "general", "Ссылка на статус серверов"), - SettingField("TERMS_OF_SERVICE_URL", "url", "general", "Условия использования"), SettingField("PRIVACY_POLICY_URL", "url", "general", "Политика конфиденциальности"), SettingField("USER_AGREEMENT_URL", "url", "general", "Пользовательское соглашение"), SettingField("DISABLE_WELCOME_MESSAGE", "bool", "general", "Скрыть приветствие /start"), @@ -77,14 +76,20 @@ SETTINGS_MANIFEST: List[SettingField] = [ "int", "general", "ID обязательного канала", - "Telegram ID канала, в котором нужно состоять.", + ( + "Telegram ID канала для проверки подписки. Если бот видит канал, " + "ссылка кнопки будет получена автоматически." + ), ), SettingField( "REQUIRED_CHANNEL_LINK", "string", "general", "Ссылка на канал", - "Имя пользователя или invite-link.", + ( + "Необязательно: публичный @username или invite-link, " + "если ссылку нельзя получить по ID канала." + ), ), SettingField( "PANEL_API_URL", @@ -101,6 +106,42 @@ SETTINGS_MANIFEST: List[SettingField] = [ "Секретный ключ API панели.", secret=True, ), + SettingField( + "PANEL_API_TOTAL_TIMEOUT_SECONDS", + "float", + "remnawave", + "Panel API total timeout", + "Maximum total time for one Remnawave API request, in seconds.", + optional=False, + min=1, + ), + SettingField( + "PANEL_API_CONNECT_TIMEOUT_SECONDS", + "float", + "remnawave", + "Panel API connect timeout", + "Maximum time to get or open a Remnawave API connection, in seconds.", + optional=False, + min=1, + ), + SettingField( + "PANEL_API_SOCK_CONNECT_TIMEOUT_SECONDS", + "float", + "remnawave", + "Panel API socket connect timeout", + "Maximum TCP/TLS connection time for Remnawave API, in seconds.", + optional=False, + min=1, + ), + SettingField( + "PANEL_API_SOCK_READ_TIMEOUT_SECONDS", + "float", + "remnawave", + "Panel API socket read timeout", + "Maximum time to wait for response data from Remnawave API, in seconds.", + optional=False, + min=1, + ), SettingField( "PANEL_WEBHOOK_SECRET", "string", @@ -139,27 +180,7 @@ SETTINGS_MANIFEST: List[SettingField] = [ SettingField( "WEBAPP_PRIMARY_COLOR", "color", "appearance", "Основной цвет", placeholder="#00fe7a" ), - SettingField("WEBAPP_LOGO_USE_EMOJI", "bool", "appearance", "Использовать эмоджи-логотип"), SettingField("WEBAPP_LOGO_URL", "url", "appearance", "URL логотипа"), - SettingField("WEBAPP_LOGO_EMOJI", "string", "appearance", "Эмоджи-логотип", placeholder="🫥"), - SettingField( - "WEBAPP_LOGO_EMOJI_FONT", - "string", - "appearance", - "Шрифт эмоджи-логотипа", - "Выберите шрифт для отображения эмодзи-логотипа", - choices=( - ("system", "Системный (по умолчанию)"), - ("noto-color", "Noto Color Emoji"), - ("noto-color-animated", "Noto Color Emoji Animated"), - ("noto-emoji", "Noto Emoji"), - ("twemoji", "Twitter Emoji"), - ("openmoji", "OpenMoji"), - ("apple", "Apple Color Emoji (local)"), - ("segoe", "Segoe UI Emoji (local)"), - ("noto-local", "Noto Emoji (local)"), - ), - ), SettingField( "WEBAPP_FAVICON_USE_CUSTOM", "bool", @@ -348,7 +369,7 @@ SETTINGS_MANIFEST: List[SettingField] = [ "string", "payments", "Порядок методов оплаты", - "Через запятую: severpay,freekassa,yookassa,platega,stars,cryptopay,heleket", + "Через запятую: severpay,freekassa,yookassa,platega,stars,cryptopay,heleket,paykilla", subsection="common", ), # ─── Trial ───────────────────────────────────────────────────── @@ -386,6 +407,18 @@ SETTINGS_MANIFEST: List[SettingField] = [ optional=False, subsection="trial", ), + SettingField( + "TRIAL_WITHOUT_TELEGRAM_ENABLED", + "bool", + "pricing", + "Триал без Telegram", + ( + "Если выключено, email-only пользователю нужно привязать Telegram для " + "активации триала. Disposable email домены всегда требуют Telegram." + ), + optional=False, + subsection="trial", + ), SettingField( "TRIAL_SQUAD_UUIDS", "string", @@ -396,12 +429,82 @@ SETTINGS_MANIFEST: List[SettingField] = [ ), # ─── Referral program ────────────────────────────────────────── SettingField( - "REFERRAL_ONE_BONUS_PER_REFEREE", "bool", "referral", "Один бонус на приглашённого" + "REFERRAL_ONE_BONUS_PER_REFEREE", + "bool", + "pricing", + "Один бонус на приглашённого", + subsection="referral", ), SettingField( - "REFERRAL_WELCOME_BONUS_DAYS", "int", "referral", "Приветственный бонус (дней)", min=0 + "REFERRAL_WELCOME_BONUS_DAYS", + "int", + "pricing", + "Приветственный бонус (дней)", + min=0, + subsection="referral", + ), + SettingField( + "REFERRAL_WELCOME_BONUS_WITHOUT_TELEGRAM_ENABLED", + "bool", + "pricing", + "Приветственный бонус без Telegram", + ( + "Если выключено, email-only пользователю нужно привязать Telegram для получения " + "реферального приветственного бонуса. Disposable email домены всегда требуют Telegram." + ), + subsection="referral", + ), + SettingField( + "LEGACY_REFS", + "bool", + "pricing", + "Поддержка старых ref-ссылок", + subsection="referral", + ), + SettingField( + "DISPOSABLE_EMAIL_DOMAINS", + "text", + "pricing", + "Disposable email домены", + ( + "Домены по одному на строку или через запятую. Пользователи без Telegram с такими " + "email не смогут получить trial или реферальный приветственный бонус." + ), + placeholder="mailinator.com\ntemp-mail.org\nyopmail.com", + subsection="referral", + ), + SettingField( + "MIGRATION_REMNASHOP_REFERRAL_CODE_COMPAT_ENABLED", + "bool", + "migrations", + "Старые ref-ссылки Remnashop", + "Принимать импортированные ref-коды Remnashop вместе с текущими кодами пользователей.", + subsection="Remnashop", + ), + SettingField( + "MIGRATION_REMNASHOP_PROMO_CODE_COMPAT_ENABLED", + "bool", + "migrations", + "Старые промокоды Remnashop", + "Пробовать точное совпадение промокода перед обычной uppercase-нормализацией.", + subsection="Remnashop", + ), + SettingField( + "MIGRATION_REMNASHOP_IMPORTED_AT", + "string", + "migrations", + "Последний импорт Remnashop", + "Заполняется скриптом импорта. Можно очистить, если отметка больше не нужна.", + subsection="Remnashop", + ), + SettingField( + "MIGRATION_REMNASHOP_NOTES", + "text", + "migrations", + "Заметки по миграции Remnashop", + "Внутренние заметки оператора по перенесенному инстансу.", + subsection="Remnashop", ), - SettingField("LEGACY_REFS", "bool", "referral", "Поддержка старых ref-ссылок"), # ─── Notifications ───────────────────────────────────────────── SettingField( "SUBSCRIPTION_NOTIFICATIONS_ENABLED", @@ -734,6 +837,7 @@ def manifest_payload() -> List[dict]: "devices": 10, "subscription_guides": 10, "system": 12, + "migrations": 13, } exclusive_map = { key: opposite diff --git a/backend/bot/app/web/themes/ascii/style.css b/backend/bot/app/web/themes/ascii/style.css index e0a6c7d..b96b94d 100644 --- a/backend/bot/app/web/themes/ascii/style.css +++ b/backend/bot/app/web/themes/ascii/style.css @@ -318,6 +318,81 @@ box-shadow: inset 0 0 0 1px #ffffff; } +/* ---------- Admin controls: range sliders and sortable rows ---------- */ + +.theme-key-ascii .ui-range-input { + height: 20px; +} + +.theme-key-ascii .ui-range-input::before { + height: 8px; + border: 1px solid #ffffff; + background: #000000; +} + +.theme-key-ascii .ui-range-input__range { + height: 8px; + background: #ffffff; +} + +.theme-key-ascii .ui-range-input__thumb { + width: 16px; + height: 18px; + border: 1px solid #ffffff; + border-radius: 0; + background: #000000; + box-shadow: none; + transition: none; +} + +.theme-key-ascii .ui-range-input__thumb:hover, +.theme-key-ascii .ui-range-input__thumb:focus-visible { + background: #ffffff; + color: #000000; + box-shadow: 0 0 0 1px #000000; +} + +.theme-key-ascii .ui-sortable { + --sortable-drop-line: #ffffff; + --sortable-drop-soft: rgba(255, 255, 255, 0.08); + gap: 6px; +} + +.theme-key-ascii .ui-sortable-item.is-dragging { + opacity: 0.72; +} + +.theme-key-ascii .ui-sortable-item.is-drop-target { + outline: 1px dashed #ffffff; + outline-offset: 2px; + background: rgba(255, 255, 255, 0.08); + box-shadow: none; +} + +.theme-key-ascii .ui-sortable-item.is-drop-target::before { + top: -5px; + height: 1px; + border-radius: 0; + background: #ffffff; + box-shadow: none; +} + +.theme-key-ascii .ui-sortable-handle { + align-self: center; + height: 28px; + border: 1px solid #ffffff; + background: #000000; + color: #ffffff; + box-shadow: none; +} + +.theme-key-ascii .ui-sortable-handle:hover, +.theme-key-ascii .ui-sortable-handle:focus-visible, +.theme-key-ascii .ui-sortable-handle:active { + background: #ffffff; + color: #000000; +} + /* ---------- New webapp surfaces: support, purchase info, password login ---------- */ .theme-key-ascii .trial-offer-card, @@ -1104,6 +1179,7 @@ body:has(.theme-key-ascii) .install-platform-item[data-selected] svg { .admin-cn-card-skeleton--tall, .admin-input, .admin-textarea, .admin-btn, .admin-chip, .admin-tabs-trigger, .admin-tabs-list, + .ui-range-input__thumb, .ui-sortable-item, .ui-sortable-handle, .admin-nav-item, .admin-revenue-period-btn, .admin-mobile-toggle, .admin-header, .admin-sidebar, .admin-sidebar-brand, .admin-dialog, @@ -1243,3 +1319,51 @@ body:has(.theme-key-ascii) .install-platform-item[data-selected] svg { .theme-key-ascii table tbody tr:hover td { color: #ffffff; } + +/* ============================================================ + * Newer webapp surfaces: telegram banner, traffic/referral + * dropdowns, login language picker. Flatten the accent pills, + * rounded badges and colored gradients these ship with so they + * read as plain console boxes. + * ============================================================ */ + +/* Telegram notifications banner: the .card chrome is already + * flattened above; only the rounded, color-tinted icon badge needs + * squaring off (the Send glyph itself is whitened by the global rule). */ +.theme-key-ascii .telegram-notifications-icon { + border: 1px solid #ffffff; + border-radius: 0; + background: #000000; + color: #ffffff; +} + +/* Premium-server / referral-tariff dropdown help glyph: drop the + * pill background in every state (the accent maps to white here, + * which would otherwise paint a white blob behind the icon). */ +.theme-key-ascii .premium-server-help-icon, +.theme-key-ascii .premium-server-dropdown summary:hover .premium-server-help-icon, +.theme-key-ascii .premium-server-dropdown[open] .premium-server-help-icon, +.theme-key-ascii .referral-tariff-dropdown summary:hover .premium-server-help-icon, +.theme-key-ascii .referral-tariff-dropdown[open] .premium-server-help-icon { + padding: 0; + border-radius: 0; + background: transparent; + color: #ffffff; +} + +/* The check on the selected language sits on a solid white row, so a + * white glyph would vanish — invert it to black to keep it readable. */ +.theme-key-ascii .language-select-item[data-selected] .language-select-item-check { + color: #000000 !important; + stroke: #000000 !important; +} + +/* Login-screen language trigger: square the rounded chip. */ +.theme-key-ascii .auth-language-trigger { + border-radius: 0; +} + +/* Render flag emoji as monochrome glyphs to stay in the console palette. */ +.theme-key-ascii .emoji-flag { + filter: grayscale(1) contrast(1.05); +} diff --git a/backend/bot/app/web/themes/ascii/theme.json b/backend/bot/app/web/themes/ascii/theme.json index 6880a94..9ab9e61 100644 --- a/backend/bot/app/web/themes/ascii/theme.json +++ b/backend/bot/app/web/themes/ascii/theme.json @@ -9,7 +9,7 @@ "use_primary_accent": false, "use_in_admin": true, "css_file": "style.css", - "assets_version": 4, + "assets_version": 6, "tokens": { "color_scheme": "dark", "style_preset": "ascii" diff --git a/backend/bot/app/web/themes/light/style.css b/backend/bot/app/web/themes/light/style.css index 8a7fa2f..d78fa3b 100644 --- a/backend/bot/app/web/themes/light/style.css +++ b/backend/bot/app/web/themes/light/style.css @@ -204,3 +204,56 @@ body:has(.theme-key-light) .install-platform-item[data-selected] { .theme-key-light .install-loading .ui-spinner { color: color-mix(in srgb, var(--accent) 55%, #000000); } + +/* Admin controls: range sliders and sortable rows */ + +.theme-key-light .ui-range-input::before { + background: rgba(15, 23, 42, 0.12); +} + +.theme-key-light .ui-range-input__range { + background: color-mix(in srgb, var(--accent) 70%, #0f172a); +} + +.theme-key-light .ui-range-input__thumb { + border-color: color-mix(in srgb, var(--accent) 68%, #0f172a); + background: #ffffff; + box-shadow: 0 2px 8px rgba(15, 23, 42, 0.18); +} + +.theme-key-light .ui-range-input__thumb:focus-visible { + box-shadow: 0 0 0 4px color-mix(in srgb, var(--accent) 18%, transparent); +} + +.theme-key-light .ui-sortable-handle { + border-radius: 6px; + color: color-mix(in srgb, var(--admin-muted) 82%, var(--admin-text)); +} + +.theme-key-light .ui-sortable-handle:hover, +.theme-key-light .ui-sortable-handle:focus-visible { + background: rgba(15, 23, 42, 0.055); + color: color-mix(in srgb, var(--accent) 58%, #0f172a); +} + +.theme-key-light .ui-sortable { + --sortable-drop-line: color-mix(in srgb, var(--accent) 64%, #0f172a); +} + +.theme-key-light .ui-sortable-item.is-drop-target { + background: color-mix(in srgb, var(--accent) 8%, #ffffff); + box-shadow: + inset 0 0 0 1px color-mix(in srgb, var(--accent) 26%, transparent), + 0 10px 22px color-mix(in srgb, var(--accent) 7%, transparent); +} + +.theme-key-light .ui-sortable-item.is-drop-target::before { + background: var(--sortable-drop-line); + box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 14%, transparent); +} + +/* Telegram notifications banner: keep the warm warning tint but swap the + * dark-theme inset bevel for the soft drop shadow the other light cards use. */ +.theme-key-light .telegram-notifications-card { + box-shadow: 0 8px 20px rgba(15, 23, 42, 0.07); +} diff --git a/backend/bot/app/web/themes/light/theme.json b/backend/bot/app/web/themes/light/theme.json index 175060f..a501c88 100644 --- a/backend/bot/app/web/themes/light/theme.json +++ b/backend/bot/app/web/themes/light/theme.json @@ -9,7 +9,7 @@ "use_primary_accent": true, "use_in_admin": true, "css_file": "style.css", - "assets_version": 3, + "assets_version": 5, "tokens": { "color_scheme": "light" } diff --git a/backend/bot/app/web/themes/windows95/style.css b/backend/bot/app/web/themes/windows95/style.css index abd98e4..0d3ad91 100644 --- a/backend/bot/app/web/themes/windows95/style.css +++ b/backend/bot/app/web/themes/windows95/style.css @@ -127,6 +127,7 @@ .theme-key-windows95 svg.lucide-file-text, .theme-key-windows95 svg.lucide-gift, .theme-key-windows95 svg.lucide-globe-2, +.theme-key-windows95 svg.lucide-grip-vertical, .theme-key-windows95 svg.lucide-home, .theme-key-windows95 svg.lucide-house, .theme-key-windows95 svg.lucide-info, @@ -232,6 +233,10 @@ --win95-button-icon: var(--win95-icon-globe); } +.theme-key-windows95 svg.lucide-grip-vertical { + --win95-button-icon: var(--win95-icon-sliders); +} + .theme-key-windows95 svg.lucide-file-text { --win95-button-icon: var(--win95-icon-file-text); } @@ -375,6 +380,7 @@ svg.lucide-file-text, svg.lucide-gift, svg.lucide-globe-2, + svg.lucide-grip-vertical, svg.lucide-home, svg.lucide-house, svg.lucide-info, @@ -1171,6 +1177,96 @@ body:has(.theme-key-windows95) .install-platform-item[data-selected] { opacity: 0.52; } +/* Admin controls: range sliders and sortable rows */ + +.theme-key-windows95 .ui-range-input { + height: 22px; +} + +.theme-key-windows95 .ui-range-input::before { + height: 8px; + border: 2px solid; + border-color: #404040 #ffffff #ffffff #404040; + border-radius: 0 !important; + background: #ffffff; + box-shadow: + inset 1px 1px 0 #808080, + inset -1px -1px 0 #dfdfdf; +} + +.theme-key-windows95 .ui-range-input__range { + height: 8px; + border-radius: 0 !important; + background: var(--accent); +} + +.theme-key-windows95 .ui-range-input__thumb { + width: 14px; + height: 20px; + border: 2px solid; + border-color: #ffffff #404040 #404040 #ffffff; + border-radius: 0 !important; + background: #c0c0c0; + box-shadow: + inset 1px 1px 0 #dfdfdf, + inset -1px -1px 0 #808080; + transition: none; +} + +.theme-key-windows95 .ui-range-input__thumb:hover, +.theme-key-windows95 .ui-range-input__thumb:focus-visible { + background: #dfdfdf; +} + +.theme-key-windows95 .ui-range-input__thumb[data-active] { + border-color: #404040 #ffffff #ffffff #404040; + box-shadow: + inset 1px 1px 0 #808080, + inset -1px -1px 0 #dfdfdf; +} + +.theme-key-windows95 .ui-sortable-item.is-drop-target { + outline: 1px dotted #000000; + outline-offset: 3px; + background: color-mix(in srgb, var(--accent) 12%, var(--admin-surface)); +} + +.theme-key-windows95 .ui-sortable-item.is-drop-target::before { + top: -7px; + height: 2px; + border-radius: 0; + background: #000080; + box-shadow: + 0 1px 0 #ffffff, + 0 -1px 0 #000000; +} + +.theme-key-windows95 .ui-sortable-handle { + align-self: center; + width: 24px; + height: 28px; + border: 2px solid; + border-color: #ffffff #404040 #404040 #ffffff; + background: #c0c0c0; + color: #000000; + box-shadow: + inset 1px 1px 0 #dfdfdf, + inset -1px -1px 0 #808080; + transition: none; +} + +.theme-key-windows95 .ui-sortable-handle:hover, +.theme-key-windows95 .ui-sortable-handle:focus-visible { + background: #dfdfdf; +} + +.theme-key-windows95 .ui-sortable-handle:active { + border-color: #404040 #ffffff #ffffff #404040; + box-shadow: + inset 1px 1px 0 #808080, + inset -1px -1px 0 #dfdfdf; +} + .theme-key-windows95 input::placeholder, .theme-key-windows95 textarea::placeholder, .theme-key-windows95 .input::placeholder, @@ -1352,3 +1448,64 @@ body:has(.theme-key-windows95) .install-platform-item[data-selected] { .theme-key-windows95 a:not(.btn):not(.bottom-nav button):not([class*="-trigger"]):visited { color: #800080; } + +/* ---------- Newer webapp surfaces: telegram banner, traffic / + * referral dropdowns, login language picker ---------- */ + +/* Telegram notifications banner: the Card chrome is already beveled by + * the shared .card rule; give the icon badge a raised chip look instead + * of the rounded, color-tinted default (the Send glyph maps to send.png). */ +.theme-key-windows95 .telegram-notifications-icon { + border-width: 2px; + border-style: solid; + border-color: #ffffff #404040 #404040 #ffffff; + border-radius: 0; + background: var(--panel); + color: var(--text); + box-shadow: + inset 1px 1px 0 #dfdfdf, + inset -1px -1px 0 #808080; +} + +/* Standalone referral-tariff dropdown and bonus rows: bevel them like the + * rest of the surfaces so they don't read as flat 1px boxes. */ +.theme-key-windows95 .referral-tariff-dropdown, +.theme-key-windows95 .referral-bonus-row { + border-width: 2px; + border-style: solid; + border-color: #ffffff #404040 #404040 #ffffff; + border-radius: 0; + background: var(--panel); + box-shadow: + inset 1px 1px 0 #dfdfdf, + inset -1px -1px 0 #808080; +} + +.theme-key-windows95 .referral-bonus-row-nested { + background: #dfdfdf; +} + +/* Premium-server / referral help glyph: drop the rounded accent pill so it + * sits inline as a plain stroked question mark. */ +.theme-key-windows95 .premium-server-help-icon, +.theme-key-windows95 .premium-server-dropdown summary:hover .premium-server-help-icon, +.theme-key-windows95 .premium-server-dropdown[open] .premium-server-help-icon, +.theme-key-windows95 .referral-tariff-dropdown summary:hover .premium-server-help-icon, +.theme-key-windows95 .referral-tariff-dropdown[open] .premium-server-help-icon { + padding: 0; + border-radius: 0; + background: transparent; + color: var(--text); +} + +/* The selected language row turns navy; its check maps to a dark bitmap, + * so invert it to white to keep it visible. */ +.theme-key-windows95 .language-select-item[data-highlighted] .language-select-item-check, +.theme-key-windows95 .language-select-item[data-selected] .language-select-item-check { + filter: brightness(0) invert(1); +} + +/* Login-screen language trigger: square the rounded chip. */ +.theme-key-windows95 .auth-language-trigger { + border-radius: 0; +} diff --git a/backend/bot/app/web/themes/windows95/theme.json b/backend/bot/app/web/themes/windows95/theme.json index ab765c2..aea131f 100644 --- a/backend/bot/app/web/themes/windows95/theme.json +++ b/backend/bot/app/web/themes/windows95/theme.json @@ -9,7 +9,7 @@ "use_primary_accent": false, "use_in_admin": true, "css_file": "style.css", - "assets_version": 11, + "assets_version": 13, "tokens": { "color_scheme": "light", "style_preset": "win95" diff --git a/backend/bot/app/web/webapp/_runtime.py b/backend/bot/app/web/webapp/_runtime.py index 4da0199..3e6a9f2 100644 --- a/backend/bot/app/web/webapp/_runtime.py +++ b/backend/bot/app/web/webapp/_runtime.py @@ -42,7 +42,7 @@ from bot.app.web.webapp_auth import ( verify_webapp_session_token, ) from bot.infra.redis import cache_delete, cache_get_json, cache_set_json, get_redis, redis_key -from bot.services.email_auth_service import EmailAuthService, normalize_email +from bot.services.email_auth_service import EmailAuthService, is_disposable_email, normalize_email from bot.services.email_templates import render_account_merged from bot.services.promo_code_service import PromoCodeService from bot.services.referral_service import ReferralService @@ -82,7 +82,6 @@ WEBAPP_DEFAULT_LOGO_PATH = "/webapp-default-logo.webp" WEBAPP_DEFAULT_FAVICON_DIGEST = "19b2a242e5b7bc2d" WEBAPP_DEFAULT_FAVICON_DIR = WEBAPP_DEFAULT_BRAND_DIR / "favicons" / WEBAPP_DEFAULT_FAVICON_DIGEST WEBAPP_DEFAULT_FAVICON_URL = f"{WEBAPP_FAVICON_PATH}/{WEBAPP_DEFAULT_FAVICON_DIGEST}/icon-180.png" -WEBAPP_EMOJI_CACHE_DIR = APP_ROOT / "data" / "webapp-emoji" WEBAPP_CONFIG_PLACEHOLDER = "" WEBAPP_I18N_PLACEHOLDER = "" WEBAPP_JS_PLACEHOLDER = "" @@ -92,7 +91,6 @@ DEV_MOCK_END_MARKER = "" WEBAPP_RATE_LIMIT_WINDOW_SECONDS = 60 WEBAPP_RATE_LIMIT_MAX_REQUESTS = 30 WEBAPP_LOGO_MAX_BYTES = 2 * 1024 * 1024 -WEBAPP_EMOJI_MAX_BYTES = 4 * 1024 * 1024 WEBAPP_THEME_CSS_MAX_BYTES = 512 * 1024 WEBAPP_THEME_ASSET_MAX_BYTES = 1024 * 1024 WEBAPP_THEME_ASSET_CONTENT_TYPES = { diff --git a/backend/bot/app/web/webapp/application.py b/backend/bot/app/web/webapp/application.py index 3dce8e2..a5ead66 100644 --- a/backend/bot/app/web/webapp/application.py +++ b/backend/bot/app/web/webapp/application.py @@ -33,7 +33,6 @@ def create_subscription_webapp_application( async def _startup(app_obj: web.Application) -> None: await _ensure_shared_http_session() await _warm_webapp_logo_cache(app_obj) - await _warm_webapp_animated_emoji_cache(app_obj) await warm_subscription_guides_config(app_obj) async def _shutdown(app_obj: web.Application) -> None: diff --git a/backend/bot/app/web/webapp/assets.py b/backend/bot/app/web/webapp/assets.py index cd53216..dec98bd 100644 --- a/backend/bot/app/web/webapp/assets.py +++ b/backend/bot/app/web/webapp/assets.py @@ -206,9 +206,6 @@ async def theme_asset_route(request: web.Request) -> web.Response: def _resolve_webapp_logo_url(settings: Settings) -> str: - if getattr(settings, "WEBAPP_LOGO_USE_EMOJI", False): - return "" - raw_logo_url = (getattr(settings, "WEBAPP_LOGO_URL", None) or "").strip() if not raw_logo_url: return WEBAPP_DEFAULT_LOGO_PATH @@ -303,29 +300,8 @@ def _uploaded_webapp_logo_response(filename: str) -> web.Response: return response -def _emoji_to_codepoints(value: str) -> str: - return "_".join(f"{ord(char):x}" for char in str(value or "").strip()) - - -def _webapp_emoji_disk_path(codepoints: str, ext: str) -> Path: - return WEBAPP_EMOJI_CACHE_DIR / f"{codepoints}.512.{ext}" - - -def _webapp_animated_emoji_source_url(codepoints: str, ext: str) -> str: - return f"https://fonts.gstatic.com/s/e/notoemoji/latest/{codepoints}/512.{ext}" - - -def _webapp_animated_emoji_asset_path(emoji: str, ext: str = "gif") -> str: - codepoints = _emoji_to_codepoints(emoji) - if not codepoints or ext not in {"gif", "webp"}: - return "" - return f"/webapp-emoji/{codepoints}/512.{ext}" - - async def webapp_logo_route(request: web.Request) -> web.Response: settings: Settings = request.app["settings"] - if getattr(settings, "WEBAPP_LOGO_USE_EMOJI", False): - raise web.HTTPNotFound(text="webapp_logo_disabled") raw_logo_url = (settings.WEBAPP_LOGO_URL or "").strip() if not raw_logo_url: raise web.HTTPNotFound(text="webapp_logo_not_configured") @@ -521,37 +497,8 @@ def _webapp_default_brand_file_response(path: Path, content_type: str) -> web.Re return web.Response(body=body, content_type=content_type) -async def webapp_animated_emoji_route(request: web.Request) -> web.Response: - codepoints = str(request.match_info.get("codepoints") or "").strip().lower() - ext = str(request.match_info.get("ext") or "").strip().lower() - if not re.fullmatch(r"[0-9a-f]+(?:_[0-9a-f]+)*", codepoints) or ext not in {"gif", "webp"}: - raise web.HTTPNotFound(text="webapp_emoji_not_found") - - emoji_cache_key = f"{codepoints}:{ext}" - emoji_caches: Dict[str, Tuple[bytes, str]] = request.app.setdefault("webapp_emoji_cache", {}) - emoji_cache = emoji_caches.get(emoji_cache_key) - if emoji_cache is None: - cache_lock: asyncio.Lock = request.app.setdefault("webapp_emoji_cache_lock", asyncio.Lock()) - async with cache_lock: - emoji_cache = emoji_caches.get(emoji_cache_key) - if emoji_cache is None: - emoji_cache = await _load_or_fetch_webapp_animated_emoji(codepoints, ext) - if emoji_cache: - emoji_caches[emoji_cache_key] = emoji_cache - - if not emoji_cache: - raise web.HTTPNotFound(text="webapp_emoji_unavailable") - - body, content_type = emoji_cache - response = web.Response(body=body, content_type=content_type) - response.headers["Cache-Control"] = "public, max-age=31536000, immutable" - return response - - async def _warm_webapp_logo_cache(app: web.Application) -> None: settings: Settings = app["settings"] - if getattr(settings, "WEBAPP_LOGO_USE_EMOJI", False): - return raw_logo_url = (settings.WEBAPP_LOGO_URL or "").strip() if not raw_logo_url or not _is_proxyable_webapp_logo_url(raw_logo_url): return @@ -573,111 +520,6 @@ async def _warm_webapp_logo_cache(app: web.Application) -> None: ) -async def _warm_webapp_animated_emoji_cache(app: web.Application) -> None: - settings: Settings = app["settings"] - if not getattr(settings, "WEBAPP_LOGO_USE_EMOJI", False): - return - if str(settings.WEBAPP_LOGO_EMOJI_FONT or "").strip() != "noto-color-animated": - return - - codepoints = _emoji_to_codepoints(settings.WEBAPP_LOGO_EMOJI) - if not codepoints: - return - - app.setdefault("webapp_emoji_cache", {}) - app.setdefault("webapp_emoji_cache_lock", asyncio.Lock()) - emoji_caches: Dict[str, Tuple[bytes, str]] = app["webapp_emoji_cache"] - - for ext in ("gif", "webp"): - emoji_cache_key = f"{codepoints}:{ext}" - if emoji_cache_key in emoji_caches: - continue - loaded_emoji = await _load_or_fetch_webapp_animated_emoji(codepoints, ext) - if loaded_emoji: - emoji_caches[emoji_cache_key] = loaded_emoji - if ext == "gif": - return - - -async def _load_or_fetch_webapp_animated_emoji( - codepoints: str, ext: str -) -> Optional[Tuple[bytes, str]]: - disk_emoji = await asyncio.to_thread(_read_webapp_animated_emoji_from_disk, codepoints, ext) - if disk_emoji: - return disk_emoji - - fetched_emoji = await _fetch_webapp_animated_emoji(codepoints, ext) - if fetched_emoji: - await asyncio.to_thread( - _write_webapp_animated_emoji_to_disk, codepoints, ext, fetched_emoji - ) - return fetched_emoji - - -def _read_webapp_animated_emoji_from_disk(codepoints: str, ext: str) -> Optional[Tuple[bytes, str]]: - path = _webapp_emoji_disk_path(codepoints, ext) - try: - body = path.read_bytes() - except OSError: - return None - - if not body or len(body) > WEBAPP_EMOJI_MAX_BYTES: - return None - return body, "image/gif" if ext == "gif" else "image/webp" - - -def _write_webapp_animated_emoji_to_disk( - codepoints: str, ext: str, emoji: Tuple[bytes, str] -) -> None: - body, _content_type = emoji - if not body or len(body) > WEBAPP_EMOJI_MAX_BYTES: - return - - path = _webapp_emoji_disk_path(codepoints, ext) - try: - WEBAPP_EMOJI_CACHE_DIR.mkdir(parents=True, exist_ok=True) - path.write_bytes(body) - except OSError as exc: - logger.warning("Failed to write WEBAPP animated emoji cache: %s", exc) - - -async def _fetch_webapp_animated_emoji(codepoints: str, ext: str) -> Optional[Tuple[bytes, str]]: - try: - session = await _get_shared_http_session() - timeout = ClientTimeout(total=4) - source_url = _webapp_animated_emoji_source_url(codepoints, ext) - async with session.get( - source_url, - allow_redirects=False, - headers={"Accept": "image/gif,image/webp,image/*,*/*;q=0.8"}, - timeout=timeout, - ) as response: - if response.status != 200: - return None - - content_type = ( - (response.headers.get("Content-Type") or "").split(";", 1)[0].strip().lower() - ) - expected_content_type = "image/gif" if ext == "gif" else "image/webp" - if content_type and content_type != expected_content_type: - return None - - body = bytearray() - async for chunk in response.content.iter_chunked(64 * 1024): - body.extend(chunk) - if len(body) > WEBAPP_EMOJI_MAX_BYTES: - logger.warning("WEBAPP animated emoji exceeded the 4 MiB limit.") - return None - - if not body: - return None - - return bytes(body), expected_content_type - except Exception as exc: - logger.warning("Failed to fetch WEBAPP animated emoji: %s", exc) - return None - - async def _load_or_fetch_webapp_logo(logo_url: str) -> Optional[Tuple[bytes, str]]: disk_logo = await asyncio.to_thread(_read_webapp_logo_from_disk, logo_url) if disk_logo: @@ -923,7 +765,6 @@ def _get_cached_webapp_settings(request: web.Request) -> Dict[str, Any]: "traffic_packages": settings.traffic_packages, "stars_traffic_packages": settings.stars_traffic_packages, "support_url": settings.SUPPORT_LINK or "", - "terms_url": settings.TERMS_OF_SERVICE_URL or "", "privacy_policy_url": settings.PRIVACY_POLICY_URL or "", "user_agreement_url": settings.USER_AGREEMENT_URL or "", "currency": settings.DEFAULT_CURRENCY_SYMBOL or "RUB", @@ -937,9 +778,31 @@ def _get_cached_webapp_settings(request: web.Request) -> Dict[str, Any]: def _resolve_app_version() -> str: # Single source of truth shared with the telemetry worker so the admin # sidebar and the install beacon always report the same version. - from bot.utils.app_version import resolve_app_version + from bot.utils import app_version as app_version_module - return resolve_app_version() + global _APP_VERSION_CACHE + + app_version_module.APP_ROOT = APP_ROOT + app_version_module._run_git_command = _run_git_command + app_version_module._APP_VERSION_CACHE = _APP_VERSION_CACHE + version = app_version_module.resolve_app_version() + _APP_VERSION_CACHE = app_version_module._APP_VERSION_CACHE + return version + + +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() async def _enforce_webapp_rate_limit( @@ -1144,9 +1007,6 @@ def _build_webapp_bootstrap_payload(request: web.Request) -> Dict[str, Any]: "themesDir": settings.WEBAPP_THEMES_DIR, "themePreviewKey": preview_key, "logoUrl": cached["logo_url"], - "logoUseEmoji": bool(settings.WEBAPP_LOGO_USE_EMOJI), - "logoEmoji": settings.WEBAPP_LOGO_EMOJI, - "logoEmojiFont": settings.WEBAPP_LOGO_EMOJI_FONT, "faviconUrl": cached["favicon_url"], "faviconUseCustom": bool(settings.WEBAPP_FAVICON_USE_CUSTOM), "apiBase": "/api", @@ -1157,7 +1017,6 @@ def _build_webapp_bootstrap_payload(request: web.Request) -> Dict[str, Any]: "telegramOAuthClientId": _resolve_telegram_oauth_client_id(settings) or 0, "telegramOAuthRequestAccess": _resolve_telegram_oauth_request_access(settings), "supportUrl": cached["support_url"], - "termsUrl": cached["terms_url"], "privacyPolicyUrl": cached["privacy_policy_url"], "userAgreementUrl": cached["user_agreement_url"], "currency": cached["currency"], @@ -1308,12 +1167,6 @@ async def index_route(request: web.Request) -> web.Response: f'', ) brand_asset_url = cached["logo_url"] - if ( - not brand_asset_url - and settings.WEBAPP_LOGO_USE_EMOJI - and settings.WEBAPP_LOGO_EMOJI_FONT == "noto-color-animated" - ): - brand_asset_url = _webapp_animated_emoji_asset_path(settings.WEBAPP_LOGO_EMOJI) if brand_asset_url: html = html.replace( "", @@ -1683,6 +1536,9 @@ _INITIAL_THEME_TOKEN_CSS_MAP = { "font_sans": "--font-sans", "font_logo": "--font-logo", "font_mono": "--font-mono", + "home_logo_scale": "--home-logo-scale", + "home_logo_scale_desktop": "--home-logo-scale-desktop", + "home_logo_scale_mobile": "--home-logo-scale-mobile", "admin_bg": "--admin-bg", "admin_surface": "--admin-surface", "admin_surface_2": "--admin-surface-2", @@ -1694,6 +1550,12 @@ _INITIAL_THEME_TOKEN_CSS_MAP = { "admin_dim": "--admin-dim", } +_INITIAL_THEME_LOGO_SCALE_TOKENS = { + "home_logo_scale", + "home_logo_scale_desktop", + "home_logo_scale_mobile", +} + def _theme_css_href_for_html(theme: Any) -> str: css_file = str(getattr(theme, "css_file", "") or "").strip() @@ -1737,6 +1599,14 @@ def _initial_theme_head_markup(request: web.Request, theme: Any, primary_color: tokens = tokens if isinstance(tokens, dict) else {} declarations = [] for token_key, css_name in _INITIAL_THEME_TOKEN_CSS_MAP.items(): + if token_key in _INITIAL_THEME_LOGO_SCALE_TOKENS: + try: + scale = float(tokens.get(token_key) or 0) + except (TypeError, ValueError): + continue + if scale > 0: + declarations.append(f"{css_name}:{scale / 100:g}") + continue value = str(tokens.get(token_key) or "").strip() if value: declarations.append(f"{css_name}:{value}") diff --git a/backend/bot/app/web/webapp/auth.py b/backend/bot/app/web/webapp/auth.py index f1bfbe5..88f6de4 100644 --- a/backend/bot/app/web/webapp/auth.py +++ b/backend/bot/app/web/webapp/auth.py @@ -713,6 +713,7 @@ async def email_auth_verify_route(request: web.Request) -> web.Response: session, referral_param, current_user_id=None, + settings=settings, ) db_user, _ = await user_dal.create_email_user( session, @@ -821,6 +822,7 @@ async def email_auth_magic_route(request: web.Request) -> web.Response: session, referral_param, current_user_id=None, + settings=settings, ) db_user, _ = await user_dal.create_email_user( session, @@ -1010,13 +1012,53 @@ async def _request_email_code( def _telegram_id_for_user(user: User) -> Optional[int]: - if user.telegram_id: - return int(user.telegram_id) - if user.user_id and int(user.user_id) > 0: - return int(user.user_id) + telegram_id = getattr(user, "telegram_id", None) + if telegram_id: + return int(telegram_id) + user_id = getattr(user, "user_id", None) + if user_id and int(user_id) > 0: + return int(user_id) return None +def _user_has_linked_telegram(user: User) -> bool: + return bool(getattr(user, "telegram_id", None)) + + +def _email_only_telegram_required_reason( + settings: Settings, + user: User, + *, + without_telegram_enabled_attr: str, +) -> Optional[str]: + if _user_has_linked_telegram(user): + return None + if is_disposable_email(getattr(user, "email", None), settings): + return "disposable_email" + if not bool(getattr(settings, without_telegram_enabled_attr, True)): + return "telegram_required" + return None + + +def _trial_telegram_required_reason(settings: Settings, user: User) -> Optional[str]: + return _email_only_telegram_required_reason( + settings, + user, + without_telegram_enabled_attr="TRIAL_WITHOUT_TELEGRAM_ENABLED", + ) + + +def _referral_welcome_telegram_required_reason( + settings: Settings, + user: User, +) -> Optional[str]: + return _email_only_telegram_required_reason( + settings, + user, + without_telegram_enabled_attr="REFERRAL_WELCOME_BONUS_WITHOUT_TELEGRAM_ENABLED", + ) + + def _panel_description_for_user(user: User) -> str: return panel_description_from_profile( user.username, @@ -1292,17 +1334,35 @@ async def _link_telegram_to_user( return current_user -def _normalize_referral_param(raw: Optional[str]) -> Optional[str]: +def _remnashop_referral_compat_enabled(settings: Optional[Settings]) -> bool: + if settings is None: + return False + return bool(getattr(settings, "MIGRATION_REMNASHOP_REFERRAL_CODE_COMPAT_ENABLED", False)) + + +def _strip_referral_param_prefix( + raw: Optional[str], + *, + preserve_current_u_prefix: bool, +) -> str: value = (raw or "").strip() if not value: - return None + return "" value_lower = value.lower() - if value_lower.startswith("ref_u"): + if value_lower.startswith("ref_u") and not preserve_current_u_prefix: value = value[5:] elif value_lower.startswith("ref_"): value = value[4:] - elif value and value[0].lower() == "u" and len(value) == 10: + return value + + +def _normalize_referral_param(raw: Optional[str]) -> Optional[str]: + value = _strip_referral_param_prefix(raw, preserve_current_u_prefix=False) + if not value: + return None + + if value and value[0].lower() == "u" and len(value) == 10: value = value[1:] if not re.fullmatch(r"[A-Za-z0-9]{1,32}", value): @@ -1310,26 +1370,64 @@ def _normalize_referral_param(raw: Optional[str]) -> Optional[str]: return value.upper() +def _referral_param_lookup_candidates( + raw: Optional[str], + *, + remnashop_compat: bool, +) -> List[str]: + if not remnashop_compat: + normalized = _normalize_referral_param(raw) + return [normalized] if normalized else [] + + value = _strip_referral_param_prefix(raw, preserve_current_u_prefix=True) + if not value or not re.fullmatch(r"[A-Za-z0-9._:-]{1,128}", value): + return [] + + candidates = [value] + if value and value[0].lower() == "u": + candidates.append(value[1:]) + + unique: List[str] = [] + for candidate in candidates: + if candidate and candidate not in unique: + unique.append(candidate) + return unique + + async def _resolve_referrer_id( session: AsyncSession, raw_referral_param: Optional[str], *, current_user_id: Optional[int], + settings: Optional[Settings] = None, ) -> Optional[int]: - normalized = _normalize_referral_param(raw_referral_param) - if not normalized: + remnashop_compat = _remnashop_referral_compat_enabled(settings) + candidates = _referral_param_lookup_candidates( + raw_referral_param, + remnashop_compat=remnashop_compat, + ) + if not candidates: return None - ref_user = None - if normalized.isdigit(): - ref_user = await user_dal.get_user_by_id(session, int(normalized)) - if not ref_user: - ref_user = await user_dal.get_user_by_referral_code(session, normalized) - if not ref_user: - return None - if current_user_id is not None and int(ref_user.user_id) == int(current_user_id): - return None - return int(ref_user.user_id) + for normalized in candidates: + ref_user = None + if normalized.isdigit() and not remnashop_compat: + ref_user = await user_dal.get_user_by_id(session, int(normalized)) + if not ref_user: + ref_user = await user_dal.get_user_by_referral_code( + session, + normalized, + include_legacy=remnashop_compat, + ) + if not ref_user and normalized.isdigit() and remnashop_compat: + ref_user = await user_dal.get_user_by_id(session, int(normalized)) + if not ref_user: + continue + if current_user_id is not None and int(ref_user.user_id) == int(current_user_id): + continue + return int(ref_user.user_id) + + return None async def _apply_referral_to_existing_user( @@ -1345,6 +1443,7 @@ async def _apply_referral_to_existing_user( session, raw_referral_param, current_user_id=int(user.user_id), + settings=request.app["settings"], ) if not referred_by_id: return False @@ -1374,6 +1473,21 @@ async def _apply_referral_welcome_bonus_if_needed( if not raw_referral_param or not user.referred_by_id: return None + settings: Settings = request.app["settings"] + if _referral_welcome_telegram_required_reason(settings, user): + return None + + return await _grant_referral_welcome_bonus_if_eligible(request, session, user) + + +async def _grant_referral_welcome_bonus_if_eligible( + request: web.Request, + session: AsyncSession, + user: User, +) -> Optional[datetime]: + if not user.referred_by_id: + return None + settings: Settings = request.app["settings"] referral_welcome_days = max( 0, @@ -1397,6 +1511,67 @@ async def _apply_referral_welcome_bonus_if_needed( ) +def _webapp_datetime_text(value: Optional[datetime]) -> Optional[str]: + if not value: + return None + normalized = value if value.tzinfo else value.replace(tzinfo=timezone.utc) + return normalized.strftime("%d.%m.%Y %H:%M") + + +async def referral_welcome_bonus_claim_route(request: web.Request) -> web.Response: + user_id = _require_user_id(request) + rate_limit_response = await _enforce_webapp_rate_limit( + request, + user_id=user_id, + action="referral_welcome_claim", + ) + if rate_limit_response: + return rate_limit_response + + settings: Settings = request.app["settings"] + async_session_factory: sessionmaker = request.app["async_session_factory"] + async with async_session_factory() as session: + try: + db_user = await user_dal.get_user_by_id(session, user_id) + if not db_user or db_user.is_banned: + await session.rollback() + return _json_error(403, "access_denied", "Access denied") + + reason = _referral_welcome_telegram_required_reason(settings, db_user) + if reason: + await session.rollback() + return _json_error(400, "referral_welcome_telegram_required", reason) + + end_date = await _grant_referral_welcome_bonus_if_eligible( + request, + session, + db_user, + ) + if not end_date: + await session.rollback() + return _json_error( + 400, + "referral_welcome_unavailable", + "Referral welcome bonus is not available", + ) + + await session.commit() + except Exception: + await session.rollback() + logger.exception("Referral welcome bonus claim failed") + return _json_error(500, "referral_welcome_failed", "Referral welcome bonus failed") + + await _invalidate_webapp_user_caches(settings, user_id, include_devices=True) + return web.json_response( + { + "ok": True, + "claimed": True, + "end_date": end_date.isoformat() if isinstance(end_date, datetime) else None, + "end_date_text": _webapp_datetime_text(end_date), + } + ) + + async def _ensure_user_from_telegram( session: AsyncSession, telegram_user: Dict[str, Any], @@ -1427,6 +1602,7 @@ async def _ensure_user_from_telegram( session, referral_param or telegram_user.get("start_param"), current_user_id=user_id, + settings=settings, ) db_user, created = await user_dal.create_user( session, diff --git a/backend/bot/app/web/webapp/billing.py b/backend/bot/app/web/webapp/billing.py index 56189bc..9408c74 100644 --- a/backend/bot/app/web/webapp/billing.py +++ b/backend/bot/app/web/webapp/billing.py @@ -1,7 +1,18 @@ # ruff: noqa: F401,F403,F405,I001 from ._runtime import * # noqa: F403,F405 +from bot.app.web.webapp.auth import _trial_telegram_required_reason from bot.app.web.webapp.cache_helpers import invalidate_webapp_user_caches +from db.dal import message_log_dal + + +_HTML_TAG_RE = re.compile(r"<[^>]+>") + + +def _plain_text_message(value: Any) -> str: + """Strip Telegram-style HTML markup from a localized message for the web app.""" + text = _HTML_TAG_RE.sub("", str(value)) + return html.unescape(text).strip() def _billing_iso_datetime(value: Optional[Any]) -> Optional[str]: @@ -68,7 +79,7 @@ async def apply_promo_route(request: web.Request) -> web.Response: ) if not success: await session.commit() - return _json_error(400, "promo_apply_failed", str(result)) + return _json_error(400, "promo_apply_failed", _plain_text_message(result)) await session.commit() end_date = result if isinstance(result, datetime) else None return web.json_response( @@ -111,10 +122,11 @@ async def create_payment_route(request: web.Request) -> web.Response: hwid_quote: Optional[Dict[str, Any]] = None requested_sale_mode = _sale_mode_base(str(payment_payload.sale_mode or "")) + if tariffs_config and requested_sale_mode == "hwid_devices_renewal": + return _json_error(400, "invalid_plan", "Device renewal is part of subscription renewal") if tariffs_config and requested_sale_mode in { "hwid_device", "hwid_devices", - "hwid_devices_renewal", }: tariff_key = str(payment_payload.tariff_key or "").strip() if not tariff_key: @@ -308,7 +320,7 @@ async def create_payment_route(request: web.Request) -> web.Response: user_id=user_id, device_count=int(payment_units), tariff_key=sale_tariff_key, - renewal=_sale_mode_base(sale_mode) == "hwid_devices_renewal", + renewal=False, currency=currency, ) if not hwid_quote: @@ -321,6 +333,25 @@ async def create_payment_route(request: web.Request) -> web.Response: else: price = float(hwid_quote["price"]) stars_price = None + elif _sale_mode_base(sale_mode) == "subscription" and bool( + payment_payload.renew_hwid_devices + ): + currency = "stars" if method == "stars" else default_currency + sale_tariff_key = _sale_mode_tariff_key(sale_mode) + if sale_tariff_key: + hwid_quote = await subscription_service.quote_hwid_device_renewal_for_subscription( + session, + user_id=user_id, + target_tariff_key=sale_tariff_key, + months=int(payment_units), + currency=currency, + ) + if hwid_quote: + if method == "stars": + stars_price = int(stars_price or 0) + int(hwid_quote["price"]) + else: + price = float(price or 0) + float(hwid_quote["price"]) + stars_price = None admin_ids = {int(item) for item in (settings.ADMIN_IDS or [])} is_admin = bool(db_user.telegram_id and int(db_user.telegram_id) in admin_ids) return await _create_subscription_payment( @@ -360,6 +391,13 @@ async def activate_trial_route(request: web.Request) -> web.Response: db_user = await user_dal.get_user_by_id(session, user_id) if not db_user or db_user.is_banned: return _json_error(403, "access_denied", "Access denied") + telegram_required_reason = _trial_telegram_required_reason(settings, db_user) + if telegram_required_reason: + return _json_error( + 400, + "trial_telegram_required", + telegram_required_reason, + ) activation_result = await subscription_service.activate_trial_subscription(session, user_id) if not activation_result or not activation_result.get("activated"): @@ -395,6 +433,28 @@ async def activate_trial_route(request: web.Request) -> web.Response: except Exception: logger.exception("Failed to send WebApp trial activation notification") + try: + await message_log_dal.create_message_log_no_commit( + session, + { + "user_id": user_id, + "telegram_username": getattr(db_user, "username", None), + "telegram_first_name": getattr(db_user, "first_name", None), + "event_type": "webapp_trial_activate", + "content": ( + f"Trial activated via WebApp for user_id={user_id}; " + f"email={getattr(db_user, 'email', None) or 'N/A'}" + ), + "is_admin_event": False, + "target_user_id": user_id, + "timestamp": datetime.now(timezone.utc), + }, + ) + except Exception: + logger.exception("Failed to add WebApp trial activation audit log") + + await session.commit() + try: from db.dal import ad_dal as _ad_dal @@ -659,7 +719,6 @@ async def device_topup_options_route(request: web.Request) -> web.Response: return _json_error(400, "device_topup_unavailable", "Device top-up is not available") lang = db_user.language_code or settings.DEFAULT_LANGUAGE active = await subscription_service.get_active_subscription_details(session, user_id) - renewal_available = bool(active and active.get("device_topup_renewal_available")) extra_hwid_valid_until = active.get("extra_hwid_devices_valid_until") if active else None extra_hwid_valid_until_text = ( active.get("extra_hwid_devices_valid_until_text") if active else None @@ -681,7 +740,7 @@ async def device_topup_options_route(request: web.Request) -> web.Response: user_id=user_id, device_count=count, tariff_key=tariff.key, - renewal=renewal_available, + renewal=False, currency=default_currency, ) if count in currency_counts @@ -693,7 +752,7 @@ async def device_topup_options_route(request: web.Request) -> web.Response: user_id=user_id, device_count=count, tariff_key=tariff.key, - renewal=renewal_available, + renewal=False, currency="stars", ) if count in stars_counts @@ -701,28 +760,27 @@ async def device_topup_options_route(request: web.Request) -> web.Response: ) if not currency_quote and not stars_quote: continue - sale_mode_for_plan = "hwid_devices_renewal" if renewal_available else "hwid_devices" + quote = currency_quote or stars_quote + valid_from = quote.get("valid_from") + valid_until = quote.get("valid_until") plan = { - "id": f"{tariff.key}:hwid:{count}{':renewal' if renewal_available else ''}", + "id": f"{tariff.key}:hwid:{count}", "tariff_key": tariff.key, "tariff_name": tariff.name(lang), "billing_model": tariff.billing_model, - "sale_mode": sale_mode_for_plan, + "sale_mode": "hwid_devices", + "renewal": False, "months": count, "device_count": count, "price": float(currency_quote.get("price") if currency_quote else 0), "currency": default_currency_code, "title": f"+{count}", "subtitle": tariff.name(lang), - "valid_from": _billing_iso_datetime( - (currency_quote or stars_quote).get("valid_from") - ), - "valid_until": _billing_iso_datetime( - (currency_quote or stars_quote).get("valid_until") - ), - "proration_ratio": float( - (currency_quote or stars_quote).get("proration_ratio") or 0 - ), + "valid_from": _billing_iso_datetime(valid_from), + "valid_from_text": _billing_datetime_text(valid_from), + "valid_until": _billing_iso_datetime(valid_until), + "valid_until_text": _billing_datetime_text(valid_until), + "proration_ratio": float(quote.get("proration_ratio") or 0), } if stars_quote and int(stars_quote.get("price") or 0) > 0: plan["stars_price"] = int(stars_quote["price"]) @@ -738,10 +796,8 @@ async def device_topup_options_route(request: web.Request) -> web.Response: else int(sub.extra_hwid_devices or 0), "extra_hwid_devices_valid_until": _billing_iso_datetime(extra_hwid_valid_until), "extra_hwid_devices_valid_until_text": extra_hwid_valid_until_text, - "renewal_available": renewal_available, - "renewal_recommended_count": int(active.get("extra_hwid_devices") or 0) - if active and renewal_available - else 0, + "renewal_available": False, + "renewal_recommended_count": 0, "plans": plans, } ) @@ -907,7 +963,11 @@ async def payment_status_route(request: web.Request) -> web.Response: payment = await _refresh_yookassa_payment_status(request, session, payment) payment = await _refresh_wata_payment_status(request, session, payment) if payment.status == "succeeded": - await invalidate_webapp_user_caches(request.app["settings"], user_id) + await invalidate_webapp_user_caches( + request.app["settings"], + user_id, + include_devices=True, + ) return web.json_response( { "ok": True, @@ -1005,6 +1065,7 @@ async def _create_subscription_payment( description=description, sale_mode=sale_mode, traffic_gb=traffic_gb, + hwid_device_count=hwid_quote.get("device_count") if hwid_quote else None, hwid_valid_from=hwid_quote.get("valid_from") if hwid_quote else None, hwid_valid_until=hwid_quote.get("valid_until") if hwid_quote else None, hwid_pricing_period_months=hwid_quote.get("pricing_period_months") diff --git a/backend/bot/app/web/webapp/payloads.py b/backend/bot/app/web/webapp/payloads.py index e03221e..eba2a99 100644 --- a/backend/bot/app/web/webapp/payloads.py +++ b/backend/bot/app/web/webapp/payloads.py @@ -49,6 +49,7 @@ class WebAppPaymentCreatePayload(BaseModel): device_count: Any = None tariff_key: Optional[constr(max_length=128)] = None sale_mode: Optional[constr(max_length=64)] = None + renew_hwid_devices: Optional[bool] = None description: Optional[constr(max_length=4096)] = None comment: Optional[constr(max_length=4096)] = None note: Optional[constr(max_length=4096)] = None diff --git a/backend/bot/app/web/webapp/routes.py b/backend/bot/app/web/webapp/routes.py index c9b7c13..3cec44b 100644 --- a/backend/bot/app/web/webapp/routes.py +++ b/backend/bot/app/web/webapp/routes.py @@ -46,10 +46,6 @@ def setup_subscription_webapp_routes(app: web.Application) -> None: rf"{WEBAPP_FAVICON_PATH}/{{digest:[0-9a-f]{{16}}}}/{{filename:[A-Za-z0-9_.-]+}}", webapp_favicon_route, ) - app.router.add_get( - r"/webapp-emoji/{codepoints:[0-9a-f_]+}/512.{ext:gif|webp}", - webapp_animated_emoji_route, - ) app.router.add_get("/subscription_webapp.{asset_hash:[0-9a-f]{8}}.css", css_asset_route) app.router.add_get("/subscription_webapp.css", css_asset_route) app.router.add_get( @@ -89,6 +85,7 @@ def setup_subscription_webapp_routes(app: web.Application) -> None: "/api/account/telegram/notifications/probe", account_telegram_notifications_probe_route, ) + app.router.add_post("/api/referral/welcome-bonus/claim", referral_welcome_bonus_claim_route) app.router.add_post("/api/promo/apply", apply_promo_route) app.router.add_post("/api/trial/activate", activate_trial_route) app.router.add_get("/api/devices", devices_route) diff --git a/backend/bot/app/web/webapp/serializers.py b/backend/bot/app/web/webapp/serializers.py index 075daed..cffbd28 100644 --- a/backend/bot/app/web/webapp/serializers.py +++ b/backend/bot/app/web/webapp/serializers.py @@ -1,6 +1,11 @@ # ruff: noqa: F401,F403,F405,I001 from ._runtime import * # noqa: F403,F405 +from bot.app.web.webapp.auth import ( + _referral_welcome_telegram_required_reason, + _trial_telegram_required_reason, + _user_has_linked_telegram, +) from config.subscription_guides_config import subscription_guides_available from config.webapp_themes_config import public_themes_catalog_payload from bot.services.telegram_notifications import ( @@ -64,20 +69,48 @@ async def _build_user_payload(request: web.Request, user_id: int) -> Dict[str, A if active and local_sub else None ) - trial_available = bool( + trial_base_available = bool( settings.TRIAL_ENABLED and settings.TRIAL_DURATION_DAYS > 0 and not await subscription_service.has_trial_blocking_subscription(session, user_id) ) + trial_telegram_required_reason = ( + _trial_telegram_required_reason(settings, db_user) if trial_base_available else None + ) + trial_available = bool(trial_base_available and not trial_telegram_required_reason) + lang = _normalize_language(db_user.language_code or settings.DEFAULT_LANGUAGE) + plans_payload = _serialize_plans( + settings, + lang, + subscription_options=cached["subscription_options"], + stars_subscription_options=cached["stars_subscription_options"], + traffic_packages=cached["traffic_packages"], + stars_traffic_packages=cached["stars_traffic_packages"], + ) + await _attach_hwid_renewal_quotes_to_plans( + session, + subscription_service, + user_id=user_id, + settings=settings, + active=active, + local_sub=local_sub, + plans=plans_payload, + ) avatar = await _ensure_cached_telegram_avatar(request, session, db_user) try: await session.commit() except Exception: await session.rollback() - lang = _normalize_language(db_user.language_code or settings.DEFAULT_LANGUAGE) admin_ids = {int(x) for x in (settings.ADMIN_IDS or [])} is_admin = bool(db_user.telegram_id and int(db_user.telegram_id) in admin_ids) + telegram_linked = _user_has_linked_telegram(db_user) + referral_welcome_days = max(0, int(getattr(settings, "REFERRAL_WELCOME_BONUS_DAYS", 0) or 0)) + referral_welcome_telegram_required_reason = ( + _referral_welcome_telegram_required_reason(settings, db_user) + if db_user.referred_by_id and not active and referral_welcome_days > 0 + else None + ) telegram_notifications_status = normalize_telegram_notification_status( getattr(db_user, "telegram_notifications_status", None) ) @@ -94,7 +127,7 @@ async def _build_user_payload(request: web.Request, user_id: int) -> Dict[str, A db_user.email and db_user.email_verified_at and db_user.password_hash ), "telegram_id": db_user.telegram_id, - "telegram_linked": bool(_telegram_id_for_user(db_user)), + "telegram_linked": telegram_linked, "telegram_notifications_status": telegram_notifications_status, "telegram_notifications_enabled": ( telegram_notifications_status == TELEGRAM_NOTIFICATIONS_ENABLED @@ -120,22 +153,20 @@ async def _build_user_payload(request: web.Request, user_id: int) -> Dict[str, A "webapp_link": webapp_referral_link, "invited_count": referral_stats.get("invited_count", 0), "purchased_count": referral_stats.get("purchased_count", 0), - "welcome_bonus_days": max( - 0, int(getattr(settings, "REFERRAL_WELCOME_BONUS_DAYS", 0) or 0) + "welcome_bonus_days": referral_welcome_days, + "welcome_bonus_without_telegram_enabled": bool( + getattr(settings, "REFERRAL_WELCOME_BONUS_WITHOUT_TELEGRAM_ENABLED", True) ), + "welcome_bonus_requires_telegram": bool( + referral_welcome_telegram_required_reason and not telegram_linked + ), + "welcome_bonus_block_reason": referral_welcome_telegram_required_reason, "one_bonus_per_referee": bool( getattr(settings, "REFERRAL_ONE_BONUS_PER_REFEREE", False) ), "bonus_details": _serialize_referral_bonus_details(settings, lang), }, - "plans": _serialize_plans( - settings, - lang, - subscription_options=cached["subscription_options"], - stars_subscription_options=cached["stars_subscription_options"], - traffic_packages=cached["traffic_packages"], - stars_traffic_packages=cached["stars_traffic_packages"], - ), + "plans": plans_payload, "payment_methods": _serialize_payment_methods( settings, request.app, @@ -164,6 +195,11 @@ async def _build_user_payload(request: web.Request, user_id: int) -> Dict[str, A ), "trial_enabled": bool(settings.TRIAL_ENABLED), "trial_available": trial_available, + "trial_without_telegram_enabled": bool( + getattr(settings, "TRIAL_WITHOUT_TELEGRAM_ENABLED", True) + ), + "trial_requires_telegram": bool(trial_telegram_required_reason and not telegram_linked), + "trial_block_reason": trial_telegram_required_reason, "trial_duration_days": int(settings.TRIAL_DURATION_DAYS or 0), "trial_traffic_limit_gb": float(settings.TRIAL_TRAFFIC_LIMIT_GB or 0), "trial_traffic_strategy": getattr(settings, "TRIAL_TRAFFIC_STRATEGY", "NO_RESET"), @@ -342,11 +378,12 @@ def _serialize_subscription( and tariff.premium_topup_packages.has_any() ) can_topup_traffic = bool(can_topup_regular_traffic or can_topup_premium_traffic) - # max_devices == 0 means unlimited — top-up is pointless in that case. + max_devices = _coerce_int_or_none(active.get("max_devices")) + # max_devices == 0 or None means unlimited — top-up is pointless in that case. can_topup_devices = bool( tariff.billing_model == "period" and tariff.has_hwid_device_packages() - and _coerce_int_or_none(active.get("max_devices")) != 0 + and max_devices not in (None, 0) ) except Exception: can_topup_regular_traffic = False @@ -437,6 +474,102 @@ def _serialize_subscription( } +def _webapp_iso_datetime(value: Optional[Any]) -> Optional[str]: + if not value: + return None + if isinstance(value, datetime): + normalized = value if value.tzinfo else value.replace(tzinfo=timezone.utc) + return normalized.isoformat() + return str(value) + + +def _webapp_datetime_text(value: Optional[Any]) -> Optional[str]: + if not value: + return None + if isinstance(value, datetime): + normalized = value if value.tzinfo else value.replace(tzinfo=timezone.utc) + return normalized.strftime("%d.%m.%Y %H:%M") + return str(value) + + +async def _attach_hwid_renewal_quotes_to_plans( + session: AsyncSession, + subscription_service: SubscriptionService, + *, + user_id: int, + settings: Settings, + active: Optional[Dict[str, Any]], + local_sub: Optional[Any], + plans: List[Dict[str, Any]], +) -> None: + quote_method = getattr(subscription_service, "quote_hwid_device_renewal_for_subscription", None) + if not callable(quote_method): + return + if not active or not local_sub or not settings.tariffs_config: + return + if not active.get("end_date") or int(active.get("extra_hwid_devices") or 0) <= 0: + return + + default_currency = default_currency_key_for_settings(settings) + default_currency_code = payment_currency_code(default_currency) + for plan in plans: + if str(plan.get("sale_mode") or "subscription") != "subscription": + continue + target_tariff_key = str(plan.get("tariff_key") or "").strip() + if not target_tariff_key: + continue + try: + months = int(plan.get("months") or 0) + except (TypeError, ValueError): + continue + if months <= 0: + continue + try: + currency_quote = await quote_method( + session, + user_id=user_id, + target_tariff_key=target_tariff_key, + months=months, + currency=default_currency, + ) + stars_quote = await quote_method( + session, + user_id=user_id, + target_tariff_key=target_tariff_key, + months=months, + currency="stars", + ) + except Exception: + logger.exception( + "Failed to quote HWID renewal for plan %s/%s", + target_tariff_key, + months, + ) + continue + quote = currency_quote or stars_quote + if not quote: + continue + valid_from = quote.get("valid_from") + valid_until = quote.get("valid_until") + active_until = quote.get("active_until") + renewal = { + "available": True, + "device_count": int(quote.get("device_count") or 0), + "price": float(currency_quote.get("price") if currency_quote else 0), + "currency": default_currency_code, + "valid_from": _webapp_iso_datetime(valid_from), + "valid_from_text": _webapp_datetime_text(valid_from), + "valid_until": _webapp_iso_datetime(valid_until), + "valid_until_text": _webapp_datetime_text(valid_until), + "active_until": _webapp_iso_datetime(active_until), + "active_until_text": _webapp_datetime_text(active_until), + "pricing_period_months": int(quote.get("pricing_period_months") or months), + } + if stars_quote and int(stars_quote.get("price") or 0) > 0: + renewal["stars_price"] = int(stars_quote["price"]) + plan["hwid_renewal"] = renewal + + def _build_install_share_link( request: Optional[web.Request], settings: Settings, @@ -495,7 +628,10 @@ def _serialize_plans( else [], } if tariff.billing_model == "period": - for months in sorted(tariff.enabled_periods): + # Render periods in the configured order (enabled_periods is the + # source of truth for purchase-period ordering, matching the bot + # keyboards). Do not sort so admins can reorder via drag & drop. + for months in tariff.enabled_periods: price = tariff.period_price(int(months), default_currency) stars_price = tariff.period_price(int(months), "stars") if price is None and (stars_price is None or int(stars_price) <= 0): @@ -528,7 +664,14 @@ def _serialize_plans( tariff.traffic_packages.stars if tariff.traffic_packages else [] ) } - for traffic_gb in sorted(set(currency_packages) | set(stars_packages)): + # Preserve the configured package order (default-currency list first, + # then any Stars-only volumes) so admins can reorder via drag & drop. + # Matches the bot keyboard, which iterates the package list as-is. + ordered_gb: List[float] = [] + for traffic_gb in list(currency_packages) + list(stars_packages): + if traffic_gb not in ordered_gb: + ordered_gb.append(traffic_gb) + for traffic_gb in ordered_gb: price = currency_packages.get(traffic_gb) stars_price = stars_packages.get(traffic_gb) if price is None and (stars_price is None or int(stars_price) <= 0): diff --git a/backend/bot/handlers/admin/user_management.py b/backend/bot/handlers/admin/user_management.py index de463bf..74e879c 100644 --- a/backend/bot/handlers/admin/user_management.py +++ b/backend/bot/handlers/admin/user_management.py @@ -236,6 +236,10 @@ def get_user_card_keyboard( text=_(key="admin_user_traffic_grant_button"), callback_data=f"user_action:traffic_grant:{user_id}", ) + builder.button( + text=_(key="admin_user_hwid_limit_button"), + callback_data=f"user_action:hwid_limit:{user_id}", + ) # Row 4: Quick links — only for users with a real Telegram profile # (synthetic email-only users have a negative user_id with no tg profile). @@ -261,9 +265,9 @@ def get_user_card_keyboard( quick_links_count = (1 if has_self_link else 0) + (1 if has_referrer_link else 0) if quick_links_count == 0: - builder.adjust(2, 2, 2, 1, 2, 1, 2) + builder.adjust(2, 2, 2, 1, 3, 1, 2) else: - builder.adjust(2, 2, 2, 1, 2, quick_links_count, 1, 2) + builder.adjust(2, 2, 2, 1, 3, quick_links_count, 1, 2) return builder @@ -403,6 +407,26 @@ async def format_user_card( f"{_('admin_user_traffic_label')} {hcode(f'{used_display} / {limit_display}')}" ) + max_devices = subscription_details.get("max_devices") + extra_hwid_devices = int(subscription_details.get("extra_hwid_devices") or 0) + if max_devices is not None: + if int(max_devices) == 0: + devices_display = _("admin_hwid_limit_state_unlimited") + elif extra_hwid_devices > 0: + base_hwid_limit = subscription_details.get("base_hwid_device_limit") + if base_hwid_limit is None: + devices_display = _("admin_hwid_limit_state_count", count=int(max_devices)) + else: + devices_display = _( + "admin_hwid_limit_state_with_extra", + total=int(max_devices), + base=int(base_hwid_limit), + extra=extra_hwid_devices, + ) + else: + devices_display = _("admin_hwid_limit_state_count", count=int(max_devices)) + card_parts.append(f"{_('admin_user_hwid_limit_label')} {hcode(devices_display)}") + premium_unlimited = bool(subscription_details.get("premium_unlimited_override")) premium_bonus_bytes = int(subscription_details.get("premium_bonus_bytes") or 0) if premium_unlimited: @@ -706,6 +730,32 @@ async def user_action_handler( await handle_traffic_grant_prompt(callback, state, user, "regular", i18n, current_lang) elif action == "traffic_grant_premium": await handle_traffic_grant_prompt(callback, state, user, "premium", i18n, current_lang) + elif action == "hwid_limit": + await handle_hwid_limit_menu(callback, state, user, session, i18n, current_lang) + elif action == "hwid_limit_set_unlimited": + await handle_hwid_limit_apply( + callback, + user, + subscription_service, + session, + settings, + i18n, + current_lang, + hwid_device_limit=0, + ) + elif action == "hwid_limit_reset": + await handle_hwid_limit_apply( + callback, + user, + subscription_service, + session, + settings, + i18n, + current_lang, + hwid_device_limit=None, + ) + elif action == "hwid_limit_set_number": + await handle_hwid_limit_prompt(callback, state, user, i18n, current_lang) else: await callback.answer(_("admin_unknown_action"), show_alert=True) @@ -850,6 +900,162 @@ async def handle_premium_override_bonus_prompt( await callback.answer() +def _admin_hwid_limit_state_text( + get_text: Callable[..., str], + hwid_device_limit: Optional[int], + extra_hwid_devices: int = 0, +) -> str: + if hwid_device_limit is None: + return get_text("admin_hwid_limit_state_default") + base_limit = int(hwid_device_limit) + if base_limit == 0: + return get_text("admin_hwid_limit_state_unlimited") + extra = max(0, int(extra_hwid_devices or 0)) + if extra > 0: + return get_text( + "admin_hwid_limit_state_with_extra", + total=base_limit + extra, + base=base_limit, + extra=extra, + ) + return get_text("admin_hwid_limit_state_count", count=base_limit) + + +async def handle_hwid_limit_menu( + callback: types.CallbackQuery, + state: FSMContext, + user: User, + session: AsyncSession, + i18n_instance, + lang: str, +) -> None: + """Show HWID device limit override controls.""" + _ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs) + + active_sub = await subscription_dal.get_active_subscription_by_user_id(session, user.user_id) + if not active_sub: + await callback.answer(_("admin_hwid_limit_no_subscription"), show_alert=True) + return + + current_text = _admin_hwid_limit_state_text( + _, + getattr(active_sub, "hwid_device_limit", None), + int(getattr(active_sub, "extra_hwid_devices", 0) or 0), + ) + text = "\n".join( + [ + f"{_('admin_hwid_limit_title')}", + "", + _("admin_hwid_limit_hint"), + "", + _("admin_hwid_limit_current", current=current_text), + ] + ) + + builder = InlineKeyboardBuilder() + builder.button( + text=_("admin_hwid_limit_btn_set_number"), + callback_data=f"user_action:hwid_limit_set_number:{user.user_id}", + ) + builder.button( + text=_("admin_hwid_limit_btn_unlimited"), + callback_data=f"user_action:hwid_limit_set_unlimited:{user.user_id}", + ) + builder.button( + text=_("admin_hwid_limit_btn_reset"), + callback_data=f"user_action:hwid_limit_reset:{user.user_id}", + ) + builder.button( + text=_("admin_user_back_to_card_button"), + callback_data=f"user_action:refresh:{user.user_id}", + ) + builder.adjust(1, 1, 1, 1) + + try: + await callback.message.edit_text(text, reply_markup=builder.as_markup(), parse_mode="HTML") + except Exception: + await callback.message.answer(text, reply_markup=builder.as_markup(), parse_mode="HTML") + await state.update_data(target_user_id=user.user_id) + await callback.answer() + + +async def handle_hwid_limit_apply( + callback: types.CallbackQuery, + user: User, + subscription_service: SubscriptionService, + session: AsyncSession, + settings: Settings, + i18n_instance, + lang: str, + *, + hwid_device_limit: Optional[int], +) -> None: + """Persist a HWID device base limit override and push it to the panel.""" + _ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs) + + try: + active_sub = await subscription_dal.get_active_subscription_by_user_id( + session, user.user_id + ) + if not active_sub: + await callback.answer(_("admin_hwid_limit_no_subscription"), show_alert=True) + return + + active_sub.hwid_device_limit = hwid_device_limit + + effective_limit = await subscription_service.sync_hwid_device_limit_to_panel( + session, user.user_id + ) + await message_log_dal.create_message_log_no_commit( + session, + { + "user_id": callback.from_user.id if callback.from_user else user.user_id, + "event_type": "admin:hwid_device_limit", + "content": ( + f"hwid_device_limit={hwid_device_limit!r} " + f"effective_hwid_device_limit={effective_limit!r}" + ), + "is_admin_event": True, + "target_user_id": user.user_id, + "timestamp": datetime.now(timezone.utc), + }, + ) + await session.commit() + + await callback.answer(_("admin_hwid_limit_saved"), show_alert=False) + await handle_refresh_user_card( + callback, user, subscription_service, session, settings, i18n_instance, lang + ) + except Exception as exc: + logging.error( + "Failed to apply HWID device limit for user %s: %s", + user.user_id, + exc, + exc_info=True, + ) + await session.rollback() + await callback.answer(_("admin_hwid_limit_save_error"), show_alert=True) + + +async def handle_hwid_limit_prompt( + callback: types.CallbackQuery, + state: FSMContext, + user: User, + i18n_instance, + lang: str, +) -> None: + """Ask admin for an explicit HWID device limit.""" + _ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs) + await state.update_data(target_user_id=user.user_id) + await state.set_state(AdminStates.waiting_for_hwid_device_limit) + prompt = _("admin_hwid_limit_prompt", user_id=user.user_id) + try: + await callback.message.edit_text(prompt) + except Exception: + await callback.message.answer(prompt) + await callback.answer() + + async def handle_traffic_grant_menu( callback: types.CallbackQuery, user: User, @@ -1971,6 +2177,114 @@ async def process_premium_override_bonus_handler( await state.clear() +@router.message(AdminStates.waiting_for_hwid_device_limit, F.text) +async def process_hwid_device_limit_handler( + message: types.Message, + state: FSMContext, + settings: Settings, + i18n_data: dict, + subscription_service: SubscriptionService, + session: AsyncSession, +): + """Read explicit HWID device limit and apply it.""" + current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE) + i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") + if not i18n: + await message.reply("Language service error.") + return + _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) + + data = await state.get_data() + target_user_id = data.get("target_user_id") + if not target_user_id: + await message.answer(_("admin_hwid_limit_state_missing")) + await state.clear() + return + + raw = (message.text or "").strip() + try: + hwid_device_limit = int(raw) + if hwid_device_limit < 0 or hwid_device_limit > 1_000_000: + raise ValueError("out_of_range") + except (TypeError, ValueError): + await message.answer(_("admin_hwid_limit_invalid")) + return + + target_user = await user_dal.get_user_by_id(session, target_user_id) + if not target_user: + await message.answer(_("admin_user_not_found_action")) + await state.clear() + return + + try: + active_sub = await subscription_dal.get_active_subscription_by_user_id( + session, target_user_id + ) + if not active_sub: + await message.answer(_("admin_hwid_limit_no_subscription")) + await state.clear() + return + + active_sub.hwid_device_limit = hwid_device_limit + effective_limit = await subscription_service.sync_hwid_device_limit_to_panel( + session, target_user_id + ) + await message_log_dal.create_message_log_no_commit( + session, + { + "user_id": message.from_user.id if message.from_user else target_user_id, + "event_type": "admin:hwid_device_limit", + "content": ( + f"hwid_device_limit={hwid_device_limit!r} " + f"effective_hwid_device_limit={effective_limit!r}" + ), + "is_admin_event": True, + "target_user_id": target_user_id, + "timestamp": datetime.now(timezone.utc), + }, + ) + await session.commit() + + current_text = _admin_hwid_limit_state_text(_, hwid_device_limit) + await message.answer( + _("admin_hwid_limit_set", current=current_text, user_id=target_user_id) + ) + + referral_service = ReferralService(settings, subscription_service, message.bot, i18n) + bot_username = await _resolve_bot_username(message.bot) + user_card_text = await format_user_card( + target_user, + session, + subscription_service, + i18n, + current_lang, + referral_service, + settings=settings, + bot_username=bot_username, + ) + keyboard = get_user_card_keyboard( + target_user.user_id, i18n, current_lang, target_user.referred_by_id + ) + await _send_with_profile_link_fallback( + message.answer, + text=user_card_text, + markup=keyboard.as_markup(), + user_id=target_user.user_id, + parse_mode="HTML", + ) + except Exception as exc: + logging.error( + "Error setting HWID device limit for user %s: %s", + target_user_id, + exc, + exc_info=True, + ) + await session.rollback() + await message.answer(_("admin_hwid_limit_save_error")) + finally: + await state.clear() + + @router.message(AdminStates.waiting_for_traffic_grant_gb, F.text) async def process_traffic_grant_gb_handler( message: types.Message, diff --git a/backend/bot/handlers/user/start.py b/backend/bot/handlers/user/start.py index 44cc598..18dcace 100644 --- a/backend/bot/handlers/user/start.py +++ b/backend/bot/handlers/user/start.py @@ -27,6 +27,7 @@ from bot.utils.callback_answer import safe_answer_callback from bot.utils.channel_subscription import ( is_required_channel_access_error, normalize_required_channel_id, + resolve_required_channel_link, ) from bot.utils.install_links import ( append_install_share_link_text, @@ -40,6 +41,67 @@ from db.models import User router = Router(name="user_start_router") +def _remnashop_referral_compat_enabled(settings: Settings) -> bool: + return bool(getattr(settings, "MIGRATION_REMNASHOP_REFERRAL_CODE_COMPAT_ENABLED", False)) + + +def _referral_code_lookup_candidates( + raw_ref_value: str, + *, + remnashop_compat: bool, +) -> list[str]: + value = str(raw_ref_value or "").strip() + if not value: + return [] + + candidates = [value] + if value and value[0].lower() == "u": + stripped_current_prefix = value[1:] + if remnashop_compat: + candidates.append(stripped_current_prefix) + else: + candidates = [stripped_current_prefix] + + unique: list[str] = [] + for candidate in candidates: + candidate = candidate.strip() + if candidate and candidate not in unique: + unique.append(candidate) + return unique + + +async def _resolve_referrer_from_start_ref( + session: AsyncSession, + raw_ref_value: str, + *, + settings: Settings, + current_user_id: int, +) -> Optional[int]: + ref_user: Optional[User] = None + if raw_ref_value.isdigit() and settings.LEGACY_REFS: + potential_referrer_id = int(raw_ref_value) + if potential_referrer_id != current_user_id: + ref_user = await user_dal.get_user_by_id(session, potential_referrer_id) + + include_legacy = _remnashop_referral_compat_enabled(settings) + if not ref_user: + for code in _referral_code_lookup_candidates( + raw_ref_value, + remnashop_compat=include_legacy, + ): + ref_user = await user_dal.get_user_by_referral_code( + session, + code, + include_legacy=include_legacy, + ) + if ref_user: + break + + if ref_user and ref_user.user_id != current_user_id: + return int(ref_user.user_id) + return None + + async def should_show_trial_button( settings: Settings, subscription_service: SubscriptionService, @@ -376,11 +438,12 @@ async def ensure_required_channel_subscription( ) return True - keyboard = ( - get_channel_subscription_keyboard(current_lang, i18n, settings.REQUIRED_CHANNEL_LINK) - if i18n - else None + channel_link = await resolve_required_channel_link( + bot_instance, + required_channel_id, + settings.REQUIRED_CHANNEL_LINK, ) + keyboard = get_channel_subscription_keyboard(current_lang, i18n, channel_link) if i18n else None prompt_text = translate("channel_subscription_required") @@ -410,14 +473,10 @@ async def ensure_required_channel_subscription( @router.message(CommandStart()) +@router.message(CommandStart(magic=F.args.regexp(r"^ref_([A-Za-z0-9_-]{1,64})$").as_("ref_match"))) @router.message( - CommandStart( - magic=F.args.regexp(r"^ref_((?:[uU][A-Za-z0-9]{9})|(?:[A-Za-z0-9]{9})|\d+)$").as_( - "ref_match" - ) - ) + CommandStart(magic=F.args.regexp(r"^promo_([A-Za-z0-9_-]{1,100})$").as_("promo_match")) ) -@router.message(CommandStart(magic=F.args.regexp(r"^promo_(\w+)$").as_("promo_match"))) @router.message(CommandStart(magic=F.args.regexp(r"^admin_user_(\d+)$").as_("admin_user_match"))) @router.message(CommandStart(magic=F.args.regexp(r"^ticket_(\d+)$").as_("ticket_match"))) @router.message(CommandStart(magic=F.args.regexp(r"^notifications$").as_("notifications_match"))) @@ -534,22 +593,12 @@ async def start_command_handler( if ref_match: raw_ref_value = ref_match.group(1) - if raw_ref_value.isdigit(): - if settings.LEGACY_REFS: - potential_referrer_id = int(raw_ref_value) - if potential_referrer_id != user_id and await user_dal.get_user_by_id( - session, potential_referrer_id - ): - referred_by_user_id = potential_referrer_id - else: - normalized_code = raw_ref_value.strip() - if normalized_code and normalized_code[0].lower() == "u": - normalized_code = normalized_code[1:] - ref_user = None - if normalized_code: - ref_user = await user_dal.get_user_by_referral_code(session, normalized_code) - if ref_user and ref_user.user_id != user_id: - referred_by_user_id = ref_user.user_id + referred_by_user_id = await _resolve_referrer_from_start_ref( + session, + raw_ref_value, + settings=settings, + current_user_id=user_id, + ) elif promo_match: promo_code_to_apply = promo_match.group(1) logging.info(f"User {user_id} started with promo code: {promo_code_to_apply}") @@ -1135,7 +1184,7 @@ async def main_action_callback_handler( _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key privacy_url = settings.PRIVACY_POLICY_URL - user_agreement_url = settings.USER_AGREEMENT_URL or settings.TERMS_OF_SERVICE_URL + user_agreement_url = settings.USER_AGREEMENT_URL if not privacy_url and not user_agreement_url: await safe_answer_callback( diff --git a/backend/bot/handlers/user/subscription/core.py b/backend/bot/handlers/user/subscription/core.py index d9b3e2f..3ab73bc 100644 --- a/backend/bot/handlers/user/subscription/core.py +++ b/backend/bot/handlers/user/subscription/core.py @@ -319,7 +319,11 @@ async def select_tariff_callback( @router.callback_query(F.data.startswith("tariff:period:")) async def select_tariff_period_callback( - callback: types.CallbackQuery, i18n_data: dict, settings: Settings, session: AsyncSession + callback: types.CallbackQuery, + i18n_data: dict, + settings: Settings, + session: AsyncSession, + subscription_service: SubscriptionService, ): current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE) i18n: JsonI18n = i18n_data.get("i18n_instance") @@ -333,7 +337,9 @@ async def select_tariff_period_callback( await callback.answer(get_text("error_try_again"), show_alert=True) return tariff_key, months_raw = parts[2], parts[3] - callback_context = parts[4] if len(parts) > 4 else None + callback_tokens = [part for part in parts[4:] if part] + callback_context = "bot" if "bot" in callback_tokens else None + renew_hwid_devices = "no_hwid" not in callback_tokens tariff = config.require(tariff_key) months = int(months_raw) default_currency = default_currency_key_for_settings(settings) @@ -343,6 +349,22 @@ async def select_tariff_period_callback( if price_rub is None: await callback.answer(get_text("error_try_again"), show_alert=True) return + hwid_renewal_quote = await subscription_service.quote_hwid_device_renewal_for_subscription( + session, + user_id=callback.from_user.id, + target_tariff_key=tariff.key, + months=months, + currency=default_currency, + ) + hwid_renewal_stars_quote = ( + await subscription_service.quote_hwid_device_renewal_for_subscription( + session, + user_id=callback.from_user.id, + target_tariff_key=tariff.key, + months=months, + currency="stars", + ) + ) markup = get_payment_method_keyboard( months, price_rub, @@ -354,6 +376,9 @@ async def select_tariff_period_callback( sale_mode=sale_mode_with_callback_context(f"subscription@{tariff.key}", callback_context), back_callback=f"tariff:select:{tariff.key}{callback_suffix_for_context(callback_context)}", user_id=callback.from_user.id, + hwid_renewal_quote=hwid_renewal_quote, + hwid_renewal_stars_quote=hwid_renewal_stars_quote, + hwid_renewal_selected=bool(renew_hwid_devices), ) await callback.message.edit_text(get_text("choose_payment_method"), reply_markup=markup) await callback.answer() @@ -577,7 +602,6 @@ async def hwid_devices_list_callback( if not packages: await callback.answer(get_text("no_hwid_device_packages_available"), show_alert=True) return - renewal_available = bool(active.get("device_topup_renewal_available")) markup = get_hwid_device_packages_keyboard( tariff, packages, @@ -585,14 +609,11 @@ async def hwid_devices_list_callback( i18n, settings, back_callback="main_action:my_devices", - renewal=renewal_available, - ) - text_key = ( - "select_hwid_device_renewal_package" if renewal_available else "select_hwid_device_package" + renewal=False, ) await callback.message.edit_text( get_text( - text_key, + "select_hwid_device_package", date=active.get("extra_hwid_devices_valid_until_text") or "", ), reply_markup=markup, @@ -640,6 +661,7 @@ async def hwid_devices_package_callback( await callback.answer(get_text("error_try_again"), show_alert=True) return sale_mode_base = "hwid_devices_renewal" if action == "renewal_package" else "hwid_devices" + renewal = action == "renewal_package" 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( @@ -647,7 +669,7 @@ async def hwid_devices_package_callback( user_id=callback.from_user.id, device_count=count, tariff_key=tariff.key, - renewal=action == "renewal_package", + renewal=renewal, currency=default_currency, ) stars_quote = await subscription_service.quote_hwid_device_topup( @@ -655,7 +677,7 @@ async def hwid_devices_package_callback( user_id=callback.from_user.id, device_count=count, tariff_key=tariff.key, - renewal=action == "renewal_package", + renewal=renewal, currency="stars", ) if not currency_quote and not stars_quote: diff --git a/backend/bot/keyboards/inline/user_keyboards.py b/backend/bot/keyboards/inline/user_keyboards.py index 6bd5fde..064f786 100644 --- a/backend/bot/keyboards/inline/user_keyboards.py +++ b/backend/bot/keyboards/inline/user_keyboards.py @@ -4,6 +4,7 @@ from aiogram.types import InlineKeyboardMarkup, WebAppInfo from aiogram.utils.keyboard import InlineKeyboardBuilder, InlineKeyboardButton from bot.middlewares.i18n import locale_language_options +from bot.utils.channel_subscription import normalize_required_channel_link from bot.utils.install_links import bot_install_guide_url from bot.utils.mini_app_url import subscription_mini_app_trial_url from config.settings import Settings @@ -13,6 +14,13 @@ from config.tariffs_config import ( ) BOT_MENU_CONTEXT = "bot" +HWID_RENEWAL_TOKEN = "hwid_renewal" + + +def sale_mode_tokens(sale_mode: Optional[str]) -> Tuple[str, ...]: + if not sale_mode or "|" not in sale_mode: + return () + return tuple(token.strip() for token in str(sale_mode).split("|")[1:] if token.strip()) def callback_context_from_back_callback(back_callback: Optional[str]) -> Optional[str]: @@ -23,16 +31,36 @@ def callback_context_from_back_callback(back_callback: Optional[str]) -> Optiona def sale_mode_with_callback_context(sale_mode: str, context: Optional[str]) -> str: sale_mode = sale_mode or "subscription" - if not context or "|" in sale_mode: + if not context or context in sale_mode_tokens(sale_mode): return sale_mode return f"{sale_mode}|{context}" +def sale_mode_with_token(sale_mode: str, token: str) -> str: + sale_mode = sale_mode or "subscription" + token = str(token or "").strip() + if not token or token in sale_mode_tokens(sale_mode): + return sale_mode + return f"{sale_mode}|{token}" + + +def sale_mode_without_token(sale_mode: str, token: str) -> str: + sale_mode = sale_mode or "subscription" + token = str(token or "").strip() + if not token or "|" not in sale_mode: + return sale_mode + base, *tokens = sale_mode.split("|") + kept = [item for item in tokens if item.strip() and item.strip() != token] + return "|".join([base, *kept]) + + +def sale_mode_has_token(sale_mode: Optional[str], token: str) -> bool: + return str(token or "").strip() in sale_mode_tokens(sale_mode) + + def callback_context_from_sale_mode(sale_mode: Optional[str]) -> Optional[str]: - if not sale_mode or "|" not in sale_mode: - return None - context = str(sale_mode).split("|", 1)[1].strip() - return context or None + tokens = sale_mode_tokens(sale_mode) + return BOT_MENU_CONTEXT if BOT_MENU_CONTEXT in tokens else None def callback_suffix_for_context(context: Optional[str]) -> str: @@ -138,8 +166,7 @@ def get_main_menu_inline_keyboard( InlineKeyboardButton(text=_(key="menu_support_button"), url=settings.SUPPORT_LINK) ) - user_agreement_url = settings.USER_AGREEMENT_URL or settings.TERMS_OF_SERVICE_URL - if settings.PRIVACY_POLICY_URL or user_agreement_url: + if settings.PRIVACY_POLICY_URL or settings.USER_AGREEMENT_URL: builder.row( InlineKeyboardButton(text=_(key="menu_info_button"), callback_data="main_action:info") ) @@ -206,8 +233,7 @@ def get_bot_interface_inline_keyboard( InlineKeyboardButton(text=_(key="menu_support_button"), url=settings.SUPPORT_LINK) ) - user_agreement_url = settings.USER_AGREEMENT_URL or settings.TERMS_OF_SERVICE_URL - if settings.PRIVACY_POLICY_URL or user_agreement_url: + if settings.PRIVACY_POLICY_URL or settings.USER_AGREEMENT_URL: builder.row( InlineKeyboardButton( text=_(key="menu_info_button"), callback_data="main_action:bot_info" @@ -483,6 +509,9 @@ def get_payment_method_keyboard( back_callback: Optional[str] = None, user_id: Optional[int] = None, is_admin: Optional[bool] = None, + hwid_renewal_quote: Optional[Dict[str, Any]] = None, + hwid_renewal_stars_quote: Optional[Dict[str, Any]] = None, + hwid_renewal_selected: bool = True, ) -> InlineKeyboardMarkup: _ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs) builder = InlineKeyboardBuilder() @@ -491,12 +520,39 @@ def get_payment_method_keyboard( return str(int(val)) if float(val).is_integer() else f"{val:g}" value_str = _format_value(months) - import logging as _kbd_logging - - _kbd_logging.info( - "payment_method_keyboard build: order=%s", - settings.payment_methods_order, - ) + payment_sale_mode = sale_mode + selected_hwid_quote = hwid_renewal_quote or hwid_renewal_stars_quote + if selected_hwid_quote: + tariff_key = None + sale_mode_main = str(sale_mode or "").split("|", 1)[0] + if "@" in sale_mode_main: + tariff_key = sale_mode_main.split("@", 1)[1] + context = callback_context_from_sale_mode(sale_mode) + toggle_tokens = [f"tariff:period:{tariff_key}:{value_str}"] + if context: + toggle_tokens.append(context) + toggle_tokens.append("no_hwid" if hwid_renewal_selected else "hwid") + builder.row( + InlineKeyboardButton( + text=_( + "payment_hwid_renewal_toggle_on" + if hwid_renewal_selected + else "payment_hwid_renewal_toggle_off", + count=int(selected_hwid_quote.get("device_count") or 0), + price=( + hwid_renewal_quote.get("price") + if hwid_renewal_quote + else hwid_renewal_stars_quote.get("price") + ), + currency_symbol=currency_symbol_val, + ), + callback_data=":".join(toggle_tokens), + ) + ) + if hwid_renewal_selected: + payment_sale_mode = sale_mode_with_token(sale_mode, HWID_RENEWAL_TOKEN) + else: + payment_sale_mode = sale_mode_without_token(sale_mode, HWID_RENEWAL_TOKEN) from bot.payment_providers import get_provider_spec, provider_telegram_button_text for method in settings.payment_methods_order: @@ -517,7 +573,7 @@ def get_payment_method_keyboard( value=value_str, rub_price=price, stars_price=stars_price, - sale_mode=sale_mode, + sale_mode=payment_sale_mode, ) if not callback_data: continue @@ -576,7 +632,7 @@ def get_yk_autopay_choice_keyboard( builder.row( InlineKeyboardButton( text=_(key="yookassa_autopay_pay_saved_card_button"), - callback_data=f"pay_yk_saved_list:{value_str}:{price_str}{suffix}", + callback_data=f"pay_yk_saved_list:{value_str}:{price_str}:0{suffix}", ) ) builder.row( @@ -718,10 +774,11 @@ def get_channel_subscription_keyboard( has_buttons = False - if channel_link: + channel_url = normalize_required_channel_link(channel_link) + if channel_url: builder.button( text=_(key="channel_subscription_join_button"), - url=channel_link, + url=channel_url, ) has_buttons = True diff --git a/backend/bot/middlewares/channel_subscription.py b/backend/bot/middlewares/channel_subscription.py index 0c760ce..f63e81f 100644 --- a/backend/bot/middlewares/channel_subscription.py +++ b/backend/bot/middlewares/channel_subscription.py @@ -11,7 +11,10 @@ from sqlalchemy.ext.asyncio import AsyncSession from bot.keyboards.inline.user_keyboards import get_channel_subscription_keyboard from bot.middlewares.i18n import JsonI18n -from bot.utils.channel_subscription import normalize_required_channel_id +from bot.utils.channel_subscription import ( + normalize_required_channel_id, + resolve_required_channel_link, +) from config.settings import Settings from db.dal import user_dal @@ -86,10 +89,14 @@ class ChannelSubscriptionMiddleware(BaseMiddleware): return i18n_instance.gettext(current_lang, key) return key + bot_instance = data.get("bot") or data.get("bot_instance") + channel_link = await resolve_required_channel_link( + bot_instance, + required_channel_id, + self.settings.REQUIRED_CHANNEL_LINK, + ) keyboard = ( - get_channel_subscription_keyboard( - current_lang, i18n_instance, self.settings.REQUIRED_CHANNEL_LINK - ) + get_channel_subscription_keyboard(current_lang, i18n_instance, channel_link) if i18n_instance else None ) diff --git a/backend/bot/payment_providers/base.py b/backend/bot/payment_providers/base.py index 88d7f4f..9f5cf65 100644 --- a/backend/bot/payment_providers/base.py +++ b/backend/bot/payment_providers/base.py @@ -114,6 +114,7 @@ class WebAppPaymentContext: sale_mode: str currency: str = "RUB" traffic_gb: Optional[float] = None + hwid_device_count: Optional[int] = None hwid_valid_from: Optional[Any] = None hwid_valid_until: Optional[Any] = None hwid_pricing_period_months: Optional[int] = None diff --git a/backend/bot/payment_providers/cryptopay.py b/backend/bot/payment_providers/cryptopay.py index 94457f5..4efbd18 100644 --- a/backend/bot/payment_providers/cryptopay.py +++ b/backend/bot/payment_providers/cryptopay.py @@ -192,6 +192,7 @@ class CryptoPayService: sale_mode: str = "subscription", url_kind: str = "bot", hwid_quote: Optional[dict] = None, + hwid_device_count: Optional[int] = None, currency: Optional[str] = None, ) -> Optional[str]: if not self.configured or not self.client: @@ -210,7 +211,11 @@ class CryptoPayService: return None 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, + hwid_device_count=hwid_device_count, + ) try: payment_record = await payment_dal.create_payment_record( session, @@ -252,6 +257,7 @@ class CryptoPayService: "payment_db_id": str(payment_record.payment_id), "sale_mode": sale_mode, "traffic_gb": str(months) if sale_mode_is_traffic(sale_mode) else None, + "hwid_devices": amounts.purchased_hwid_devices, } ) try: @@ -513,6 +519,7 @@ async def create_webapp_payment(ctx: WebAppPaymentContext) -> web.Response: } if ctx.hwid_valid_from and ctx.hwid_valid_until else None, + hwid_device_count=ctx.hwid_device_count, ) if not url: return payment_failed() diff --git a/backend/bot/payment_providers/paykilla.py b/backend/bot/payment_providers/paykilla.py new file mode 100644 index 0000000..8519f4a --- /dev/null +++ b/backend/bot/payment_providers/paykilla.py @@ -0,0 +1,1387 @@ +import hashlib +import hmac +import json +import logging +import re +import time +from datetime import datetime, timedelta, timezone +from decimal import Decimal, InvalidOperation +from typing import Any, Dict, List, Optional, Tuple +from urllib.parse import urlencode + +from aiogram import Bot, F, Router, types +from aiohttp import web +from pydantic import AliasChoices, Field, field_validator +from pydantic_settings import SettingsConfigDict +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import sessionmaker + +from bot.middlewares.i18n import JsonI18n +from bot.services.referral_service import ReferralService +from bot.services.subscription_service import SubscriptionService +from bot.utils.request_security import ip_in_allowlist, request_client_ip +from config.settings import Settings +from config.tariffs_config import ( + default_currency_key_for_settings, + default_payment_currency_code_for_settings, +) +from db.dal import payment_dal + +from .base import ( + PaymentProviderSpec, + ProviderEnvConfig, + ProviderManifestField, + ServiceFactoryContext, + WebAppPaymentContext, + normalize_payment_currency_code, + parse_supported_currency_codes, + provider_env_file, + provider_runtime_enabled, +) +from .shared import ( + HttpClientMixin, + PaymentSuccessRequest, + build_payment_record_payload, + create_webapp_payment_record, + decimal_amounts_equal, + describe_payment, + finalize_successful_payment, + finalize_webapp_link_payment, + first_value, + format_decimal_amount, + lookup_payment_by_order_or_provider_id, + make_translator, + notify_callback_parse_error, + notify_payment_record_failure, + notify_service_unavailable, + notify_user_payment_failed, + parse_payment_callback, + payment_failed, + payment_unavailable, + payment_units_for_activation, + quote_hwid_callback_parts, + render_link_or_fail, +) + +router = Router(name="user_subscription_payments_paykilla_router") +_LOG = "paykilla" + +PAYKILLA_DEFAULT_PAYMENT_CURRENCIES = "USDTTRC" +PAYKILLA_DEFAULT_INVOICE_CURRENCIES = "USD,EUR" +PAYKILLA_DEFAULT_EXCHANGE_RATE_URL = "https://open.er-api.com/v6/latest/{source}" +PAYKILLA_DEFAULT_SUPPORTED_CURRENCIES = ( + "RUB,USD,EUR,AED,GBP,BTC,ETH,TRX,TON,USDTTRC,USDTETH,USDTBSC," + "USDCETH,USDCBSC,DAIETH,DAIBSC,BNBBSC,ETHBSC,LINKETH,LINKBSC," + "USDTTON,AAVEETH,MANAETH,SHIBETH" +) +_FIAT_CURRENCIES = {"RUB", "USD", "EUR", "AED", "GBP"} +_SUCCESS_EVENTS = {"INVOICE_PAID", "PAYMENT_COMPLETED", "PAYMENT_OVERPAID"} +_FAILED_EVENTS = { + "PAYMENT_FAILED", + "PAYMENT_UNDERPAID", + "INVOICE_EXPIRED", + "INVOICE_CANCELLED", + "PAYMENT_CANCELLED", + "COMPLIANCE_FAILED", +} +_CYRILLIC_TO_LATIN = str.maketrans( + { + "А": "A", + "Б": "B", + "В": "V", + "Г": "G", + "Д": "D", + "Е": "E", + "Ё": "E", + "Ж": "Zh", + "З": "Z", + "И": "I", + "Й": "Y", + "К": "K", + "Л": "L", + "М": "M", + "Н": "N", + "О": "O", + "П": "P", + "Р": "R", + "С": "S", + "Т": "T", + "У": "U", + "Ф": "F", + "Х": "H", + "Ц": "Ts", + "Ч": "Ch", + "Ш": "Sh", + "Щ": "Sch", + "Ъ": "", + "Ы": "Y", + "Ь": "", + "Э": "E", + "Ю": "Yu", + "Я": "Ya", + "а": "a", + "б": "b", + "в": "v", + "г": "g", + "д": "d", + "е": "e", + "ё": "e", + "ж": "zh", + "з": "z", + "и": "i", + "й": "y", + "к": "k", + "л": "l", + "м": "m", + "н": "n", + "о": "o", + "п": "p", + "р": "r", + "с": "s", + "т": "t", + "у": "u", + "ф": "f", + "х": "h", + "ц": "ts", + "ч": "ch", + "ш": "sh", + "щ": "sch", + "ъ": "", + "ы": "y", + "ь": "", + "э": "e", + "ю": "yu", + "я": "ya", + } +) + + +class PaykillaConfig(ProviderEnvConfig): + """PayKilla V2 env vars.""" + + model_config = SettingsConfigDict( + env_file=provider_env_file(), + env_file_encoding="utf-8", + env_prefix="PAYKILLA_", + extra="ignore", + populate_by_name=True, + ) + + ENABLED: bool = Field(default=False) + API_KEY: Optional[str] = Field( + default=None, + validation_alias=AliasChoices("PAYKILLA_API_KEY", "PAYKILLA_V2_API_KEY"), + ) + SECRET_KEY: Optional[str] = Field( + default=None, + validation_alias=AliasChoices("PAYKILLA_SECRET_KEY", "PAYKILLA_V2_SECRET_KEY"), + ) + BASE_URL: str = Field( + default="https://account-api.paykilla.com", + validation_alias=AliasChoices("PAYKILLA_BASE_URL", "PAYKILLA_V2_BASE_URL"), + ) + WIDGET_URL: str = Field(default="https://gopay.paykilla.com") + CURRENCY: str = Field(default="USD") + INVOICE_CURRENCIES: str = Field(default=PAYKILLA_DEFAULT_INVOICE_CURRENCIES) + INVOICE_TYPE: Optional[str] = None + PAYMENT_CURRENCIES: str = Field(default=PAYKILLA_DEFAULT_PAYMENT_CURRENCIES) + SUPPORTED_CURRENCIES: str = Field(default=PAYKILLA_DEFAULT_SUPPORTED_CURRENCIES) + LIFETIME_SECONDS: int = Field(default=3600) + RECV_WINDOW_MS: int = Field(default=5000) + USER_PAYS_SERVICE_FEE: bool = Field(default=True) + USER_PAYS_NETWORK_FEE: bool = Field(default=True) + EXCHANGE_RATE_URL: str = Field(default=PAYKILLA_DEFAULT_EXCHANGE_RATE_URL) + EXCHANGE_RATE_CACHE_SECONDS: int = Field(default=3600) + VERIFY_WEBHOOK_SIGNATURE: bool = Field(default=True) + WEBHOOK_URL: Optional[str] = None + TRUSTED_IPS: str = Field(default="") + + @field_validator("LIFETIME_SECONDS", mode="before") + @classmethod + def _clamp_lifetime(cls, v): + if isinstance(v, str): + v = v.strip() + try: + value = int(v) + except (TypeError, ValueError): + return 3600 + return min(2_592_000, max(300, value)) + + @field_validator("RECV_WINDOW_MS", mode="before") + @classmethod + def _clamp_recv_window(cls, v): + if isinstance(v, str): + v = v.strip() + try: + value = int(v) + except (TypeError, ValueError): + return 5000 + return min(60_000, max(1000, value)) + + @field_validator("EXCHANGE_RATE_CACHE_SECONDS", mode="before") + @classmethod + def _clamp_exchange_rate_cache(cls, v): + if isinstance(v, str): + v = v.strip() + try: + value = int(v) + except (TypeError, ValueError): + return 3600 + return min(86_400, max(60, value)) + + @field_validator( + "API_KEY", + "SECRET_KEY", + "INVOICE_TYPE", + "WEBHOOK_URL", + mode="before", + ) + @classmethod + def _strip_optional(cls, v): + if isinstance(v, str) and not v.strip(): + return None + return v + + @field_validator("INVOICE_TYPE", mode="before") + @classmethod + def _normalize_invoice_type(cls, v): + if isinstance(v, str): + value = v.strip().upper() + return value or None + return v + + @property + def webhook_path(self) -> str: + return "/webhook/paykilla" + + def full_webhook_url(self, base: Optional[str]) -> Optional[str]: + if self.WEBHOOK_URL: + return self.WEBHOOK_URL.rstrip("/") + if not base: + return None + return f"{base.rstrip('/')}{self.webhook_path}" + + @property + def trusted_ips_list(self) -> List[str]: + return [item.strip() for item in (self.TRUSTED_IPS or "").split(",") if item.strip()] + + +class PaykillaPresentation(ProviderEnvConfig): + """Admin-tunable button text/icon overrides for PayKilla.""" + + model_config = SettingsConfigDict( + env_file=provider_env_file(), + env_file_encoding="utf-8", + env_prefix="PAYMENT_PAYKILLA_", + extra="ignore", + ) + + WEBAPP_LABEL_RU: Optional[str] = None + WEBAPP_LABEL_EN: Optional[str] = None + WEBAPP_ICON: Optional[str] = None + TELEGRAM_LABEL_RU: Optional[str] = None + TELEGRAM_LABEL_EN: Optional[str] = None + TELEGRAM_EMOJI: Optional[str] = None + + +def _normalize_paykilla_text(value: Any) -> str: + text = str(value or "") + text = text.translate(_CYRILLIC_TO_LATIN) + text = re.sub(r"[-\u2010-\u2015]", " ", text) + text = text.encode("ascii", "ignore").decode("ascii") + text = re.sub(r"[^A-Za-z0-9_\s.,]", "", text) + return re.sub(r"\s+", " ", text).strip() + + +def _clean_paykilla_text(value: Any, *, fallback: str, max_length: int = 255) -> str: + text = _normalize_paykilla_text(value) + fallback_text = _normalize_paykilla_text(fallback) or "Payment" + text = re.sub(r"\s+", " ", text).strip() + if not text: + text = fallback_text + return text[:max_length].strip() or fallback_text[:max_length].strip() or "Payment" + + +def _invoice_text(title: Any, payment_db_id: int) -> str: + project_title = _clean_paykilla_text(title, fallback="Minishop") + return _clean_paykilla_text( + f"{project_title} payment {payment_db_id}", + fallback=f"Payment {payment_db_id}", + ) + + +def _payment_currencies(config: PaykillaConfig) -> List[str]: + currencies = list(parse_supported_currency_codes(config.PAYMENT_CURRENCIES)) + return currencies or ["USDTTRC"] + + +def _invoice_currencies(config: PaykillaConfig) -> tuple[str, ...]: + currencies = parse_supported_currency_codes(config.INVOICE_CURRENCIES) + return currencies or parse_supported_currency_codes(PAYKILLA_DEFAULT_INVOICE_CURRENCIES) + + +def _target_invoice_currency(config: PaykillaConfig, payment_currency: str) -> str: + payment_currency = normalize_payment_currency_code(payment_currency) + invoice_currencies = _invoice_currencies(config) + if payment_currency in invoice_currencies: + return payment_currency + fallback = normalize_payment_currency_code(config.CURRENCY, default="") + if fallback and fallback in invoice_currencies: + return fallback + return invoice_currencies[0] if invoice_currencies else payment_currency + + +def _invoice_type_for(config: PaykillaConfig, currency: str) -> str: + explicit = (config.INVOICE_TYPE or "").strip().upper() + if explicit in {"FIAT_BASED", "FIXED_AMOUNT", "OPEN_AMOUNT"}: + return explicit + return "FIAT_BASED" if currency in _FIAT_CURRENCIES else "FIXED_AMOUNT" + + +def _sign_query(timestamp_ms: int, recv_window_ms: int, secret_key: str) -> Tuple[str, str]: + query = urlencode( + [ + ("timestamp", str(timestamp_ms)), + ("recvWindow", str(recv_window_ms)), + ] + ) + signature = hmac.new(secret_key.encode("utf-8"), query.encode("utf-8"), hashlib.sha256) + return query, signature.hexdigest() + + +def _webhook_signature( + *, + timestamp: str, + method: str, + url: str, + raw_body: bytes, + secret_key: str, +) -> str: + message = f"{timestamp}{method.upper()}{url}".encode("utf-8") + raw_body + return hmac.new(secret_key.encode("utf-8"), message, hashlib.sha256).hexdigest() + + +def _signature_preview(signature: str) -> str: + signature = str(signature or "") + if len(signature) <= 12: + return signature + return f"{signature[:6]}...{signature[-6:]}" + + +def _response_invoice_data(response_data: Dict[str, Any]) -> Dict[str, Any]: + data = response_data.get("data") if isinstance(response_data, dict) else None + if isinstance(data, dict) and data.get("id"): + return data + return response_data if isinstance(response_data, dict) else {} + + +def _debug_invoice_body(body: Dict[str, Any]) -> str: + return json.dumps(body, ensure_ascii=True, sort_keys=True) + + +def _decimal_from_api(value: Any) -> Optional[Decimal]: + try: + decimal_value = Decimal(str(value)) + except (InvalidOperation, TypeError, ValueError): + return None + if not decimal_value.is_finite(): + return None + return decimal_value + + +class PaykillaService(HttpClientMixin): + def __init__( + self, + *, + bot: Bot, + settings: Settings, + config: PaykillaConfig, + i18n: JsonI18n, + async_session_factory: sessionmaker, + subscription_service: SubscriptionService, + referral_service: ReferralService, + default_return_url: str, + ): + self.bot = bot + self.settings = settings + self.config = config + self.i18n = i18n + self.async_session_factory = async_session_factory + self.subscription_service = subscription_service + self.referral_service = referral_service + self._default_return_url = default_return_url + self._exchange_rate_cache: Dict[tuple[str, str], tuple[float, Decimal]] = {} + self._currency_cache: tuple[float, List[Dict[str, Any]]] = (0, []) + + self._init_http_client(total_timeout=20) + if not self.configured: + logging.warning( + "PaykillaService initialized but not fully configured. Payments disabled." + ) + + @property + def configured(self) -> bool: + return bool(provider_runtime_enabled(self.config) and self.api_key and self.secret_key) + + @property + def base_url(self) -> str: + return (self.config.BASE_URL or "https://account-api.paykilla.com").rstrip("/") + + @property + def widget_url(self) -> str: + return (self.config.WIDGET_URL or "https://gopay.paykilla.com").rstrip("/") + + @property + def api_key(self) -> str: + return (self.config.API_KEY or "").strip() + + @property + def secret_key(self) -> str: + return (self.config.SECRET_KEY or "").strip() + + @property + def currency(self) -> str: + return normalize_payment_currency_code(self.config.CURRENCY or "USD") + + @property + def verify_webhook_signature(self) -> bool: + return self.config.VERIFY_WEBHOOK_SIGNATURE + + def _signed_invoice_url(self) -> str: + timestamp_ms = int(time.time() * 1000) + recv_window_ms = int(self.config.RECV_WINDOW_MS) + query, signature = _sign_query(timestamp_ms, recv_window_ms, self.secret_key) + return f"{self.base_url}/api/v2/invoice?{query}&signature={signature}" + + def _signed_currency_url(self) -> str: + timestamp_ms = int(time.time() * 1000) + recv_window_ms = int(self.config.RECV_WINDOW_MS) + query, signature = _sign_query(timestamp_ms, recv_window_ms, self.secret_key) + return f"{self.base_url}/api/v2/currency?{query}&signature={signature}" + + def _exchange_rate_url(self, source_currency: str, target_currency: str) -> str: + template = self.config.EXCHANGE_RATE_URL or PAYKILLA_DEFAULT_EXCHANGE_RATE_URL + return template.format(source=source_currency, target=target_currency) + + async def _exchange_rate(self, source_currency: str, target_currency: str) -> Decimal: + source_currency = normalize_payment_currency_code(source_currency) + target_currency = normalize_payment_currency_code(target_currency) + if source_currency == target_currency: + return Decimal("1") + + cache_key = (source_currency, target_currency) + cache_seconds = int(self.config.EXCHANGE_RATE_CACHE_SECONDS) + now = time.time() + cache = getattr(self, "_exchange_rate_cache", None) + if cache is None: + cache = {} + self._exchange_rate_cache = cache + cached = cache.get(cache_key) + if cached and now - cached[0] < cache_seconds: + return cached[1] + + session = await self._get_session() + url = self._exchange_rate_url(source_currency, target_currency) + async with session.get(url) as response: + response_text = await response.text() + try: + response_data = json.loads(response_text) if response_text else {} + except json.JSONDecodeError as exc: + raise ValueError("exchange_rate_invalid_json") from exc + if response.status != 200 or response_data.get("result") != "success": + logging.error( + "Paykilla exchange rate request failed " + "(status=%s, body=%s, source=%s, target=%s)", + response.status, + response_data, + source_currency, + target_currency, + ) + raise ValueError("exchange_rate_unavailable") + rates = response_data.get("rates") if isinstance(response_data, dict) else None + target_rate = rates.get(target_currency) if isinstance(rates, dict) else None + rate = _decimal_from_api(target_rate) + if rate is None or rate <= 0: + raise ValueError("exchange_rate_missing") + + cache[cache_key] = (now, rate) + return rate + + async def _paykilla_currencies(self) -> List[Dict[str, Any]]: + cache_seconds = int(self.config.EXCHANGE_RATE_CACHE_SECONDS) + now = time.time() + cached_at, cached_data = getattr(self, "_currency_cache", (0, [])) + if cached_data and now - cached_at < cache_seconds: + return cached_data + + headers = {"X-API-KEY": self.api_key} + session = await self._get_session() + try: + async with session.get(self._signed_currency_url(), headers=headers) as response: + response_text = await response.text() + try: + response_data = json.loads(response_text) if response_text else [] + except json.JSONDecodeError: + logging.warning( + "Paykilla currency metadata request returned invalid JSON: %s", + response_text, + ) + return cached_data + if response.status != 200 or not isinstance(response_data, list): + logging.warning( + "Paykilla currency metadata request failed " + "(status=%s, body=%s)", + response.status, + response_data, + ) + return cached_data + except Exception: + logging.exception("Paykilla currency metadata request failed.") + return cached_data + + self._currency_cache = (now, response_data) + return response_data + + async def _currency_info_for(self, currency: str) -> Optional[Dict[str, Any]]: + currency = normalize_payment_currency_code(currency) + for item in await self._paykilla_currencies(): + if not isinstance(item, dict): + continue + if normalize_payment_currency_code(item.get("ticker"), default="") == currency: + return item + return None + + async def _invoice_amount_bounds_error( + self, *, amount: Decimal, currency: str + ) -> Optional[Dict[str, Any]]: + info = await self._currency_info_for(currency) + if not info: + return None + minimum = _decimal_from_api(info.get("invoiceMin")) + maximum = _decimal_from_api(info.get("invoiceMax")) + if minimum is not None and amount < minimum: + return { + "message": "invoice_amount_below_minimum", + "currency": currency, + "amount": str(amount), + "minimum": str(format_decimal_amount(minimum)), + } + if maximum is not None and amount > maximum: + return { + "message": "invoice_amount_above_maximum", + "currency": currency, + "amount": str(amount), + "maximum": str(format_decimal_amount(maximum)), + } + return None + + async def _invoice_amount_and_currency( + self, *, amount: float, payment_currency: str + ) -> tuple[Decimal, str]: + payment_currency = normalize_payment_currency_code(payment_currency or self.currency) + invoice_currency = _target_invoice_currency(self.config, payment_currency) + invoice_amount = format_decimal_amount(amount) + if invoice_currency == payment_currency: + return invoice_amount, invoice_currency + + rate = await self._exchange_rate(payment_currency, invoice_currency) + converted_amount = format_decimal_amount(invoice_amount * rate) + logging.info( + "Paykilla invoice currency conversion: payment=%s %s, invoice=%s %s, rate=%s", + invoice_amount, + payment_currency, + converted_amount, + invoice_currency, + rate, + ) + return converted_amount, invoice_currency + + def _invoice_body( + self, + *, + payment_db_id: int, + amount: Any, + currency: Optional[str], + description: str, + ) -> Dict[str, Any]: + currency_code = normalize_payment_currency_code(currency or self.currency) + invoice_text = _invoice_text(getattr(self.settings, "WEBAPP_TITLE", None), payment_db_id) + body: Dict[str, Any] = { + "type": _invoice_type_for(self.config, currency_code), + "purpose": invoice_text, + "currency": currency_code, + "totalPrice": str(format_decimal_amount(amount)), + "paymentCurrencies": _payment_currencies(self.config), + "clientOrderId": str(payment_db_id), + "userPaysServiceFee": bool(self.config.USER_PAYS_SERVICE_FEE), + "userPaysNetworkFee": bool(self.config.USER_PAYS_NETWORK_FEE), + "description": invoice_text, + } + if self.config.LIFETIME_SECONDS: + expires_at = datetime.now(timezone.utc) + timedelta( + seconds=int(self.config.LIFETIME_SECONDS) + ) + body["expiredAt"] = expires_at.isoformat().replace("+00:00", "Z") + return body + + async def create_payment_link( + self, + *, + payment_db_id: int, + amount: float, + currency: Optional[str], + description: str, + url_callback: Optional[str] = None, + ) -> Tuple[bool, Dict[str, Any]]: + if not self.configured: + logging.error("PaykillaService is not configured. Cannot create payment link.") + return False, {"message": "service_not_configured"} + + currency_code = normalize_payment_currency_code(currency or self.currency) + supported = parse_supported_currency_codes(self.config.SUPPORTED_CURRENCIES) + if supported and currency_code not in supported: + return False, { + "message": "unsupported_currency", + "currency": currency_code, + "supported_currencies": list(supported), + } + + try: + invoice_amount, invoice_currency = await self._invoice_amount_and_currency( + amount=amount, + payment_currency=currency_code, + ) + except Exception as exc: + logging.exception( + "Paykilla create_payment_link: failed to resolve invoice currency " + "(amount=%s currency=%s target=%s).", + amount, + currency_code, + _target_invoice_currency(self.config, currency_code), + ) + return False, {"message": str(exc) or "exchange_rate_unavailable"} + + bounds_error = await self._invoice_amount_bounds_error( + amount=invoice_amount, + currency=invoice_currency, + ) + if bounds_error: + logging.error( + "Paykilla create_payment_link: invoice amount violates PayKilla limits " + "(details=%s, payment_amount=%s, payment_currency=%s)", + bounds_error, + format_decimal_amount(amount), + currency_code, + ) + return False, bounds_error + + body = self._invoice_body( + payment_db_id=payment_db_id, + amount=invoice_amount, + currency=invoice_currency, + description=description, + ) + headers = { + "X-API-KEY": self.api_key, + "Content-Type": "application/json", + } + session = await self._get_session() + try: + async with session.post( + self._signed_invoice_url(), + json=body, + headers=headers, + ) as response: + response_text = await response.text() + try: + response_data = json.loads(response_text) if response_text else {} + except json.JSONDecodeError: + logging.error("Paykilla create_payment_link: invalid JSON: %s", response_text) + return False, { + "status": response.status, + "message": "invalid_json", + "raw": response_text, + } + invoice = _response_invoice_data(response_data) + invoice_id = first_value(invoice, "id") + if response.status not in {200, 201} or not invoice_id: + logging.error( + "Paykilla create_payment_link: API error " + "(status=%s, body=%s, request_body=%s)", + response.status, + response_data, + _debug_invoice_body(body), + ) + return False, {"status": response.status, "message": response_data} + invoice["payment_url"] = f"{self.widget_url}/{invoice_id}" + return True, invoice + except Exception as exc: + logging.exception("Paykilla create_payment_link: request failed.") + return False, {"message": str(exc)} + + def _webhook_url_for_request(self, request: web.Request) -> Optional[str]: + configured = self.config.full_webhook_url(getattr(self.settings, "WEBHOOK_BASE_URL", None)) + if configured: + return configured + try: + return f"{request.scheme}://{request.host}{request.path_qs}" + except Exception: + return None + + def _verify_webhook_signature(self, request: web.Request, raw_body: bytes) -> bool: + timestamp = str(request.headers.get("X-API-TIMESTAMP") or "").strip() + signature = str(request.headers.get("X-API-SIGN") or "").strip().lower() + recv_window_raw = str(request.headers.get("X-API-RECV-WINDOW") or "5000").strip() + api_key = str(request.headers.get("X-API-KEY") or "").strip() + if not timestamp or not signature: + return False + if api_key and not hmac.compare_digest(api_key, self.api_key): + logging.warning("Paykilla webhook: X-API-KEY does not match configured API key.") + return False + try: + timestamp_ms = int(timestamp) + recv_window_ms = int(recv_window_raw) + except ValueError: + return False + now_ms = int(time.time() * 1000) + if abs(now_ms - timestamp_ms) > max(recv_window_ms, 1000): + logging.warning( + "Paykilla webhook: timestamp outside recv window " + "(timestamp=%s now=%s recvWindow=%s).", + timestamp_ms, + now_ms, + recv_window_ms, + ) + return False + webhook_url = self._webhook_url_for_request(request) + if not webhook_url: + logging.warning("Paykilla webhook: cannot resolve webhook URL for signature.") + return False + expected = _webhook_signature( + timestamp=timestamp, + method=request.method or "POST", + url=webhook_url, + raw_body=raw_body, + secret_key=self.secret_key, + ) + if hmac.compare_digest(expected, signature): + return True + logging.warning( + "Paykilla webhook: invalid signature (received=%s expected=%s url=%s).", + _signature_preview(signature), + _signature_preview(expected), + webhook_url, + ) + return False + + async def webhook_route(self, request: web.Request) -> web.Response: + if not self.configured: + return web.Response(status=503, text="paykilla_disabled") + + client_ip = request_client_ip(request, trusted_proxies=self.settings.trusted_proxies) + trusted = self.config.trusted_ips_list + if trusted and not ip_in_allowlist(client_ip, trusted): + logging.warning( + "Paykilla webhook denied from unauthorized IP source " + "(client_ip=%s remote=%s x_forwarded_for=%s trusted_ips=%s trusted_proxies=%s).", + client_ip, + request.remote, + request.headers.get("X-Forwarded-For"), + trusted, + self.settings.trusted_proxies, + ) + return web.Response(status=403, text="forbidden") + + raw_body = await request.read() + if self.verify_webhook_signature and not self._verify_webhook_signature( + request, + raw_body, + ): + return web.Response(status=403, text="invalid_signature") + + try: + payload = json.loads(raw_body.decode("utf-8")) + except Exception: + logging.exception("Paykilla webhook: failed to parse JSON.") + return web.Response(status=400, text="bad_request") + + if not isinstance(payload, dict): + return web.Response(status=400, text="bad_request") + + event_type = str(payload.get("eventType") or "").strip().upper() + data = payload.get("data") + if not event_type or not isinstance(data, dict): + logging.error("Paykilla webhook: missing event envelope fields: %s", payload) + return web.Response(status=400, text="missing_fields") + + if event_type not in _SUCCESS_EVENTS and event_type not in _FAILED_EVENTS: + logging.info("Paykilla webhook: intermediate event '%s' ignored.", event_type) + return web.Response(text="status_ignored") + + data_type = str(data.get("type") or "").strip().upper() + if data_type and data_type != "INVOICE": + logging.info( + "Paykilla webhook: non-invoice event '%s' ignored (type=%s).", + event_type, + data_type, + ) + return web.Response(text="status_ignored") + + invoice_id = str(data.get("id") or "").strip() + client_order_id = data.get("clientOrderId") + amount_raw = data.get("amount") or data.get("expectedAmount") + invoice_currency = normalize_payment_currency_code(data.get("currency") or self.currency) + + if not (invoice_id or client_order_id): + logging.error("Paykilla webhook: missing invoice ids: %s", payload) + return web.Response(status=400, text="missing_fields") + + async with self.async_session_factory() as session: + payment = await lookup_payment_by_order_or_provider_id( + session, + order_id_raw=client_order_id, + provider_payment_id=invoice_id or None, + ) + if not payment: + logging.error( + "Paykilla webhook: payment not found (clientOrderId=%s, invoice_id=%s)", + client_order_id, + invoice_id, + ) + return web.Response(status=404, text="payment_not_found") + + if payment.status == "succeeded" and event_type in _SUCCESS_EVENTS: + return web.Response(text="ok") + + resolved_id = invoice_id or str(payment.payment_id) + + if event_type in _SUCCESS_EVENTS: + payment_currency = normalize_payment_currency_code(payment.currency) + if amount_raw is not None and invoice_currency == payment_currency: + try: + if not decimal_amounts_equal(amount_raw, payment.amount): + logging.warning( + "Paykilla webhook: amount mismatch for payment %s " + "(expected %s, got %s)", + payment.payment_id, + format_decimal_amount(payment.amount), + format_decimal_amount(amount_raw), + ) + except Exception as exc: + logging.warning( + "Paykilla webhook: failed to compare amounts for %s: %s", + payment.payment_id, + exc, + ) + elif amount_raw is not None: + logging.info( + "Paykilla webhook: invoice amount is in %s while payment record is in %s; " + "skipping direct amount comparison for payment %s.", + invoice_currency, + payment_currency, + payment.payment_id, + ) + + try: + await payment_dal.update_provider_payment_and_status( + session, + payment.payment_id, + resolved_id, + "succeeded", + ) + await session.commit() + except Exception: + await session.rollback() + logging.exception( + "Paykilla webhook: failed to mark payment %s as succeeded.", + resolved_id, + ) + return web.Response(status=500, text="processing_error") + + sale_mode = payment.sale_mode or ( + "traffic" if self.settings.traffic_sale_mode else "subscription" + ) + payment_units = payment_units_for_activation(payment, sale_mode) + + outcome = await finalize_successful_payment( + PaymentSuccessRequest( + bot=self.bot, + settings=self.settings, + i18n=self.i18n, + session=session, + subscription_service=self.subscription_service, + referral_service=self.referral_service, + payment=payment, + user_id=payment.user_id, + amount=float(payment.amount), + currency=str(payment.currency), + sale_mode=sale_mode, + months=payment_units, + traffic_amount=float(payment_units), + provider_subscription="paykilla", + provider_notification="paykilla", + db_user=payment.user, + log_prefix="Paykilla webhook", + ) + ) + if outcome is None: + return web.Response(status=500, text="processing_error") + return web.Response(text="ok") + + if event_type in _FAILED_EVENTS: + try: + await payment_dal.update_provider_payment_and_status( + session, + payment.payment_id, + resolved_id, + "failed", + ) + await session.commit() + except Exception: + await session.rollback() + logging.exception( + "Paykilla webhook: failed to mark payment %s as failed.", + resolved_id, + ) + return web.Response(status=500, text="processing_error") + await notify_user_payment_failed( + bot=self.bot, + settings=self.settings, + i18n=self.i18n, + session=session, + payment=payment, + ) + return web.Response(text="ok") + + +@router.callback_query(F.data.startswith("pay_paykilla:")) +async def pay_paykilla_callback_handler( + callback: types.CallbackQuery, + settings: Settings, + i18n_data: dict, + paykilla_service: PaykillaService, + session: AsyncSession, +): + current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE) + i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") + translator = make_translator(i18n, current_lang) + + if not i18n or not callback.message: + await notify_callback_parse_error(callback, translator) + return + + if not SPEC.is_available_to_user( + settings, + user_id=callback.from_user.id, + require_configured=False, + ): + await notify_service_unavailable(callback, translator) + return + + if not paykilla_service or not paykilla_service.configured: + logging.error("Paykilla service is not configured or unavailable.") + await notify_service_unavailable(callback, translator) + return + + parts = parse_payment_callback(callback.data or "") + if not parts: + logging.error("Invalid pay_paykilla data in callback: %s", callback.data) + await notify_callback_parse_error(callback, translator) + return + parts, hwid_quote = await quote_hwid_callback_parts( + session=session, + user_id=callback.from_user.id, + parts=parts, + subscription_service=paykilla_service.subscription_service, + currency=default_currency_key_for_settings(settings), + ) + if not parts: + await notify_callback_parse_error(callback, translator) + return + + currency_code = default_payment_currency_code_for_settings(settings) + payment_description = describe_payment(translator, parts) + record_payload = build_payment_record_payload( + user_id=callback.from_user.id, + amount=parts.price, + currency=currency_code, + status="pending_paykilla", + description=payment_description, + months=parts.months, + provider="paykilla", + sale_mode=parts.sale_mode, + hwid_quote=hwid_quote, + ) + + try: + payment_record = await payment_dal.create_payment_record(session, record_payload) + await session.commit() + except Exception: + await session.rollback() + logging.exception( + "Paykilla: failed to create payment record for user %s.", callback.from_user.id + ) + await notify_payment_record_failure(callback, translator) + return + + success, response_data = await paykilla_service.create_payment_link( + payment_db_id=payment_record.payment_id, + amount=parts.price, + currency=currency_code, + description=payment_description, + url_callback=paykilla_service.config.full_webhook_url(settings.WEBHOOK_BASE_URL), + ) + await render_link_or_fail( + callback, + translator=translator, + current_lang=current_lang, + i18n=i18n, + parts=parts, + session=session, + payment=payment_record, + api_success=success, + payment_url=first_value(response_data, "payment_url"), + provider_payment_id=first_value(response_data, "id"), + log_prefix=_LOG, + ) + + +async def create_webapp_payment(ctx: WebAppPaymentContext) -> web.Response: + settings: Settings = ctx.request.app["settings"] + service: PaykillaService = ctx.request.app["paykilla_service"] + if not service or not service.configured: + return payment_unavailable() + + currency = ctx.currency or default_payment_currency_code_for_settings(settings) + try: + payment = await create_webapp_payment_record( + ctx, + amount=ctx.price, + currency=currency, + status="pending_paykilla", + provider="paykilla", + ) + success, response_data = await service.create_payment_link( + payment_db_id=payment.payment_id, + amount=ctx.price, + currency=currency, + description=ctx.description, + url_callback=service.config.full_webhook_url(settings.WEBHOOK_BASE_URL), + ) + except Exception: + await ctx.session.rollback() + logging.exception("Paykilla WebApp payment failed") + return payment_failed() + + return await finalize_webapp_link_payment( + session=ctx.session, + payment=payment, + api_success=success, + payment_url=first_value(response_data, "payment_url") if success else None, + provider_payment_id=first_value(response_data, "id"), + log_prefix="Paykilla", + ) + + +async def paykilla_webhook_route(request: web.Request) -> web.Response: + service: PaykillaService = request.app["paykilla_service"] + return await service.webhook_route(request) + + +def create_service(ctx: ServiceFactoryContext) -> PaykillaService: + bundle = ctx.config_for("paykilla_service") + config = ( + bundle.config if bundle and isinstance(bundle.config, PaykillaConfig) else PaykillaConfig() + ) + return PaykillaService( + bot=ctx.bot, + settings=ctx.settings, + config=config, + i18n=ctx.i18n, + async_session_factory=ctx.async_session_factory, + subscription_service=ctx.subscription_service, + referral_service=ctx.referral_service, + default_return_url=ctx.bot_username_for_default_return, + ) + + +_PRESENTATION_MANIFEST = tuple( + ProviderManifestField( + key=key, + type=type_, + label=label, + description=description, + placeholder=placeholder, + subsection="PayKilla", + target="presentation", + attr=attr, + ) + for key, type_, label, description, placeholder, attr in ( + ( + "PAYMENT_PAYKILLA_WEBAPP_LABEL_RU", + "string", + "WebApp button text (RU)", + "Custom Russian text shown in the Web App payment method button.", + "", + "WEBAPP_LABEL_RU", + ), + ( + "PAYMENT_PAYKILLA_WEBAPP_LABEL_EN", + "string", + "WebApp button text (EN)", + "Custom English text shown in the Web App payment method button.", + "", + "WEBAPP_LABEL_EN", + ), + ( + "PAYMENT_PAYKILLA_WEBAPP_ICON", + "icon", + "WebApp button icon", + "Lucide icon name rendered inside the Web App payment method button.", + "Bitcoin", + "WEBAPP_ICON", + ), + ( + "PAYMENT_PAYKILLA_TELEGRAM_LABEL_RU", + "string", + "Telegram button text (RU)", + "Custom Russian text shown in Telegram bot payment buttons.", + "", + "TELEGRAM_LABEL_RU", + ), + ( + "PAYMENT_PAYKILLA_TELEGRAM_LABEL_EN", + "string", + "Telegram button text (EN)", + "Custom English text shown in Telegram bot payment buttons.", + "", + "TELEGRAM_LABEL_EN", + ), + ( + "PAYMENT_PAYKILLA_TELEGRAM_EMOJI", + "string", + "Telegram button emoji", + "Emoji prepended to the Telegram bot payment button when customized.", + "", + "TELEGRAM_EMOJI", + ), + ) +) + + +_CONFIG_MANIFEST = ( + ProviderManifestField( + "PAYKILLA_ENABLED", "bool", "Enabled", subsection="PayKilla", attr="ENABLED" + ), + ProviderManifestField( + "PAYKILLA_API_KEY", + "string", + "API key", + description="PayKilla public HMAC key with INVOICE permission.", + subsection="PayKilla", + secret=True, + attr="API_KEY", + ), + ProviderManifestField( + "PAYKILLA_SECRET_KEY", + "string", + "Secret key", + description="PayKilla HMAC secret key. Never expose it client-side.", + subsection="PayKilla", + secret=True, + attr="SECRET_KEY", + ), + ProviderManifestField( + "PAYKILLA_BASE_URL", + "url", + "Base URL", + placeholder="https://account-api.paykilla.com", + subsection="PayKilla", + attr="BASE_URL", + ), + ProviderManifestField( + "PAYKILLA_WIDGET_URL", + "url", + "Widget URL", + placeholder="https://gopay.paykilla.com", + subsection="PayKilla", + attr="WIDGET_URL", + ), + ProviderManifestField( + "PAYKILLA_CURRENCY", + "string", + "Fallback invoice currency", + description=( + "Currency used for PayKilla invoice creation when the tariff currency is not " + "accepted by PayKilla as an invoice currency. Default: USD." + ), + placeholder="USD", + subsection="PayKilla", + attr="CURRENCY", + ), + ProviderManifestField( + "PAYKILLA_INVOICE_CURRENCIES", + "string", + "PayKilla invoice currencies", + description=( + "Comma-separated currencies accepted by PayKilla as invoice currency. " + "Payments in other tariff currencies are converted to PAYKILLA_CURRENCY." + ), + placeholder=PAYKILLA_DEFAULT_INVOICE_CURRENCIES, + subsection="PayKilla", + attr="INVOICE_CURRENCIES", + ), + ProviderManifestField( + "PAYKILLA_SUPPORTED_CURRENCIES", + "string", + "Supported tariff currencies", + description=( + "Comma-separated tariff/payment currencies that may use PayKilla. " + "Unsupported PayKilla invoice currencies are converted before invoice creation." + ), + placeholder=PAYKILLA_DEFAULT_SUPPORTED_CURRENCIES, + subsection="PayKilla", + attr="SUPPORTED_CURRENCIES", + ), + ProviderManifestField( + "PAYKILLA_PAYMENT_CURRENCIES", + "string", + "Accepted crypto tickers", + description=( + "Comma-separated PayKilla tickers sent as paymentCurrencies, e.g. USDTTRC. " + "Add BTC/ETH only when enabled for the merchant account." + ), + placeholder=PAYKILLA_DEFAULT_PAYMENT_CURRENCIES, + subsection="PayKilla", + attr="PAYMENT_CURRENCIES", + ), + ProviderManifestField( + "PAYKILLA_INVOICE_TYPE", + "string", + "Invoice type", + description="Optional override: FIAT_BASED, FIXED_AMOUNT, or OPEN_AMOUNT.", + subsection="PayKilla", + attr="INVOICE_TYPE", + choices=( + ("", "Auto"), + ("FIAT_BASED", "FIAT_BASED"), + ("FIXED_AMOUNT", "FIXED_AMOUNT"), + ("OPEN_AMOUNT", "OPEN_AMOUNT"), + ), + ), + ProviderManifestField( + "PAYKILLA_LIFETIME_SECONDS", + "int", + "Invoice lifetime (seconds)", + description="Used to send expiredAt to PayKilla.", + subsection="PayKilla", + min=300, + max=2_592_000, + attr="LIFETIME_SECONDS", + ), + ProviderManifestField( + "PAYKILLA_RECV_WINDOW_MS", + "int", + "Request recvWindow (ms)", + description="Validity window for signed PayKilla API requests.", + subsection="PayKilla", + min=1000, + max=60_000, + attr="RECV_WINDOW_MS", + ), + ProviderManifestField( + "PAYKILLA_USER_PAYS_SERVICE_FEE", + "bool", + "User pays service fee", + subsection="PayKilla", + attr="USER_PAYS_SERVICE_FEE", + ), + ProviderManifestField( + "PAYKILLA_USER_PAYS_NETWORK_FEE", + "bool", + "User pays network fee", + subsection="PayKilla", + attr="USER_PAYS_NETWORK_FEE", + ), + ProviderManifestField( + "PAYKILLA_EXCHANGE_RATE_URL", + "url", + "Exchange rate URL", + description=( + "No-key exchange rate endpoint used when tariff currency must be converted. " + "Supports {source} and {target} placeholders." + ), + placeholder=PAYKILLA_DEFAULT_EXCHANGE_RATE_URL, + subsection="PayKilla", + attr="EXCHANGE_RATE_URL", + ), + ProviderManifestField( + "PAYKILLA_EXCHANGE_RATE_CACHE_SECONDS", + "int", + "Exchange rate cache (seconds)", + description="How long PayKilla currency conversion rates and PayKilla limits are cached.", + subsection="PayKilla", + min=60, + max=86_400, + attr="EXCHANGE_RATE_CACHE_SECONDS", + ), + ProviderManifestField( + "PAYKILLA_VERIFY_WEBHOOK_SIGNATURE", + "bool", + "Verify webhook signature", + subsection="PayKilla", + attr="VERIFY_WEBHOOK_SIGNATURE", + ), + ProviderManifestField( + "PAYKILLA_WEBHOOK_URL", + "url", + "Exact webhook URL", + description=( + "Optional override for signature verification. Leave empty to use " + "WEBHOOK_BASE_URL + /webhook/paykilla." + ), + subsection="PayKilla", + attr="WEBHOOK_URL", + ), + ProviderManifestField( + "PAYKILLA_TRUSTED_IPS", + "string", + "Trusted IPs", + description="Optional comma-separated IP addresses accepted for PayKilla webhooks.", + subsection="PayKilla", + attr="TRUSTED_IPS", + ), +) + + +SPEC = PaymentProviderSpec( + id="paykilla", + provider_key="paykilla", + label="PayKilla", + webapp_label="PayKilla", + webapp_labels={"ru": "PayKilla", "en": "PayKilla"}, + webapp_icon="Bitcoin", + telegram_labels={"ru": "PayKilla", "en": "PayKilla"}, + telegram_emoji="", + pending_status="pending_paykilla", + enabled=lambda config: bool(getattr(config, "ENABLED", False)), + service_key="paykilla_service", + callback_prefix="pay_paykilla", + router=router, + create_service=create_service, + webhook_path=lambda source: "/webhook/paykilla", + webhook_route=paykilla_webhook_route, + create_webapp_payment=create_webapp_payment, + emoji="", + config_class=PaykillaConfig, + presentation_class=PaykillaPresentation, + manifest_fields=_CONFIG_MANIFEST + _PRESENTATION_MANIFEST, + supported_currencies_resolver=lambda config: getattr( + config, "SUPPORTED_CURRENCIES", PAYKILLA_DEFAULT_SUPPORTED_CURRENCIES + ), + currency_support_note=( + "PayKilla invoice currency and paymentCurrencies availability can depend on " + "merchant account settings." + ), + currency_support_url="https://paykilla.gitbook.io/paykilla-docs/api-integration/supported-currencies", +) diff --git a/backend/bot/payment_providers/platega.py b/backend/bot/payment_providers/platega.py index 9fb8ab6..e4f15fc 100644 --- a/backend/bot/payment_providers/platega.py +++ b/backend/bot/payment_providers/platega.py @@ -609,6 +609,7 @@ async def _create_webapp_payment(ctx: WebAppPaymentContext, variant: str) -> web months=ctx.months, sale_mode=ctx.sale_mode, traffic_gb=ctx.traffic_gb, + hwid_device_count=ctx.hwid_device_count, ) payment = await create_webapp_payment_record( ctx, diff --git a/backend/bot/payment_providers/registry.py b/backend/bot/payment_providers/registry.py index 1767ca9..673a10e 100644 --- a/backend/bot/payment_providers/registry.py +++ b/backend/bot/payment_providers/registry.py @@ -2,7 +2,7 @@ from __future__ import annotations from typing import Any, Dict, Iterable, List, Mapping, Optional -from . import cryptopay, freekassa, heleket, platega, severpay, stars, wata, yookassa +from . import cryptopay, freekassa, heleket, paykilla, platega, severpay, stars, wata, yookassa from .base import ( PaymentProviderPresentation, PaymentProviderSpec, @@ -21,6 +21,7 @@ PAYMENT_PROVIDER_SPECS: tuple[PaymentProviderSpec, ...] = ( stars.SPEC, cryptopay.SPEC, heleket.SPEC, + paykilla.SPEC, ) diff --git a/backend/bot/payment_providers/shared/callbacks.py b/backend/bot/payment_providers/shared/callbacks.py index 66cc7c7..d646df2 100644 --- a/backend/bot/payment_providers/shared/callbacks.py +++ b/backend/bot/payment_providers/shared/callbacks.py @@ -8,8 +8,10 @@ from aiogram import types from sqlalchemy.ext.asyncio import AsyncSession from bot.keyboards.inline.user_keyboards import ( + HWID_RENEWAL_TOKEN, get_payment_url_keyboard, payment_methods_back_callback, + sale_mode_has_token, ) from bot.middlewares.i18n import JsonI18n from db.dal import payment_dal @@ -123,6 +125,27 @@ async def quote_hwid_callback_parts( subscription_service, currency: str = "rub", ) -> tuple[Optional[PaymentCallbackParts], Optional[dict]]: + base = sale_mode_base(parts.sale_mode) + if base == "subscription" and sale_mode_has_token(parts.sale_mode, HWID_RENEWAL_TOKEN): + try: + months = int(parts.months) + except (TypeError, ValueError): + return None, None + quote = await subscription_service.quote_hwid_device_renewal_for_subscription( + session, + user_id=user_id, + target_tariff_key=sale_mode_tariff_key(parts.sale_mode), + months=months, + currency=currency, + ) + if not quote: + return parts, None + quoted_parts = PaymentCallbackParts( + months=months, + price=float(parts.price or 0) + float(quote.get("price") or 0), + sale_mode=parts.sale_mode, + ) + return quoted_parts, quote if not sale_mode_is_hwid_devices(parts.sale_mode): return parts, None device_count = parse_positive_int_units(parts.months) diff --git a/backend/bot/payment_providers/shared/common.py b/backend/bot/payment_providers/shared/common.py index 53ac7b9..19680f8 100644 --- a/backend/bot/payment_providers/shared/common.py +++ b/backend/bot/payment_providers/shared/common.py @@ -100,6 +100,11 @@ def build_payment_record_payload( base = sale_mode_base(sale_mode) is_traffic = sale_mode_is_traffic(sale_mode) is_hwid = sale_mode_is_hwid_devices(sale_mode) + hwid_devices = int(float(months)) if is_hwid else None + if hwid_quote: + quote_devices = parse_positive_int_units(hwid_quote.get("device_count")) + if quote_devices is not None: + hwid_devices = quote_devices payload = { "user_id": user_id, "amount": amount, @@ -111,9 +116,9 @@ def build_payment_record_payload( "sale_mode": sale_mode, "tariff_key": sale_mode_tariff_key(sale_mode), "purchased_gb": float(months) if is_traffic else None, - "purchased_hwid_devices": int(float(months)) if is_hwid else None, + "purchased_hwid_devices": hwid_devices, } - if hwid_quote and is_hwid: + if hwid_quote and hwid_devices is not None: payload.update( { "hwid_valid_from": hwid_quote.get("valid_from"), @@ -164,14 +169,20 @@ def payment_record_amounts( months: Any, sale_mode: str, traffic_gb: Optional[float] = None, + hwid_device_count: Optional[int] = None, ) -> PaymentRecordAmounts: traffic_sale = sale_mode_is_traffic(sale_mode) hwid_devices_sale = sale_mode_is_hwid_devices(sale_mode) units = traffic_gb if traffic_sale and traffic_gb is not None else months + purchased_hwid_devices = int(float(months)) if hwid_devices_sale else None + if not hwid_devices_sale and hwid_device_count is not None: + parsed_hwid_devices = parse_positive_int_units(hwid_device_count) + if parsed_hwid_devices is not None: + purchased_hwid_devices = parsed_hwid_devices return PaymentRecordAmounts( months=int(float(units)) if traffic_sale else int(float(months)), purchased_gb=float(units) if traffic_sale else None, - purchased_hwid_devices=int(float(months)) if hwid_devices_sale else None, + purchased_hwid_devices=purchased_hwid_devices, tariff_key=sale_mode_tariff_key(sale_mode), traffic_sale=traffic_sale, hwid_devices_sale=hwid_devices_sale, @@ -281,6 +292,7 @@ async def create_webapp_payment_record( months=ctx.months, sale_mode=ctx.sale_mode, traffic_gb=ctx.traffic_gb, + hwid_device_count=ctx.hwid_device_count, ) return await create_base_payment_record( ctx.session, diff --git a/backend/bot/payment_providers/shared/success.py b/backend/bot/payment_providers/shared/success.py index 26e342a..317c399 100644 --- a/backend/bot/payment_providers/shared/success.py +++ b/backend/bot/payment_providers/shared/success.py @@ -156,6 +156,28 @@ def append_hwid_renewal_note( return f"{text}\n\n{note}" +def append_hwid_renewed_note( + text: str, + translator: Translator, + *, + count: Any, + valid_until: Optional[datetime], +) -> str: + try: + count_int = int(count or 0) + except (TypeError, ValueError): + count_int = 0 + if count_int <= 0: + return text + date_text = valid_until.strftime("%Y-%m-%d") if valid_until else "" + note = translator( + "payment_successful_hwid_devices_renewed_note", + count=format_human_units(count_int), + date=date_text, + ) + return f"{text}\n\n{note}" + + async def send_success_message_to_user( *, bot: Bot, @@ -320,8 +342,37 @@ async def finalize_successful_payment( req.log_prefix, req.payment.payment_id, ) + try: + await payment_dal.update_payment_status_by_db_id( + req.session, + req.payment.payment_id, + "activation_failed", + ) + await req.session.commit() + except Exception: + await req.session.rollback() + logging.exception( + "%s: failed to mark payment %s activation_failed.", + req.log_prefix, + req.payment.payment_id, + ) return None + try: + from bot.app.web.webapp.cache_helpers import invalidate_webapp_user_caches + + await invalidate_webapp_user_caches( + req.settings, + req.user_id, + include_devices=True, + ) + except Exception: + logging.exception( + "%s: failed to invalidate webapp caches for user %s.", + req.log_prefix, + req.user_id, + ) + db_user, language = await resolve_user_language( req.session, user_id=req.user_id, @@ -363,12 +414,20 @@ async def finalize_successful_payment( ) ) if is_subscription and activation: - success_text = append_hwid_renewal_note( - success_text, - translator, - count=activation.get("hwid_devices_renewal_recommended_count"), - valid_until=activation.get("hwid_devices_valid_until"), - ) + if activation.get("hwid_devices_renewed_count"): + success_text = append_hwid_renewed_note( + success_text, + translator, + count=activation.get("hwid_devices_renewed_count"), + valid_until=final_end_date or activation.get("hwid_devices_renewed_until"), + ) + else: + success_text = append_hwid_renewal_note( + success_text, + translator, + count=activation.get("hwid_devices_renewal_recommended_count"), + valid_until=activation.get("hwid_devices_valid_until"), + ) if req.text_prefix: success_text = f"{req.text_prefix}\n{success_text}" diff --git a/backend/bot/payment_providers/shared/webhooks.py b/backend/bot/payment_providers/shared/webhooks.py index 474df6b..204614e 100644 --- a/backend/bot/payment_providers/shared/webhooks.py +++ b/backend/bot/payment_providers/shared/webhooks.py @@ -51,7 +51,7 @@ async def notify_user_payment_failed( message_key: str = "payment_failed", ) -> None: """Send the localized ``payment_failed`` text to the user; never raises.""" - db_user = payment.user or await user_dal.get_user_by_id(session, payment.user_id) + db_user = await user_dal.get_user_by_id(session, payment.user_id) language = ( db_user.language_code if db_user and db_user.language_code else settings.DEFAULT_LANGUAGE ) diff --git a/backend/bot/payment_providers/stars.py b/backend/bot/payment_providers/stars.py index 0a69ad1..70c7064 100644 --- a/backend/bot/payment_providers/stars.py +++ b/backend/bot/payment_providers/stars.py @@ -344,6 +344,7 @@ async def create_webapp_payment(ctx: WebAppPaymentContext) -> web.Response: months=ctx.months, sale_mode=ctx.sale_mode, traffic_gb=ctx.traffic_gb, + hwid_device_count=ctx.hwid_device_count, ) payment = await create_webapp_payment_record( ctx, diff --git a/backend/bot/payment_providers/wata.py b/backend/bot/payment_providers/wata.py index 422db61..3d0109e 100644 --- a/backend/bot/payment_providers/wata.py +++ b/backend/bot/payment_providers/wata.py @@ -978,6 +978,7 @@ async def create_webapp_payment(ctx: WebAppPaymentContext) -> web.Response: months=ctx.months, sale_mode=ctx.sale_mode, traffic_gb=ctx.traffic_gb, + hwid_device_count=ctx.hwid_device_count, ) months_for_lookup = ( reuse_amounts.months if sale_mode_base(ctx.sale_mode) == "subscription" else None diff --git a/backend/bot/payment_providers/yookassa.py b/backend/bot/payment_providers/yookassa.py index 3c420fa..65f79b9 100644 --- a/backend/bot/payment_providers/yookassa.py +++ b/backend/bot/payment_providers/yookassa.py @@ -448,6 +448,36 @@ def _metadata_value_present(value: Optional[Any]) -> bool: return value is not None and str(value).strip() != "" +def _metadata_int(value: Optional[Any]) -> Optional[int]: + if not _metadata_value_present(value): + return None + try: + return int(float(str(value).strip())) + except (TypeError, ValueError): + return None + + +def _metadata_float(value: Optional[Any]) -> Optional[float]: + if not _metadata_value_present(value): + return None + try: + return float(str(value).strip()) + except (TypeError, ValueError): + return None + + +def _metadata_datetime(value: Optional[Any]) -> Optional[datetime]: + if not _metadata_value_present(value): + return None + try: + parsed = datetime.fromisoformat(str(value).strip().replace("Z", "+00:00")) + except (TypeError, ValueError): + return None + if parsed.tzinfo is None: + return parsed.replace(tzinfo=timezone.utc) + return parsed + + def _resolve_yookassa_activation_amounts( *, sale_mode_base: str, @@ -559,6 +589,11 @@ async def process_successful_payment( months_for_record = int(subscription_months) if sale_mode_base == "subscription" else 0 payment_value = float(amount_data.get("value", 0.0)) yk_payment_id_from_hook = payment_info_from_webhook.get("id") + hwid_valid_from = _metadata_datetime(metadata.get("hwid_valid_from")) + hwid_valid_until = _metadata_datetime(metadata.get("hwid_valid_until")) + hwid_pricing_period_months = _metadata_int(metadata.get("hwid_pricing_period_months")) + hwid_proration_ratio = _metadata_float(metadata.get("hwid_proration_ratio")) + hwid_full_price = _metadata_float(metadata.get("hwid_full_price")) if _is_hwid_device_sale_base(sale_mode_base) and hwid_devices_count <= 0: logging.error( @@ -574,6 +609,19 @@ async def process_successful_payment( yk_payment_id_from_hook, ) return + if sale_mode_base == "subscription" and hwid_devices_count > 0: + if ( + not hwid_valid_from + or not hwid_valid_until + or hwid_valid_from >= hwid_valid_until + or hwid_full_price is None + ): + logging.error( + "YooKassa subscription+HWID payment %s has invalid HWID metadata: %s", + yk_payment_id_from_hook, + metadata, + ) + return payment_record = None # If this is an auto-renewal (no payment_db_id in metadata), ensure a payment record exists @@ -600,6 +648,16 @@ async def process_successful_payment( or f"Auto-renewal for {months_for_record or subscription_months} months", provider="yookassa", provider_payment_id=yk_payment_id_from_hook, + sale_mode=sale_mode, + tariff_key=_sale_mode_tariff_key(sale_mode), + purchased_hwid_devices=( + hwid_devices_count if hwid_devices_count > 0 else None + ), + hwid_valid_from=hwid_valid_from, + hwid_valid_until=hwid_valid_until, + hwid_pricing_period_months=hwid_pricing_period_months, + hwid_proration_ratio=hwid_proration_ratio, + hwid_full_price=hwid_full_price, ) payment_db_id = payment_record.payment_id except Exception as e_ensure: @@ -1315,6 +1373,36 @@ def _parse_offer_payload(payload: str) -> Optional[Tuple[float, float, str]]: return None +def _parse_saved_list_payload(payload: str) -> Optional[Tuple[float, float, int, str]]: + parts = payload.split(":") + if len(parts) < 2: + return None + try: + months = float(parts[0]) + price = float(parts[1]) + except (ValueError, IndexError): + return None + + page = 0 + sale_mode = "subscription" + if len(parts) > 2: + try: + page = int(parts[2]) + sale_mode = parts[3] if len(parts) > 3 else "subscription" + except ValueError: + sale_mode = parts[2] + return months, price, page, sale_mode + + +def _metadata_iso(value: Any) -> Optional[str]: + if value is None: + return None + if hasattr(value, "isoformat"): + return value.isoformat() + text = str(value).strip() + return text or None + + def _format_saved_payment_method_title( get_text, network: Optional[str], last4: Optional[str], is_default: bool ) -> str: @@ -1363,6 +1451,9 @@ async def _initiate_yk_payment( return False sale_base = _sale_mode_base(sale_mode) + hwid_device_count = None + if hwid_quote: + hwid_device_count = parse_positive_int_units(hwid_quote.get("device_count")) payment_description = ( get_text("payment_description_traffic", traffic_gb=_format_value(months)) if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"} @@ -1379,12 +1470,14 @@ async def _initiate_yk_payment( "status": "pending_yookassa", "description": payment_description, "subscription_duration_months": int(months) if sale_base == "subscription" else None, - "sale_mode": sale_base, + "sale_mode": sale_mode, "tariff_key": sale_mode.split("@", 1)[1].split("|", 1)[0] if "@" in sale_mode else None, "purchased_gb": float(months) if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"} else None, - "purchased_hwid_devices": int(months) if sale_base in HWID_DEVICE_SALE_BASES else None, + "purchased_hwid_devices": ( + int(months) if sale_base in HWID_DEVICE_SALE_BASES else hwid_device_count + ), "hwid_valid_from": hwid_quote.get("valid_from") if hwid_quote else None, "hwid_valid_until": hwid_quote.get("valid_until") if hwid_quote else None, "hwid_pricing_period_months": hwid_quote.get("pricing_period_months") @@ -1430,6 +1523,19 @@ async def _initiate_yk_payment( yookassa_metadata["traffic_gb"] = str(months) if sale_base in HWID_DEVICE_SALE_BASES: yookassa_metadata["hwid_devices"] = str(months) + elif hwid_device_count: + yookassa_metadata["hwid_devices"] = str(hwid_device_count) + if hwid_quote and hwid_device_count: + hwid_metadata = { + "hwid_valid_from": _metadata_iso(hwid_quote.get("valid_from")), + "hwid_valid_until": _metadata_iso(hwid_quote.get("valid_until")), + "hwid_pricing_period_months": hwid_quote.get("pricing_period_months"), + "hwid_proration_ratio": hwid_quote.get("proration_ratio"), + "hwid_full_price": hwid_quote.get("full_price"), + } + yookassa_metadata.update( + {key: str(value) for key, value in hwid_metadata.items() if value is not None} + ) if payment_method_id: yookassa_metadata["used_saved_payment_method_id"] = payment_method_id @@ -1709,22 +1815,6 @@ async def pay_yk_callback_handler( months, price_rub, sale_mode = parsed hwid_quote = None - if _sale_mode_base(sale_mode) in HWID_DEVICE_SALE_BASES: - quoted_parts, hwid_quote = await quote_hwid_callback_parts( - session=session, - user_id=callback.from_user.id, - parts=PaymentCallbackParts(months=months, price=price_rub, sale_mode=sale_mode), - subscription_service=yookassa_service.subscription_service, - currency=default_currency_key_for_settings(settings), - ) - if not quoted_parts: - try: - await callback.answer(get_text("error_try_again"), show_alert=True) - except Exception: - pass - return - months = quoted_parts.months - price_rub = quoted_parts.price user_id = callback.from_user.id currency_code_for_yk = default_payment_currency_code_for_settings(settings) autopay_enabled = bool( @@ -1786,6 +1876,22 @@ async def pay_yk_callback_handler( pass return + quoted_parts, hwid_quote = await quote_hwid_callback_parts( + session=session, + user_id=callback.from_user.id, + parts=PaymentCallbackParts(months=months, price=price_rub, sale_mode=sale_mode), + subscription_service=yookassa_service.subscription_service, + currency=default_currency_key_for_settings(settings), + ) + if not quoted_parts: + try: + await callback.answer(get_text("error_try_again"), show_alert=True) + except Exception: + pass + return + months = quoted_parts.months + price_rub = quoted_parts.price + await _initiate_yk_payment( callback, settings=settings, @@ -1863,6 +1969,22 @@ async def pay_yk_new_card_handler( return months, price_rub, sale_mode = parsed + hwid_quote = None + quoted_parts, hwid_quote = await quote_hwid_callback_parts( + session=session, + user_id=callback.from_user.id, + parts=PaymentCallbackParts(months=months, price=price_rub, sale_mode=sale_mode), + subscription_service=yookassa_service.subscription_service, + currency=default_currency_key_for_settings(settings), + ) + if not quoted_parts: + try: + await callback.answer(get_text("error_try_again"), show_alert=True) + except Exception: + pass + return + months = quoted_parts.months + price_rub = quoted_parts.price user_id = callback.from_user.id currency_code_for_yk = default_payment_currency_code_for_settings(settings) autopay_enabled = bool( @@ -1889,6 +2011,7 @@ async def pay_yk_new_card_handler( save_payment_method=autopay_enabled and autopay_require_binding, back_callback=payment_methods_back_callback(_format_value(months), sale_mode, price_rub), sale_mode=sale_mode, + hwid_quote=hwid_quote, ) try: await callback.answer() @@ -1928,27 +2051,15 @@ async def pay_yk_saved_list_handler( pass return - parts = data_payload.split(":") - if len(parts) < 2: + parsed_saved_list = _parse_saved_list_payload(data_payload) + if not parsed_saved_list: logging.error(f"pay_yk_saved_list payload missing components: {callback.data}") try: await callback.answer(get_text("error_try_again"), show_alert=True) except Exception: pass return - - try: - months = float(parts[0]) - price_rub = float(parts[1]) - page = int(parts[2]) if len(parts) > 2 else 0 - sale_mode = parts[3] if len(parts) > 3 else "subscription" - except (ValueError, IndexError): - logging.error(f"pay_yk_saved_list payload parsing error: {callback.data}") - try: - await callback.answer(get_text("error_try_again"), show_alert=True) - except Exception: - pass - return + months, price_rub, page, sale_mode = parsed_saved_list autopay_enabled = bool( settings.yookassa_autopayments_active @@ -2138,6 +2249,24 @@ async def pay_yk_use_saved_handler( method_identifier = parts[2] user_id = callback.from_user.id + base_months = months + base_price_rub = price_rub + hwid_quote = None + quoted_parts, hwid_quote = await quote_hwid_callback_parts( + session=session, + user_id=user_id, + parts=PaymentCallbackParts(months=months, price=price_rub, sale_mode=sale_mode), + subscription_service=yookassa_service.subscription_service, + currency=default_currency_key_for_settings(settings), + ) + if not quoted_parts: + try: + await callback.answer(get_text("error_try_again"), show_alert=True) + except Exception: + pass + return + months = quoted_parts.months + price_rub = quoted_parts.price try: saved_methods = await user_billing_dal.list_user_payment_methods( @@ -2182,10 +2311,13 @@ async def pay_yk_use_saved_handler( price_rub=price_rub, currency_code_for_yk=currency_code_for_yk, save_payment_method=False, - back_callback=f"pay_yk_saved_list:{_format_value(months)}:{price_rub}:{sale_mode}", + back_callback=( + f"pay_yk_saved_list:{_format_value(base_months)}:{base_price_rub}:0:{sale_mode}" + ), payment_method_id=selected_method.provider_payment_method_id, selected_method_internal_id=selected_method.method_id, sale_mode=sale_mode, + hwid_quote=hwid_quote, ) try: await callback.answer() @@ -2754,6 +2886,7 @@ async def create_webapp_payment(ctx: WebAppPaymentContext) -> web.Response: months=ctx.months, sale_mode=ctx.sale_mode, traffic_gb=ctx.traffic_gb, + hwid_device_count=ctx.hwid_device_count, ) payment = await create_webapp_payment_record( ctx, @@ -2775,8 +2908,8 @@ async def create_webapp_payment(ctx: WebAppPaymentContext) -> web.Response: } if amounts.traffic_sale: metadata["traffic_gb"] = format_number_for_payload(ctx.traffic_gb or ctx.months) - if amounts.hwid_devices_sale: - metadata["hwid_devices"] = str(int(float(ctx.months))) + if amounts.purchased_hwid_devices: + metadata["hwid_devices"] = str(int(amounts.purchased_hwid_devices)) if amounts.tariff_key: metadata["tariff_key"] = amounts.tariff_key response = await service.create_payment( diff --git a/backend/bot/services/email_auth_service.py b/backend/bot/services/email_auth_service.py index 9391932..82e9ab9 100644 --- a/backend/bot/services/email_auth_service.py +++ b/backend/bot/services/email_auth_service.py @@ -10,13 +10,13 @@ from dataclasses import dataclass from datetime import datetime, timedelta, timezone from email.message import EmailMessage from email.utils import formataddr -from typing import Optional +from typing import Optional, Sequence from sqlalchemy import select, update from sqlalchemy.ext.asyncio import AsyncSession from bot.middlewares.i18n import JsonI18n -from bot.services.email_templates import EmailContent, render_login_code +from bot.services.email_templates import EmailContent, EmailInlineImage, render_login_code from bot.services.message_audit import log_user_message_delivery from config.settings import Settings from db.dal import security_dal, user_dal @@ -61,11 +61,39 @@ def normalize_email(value: str) -> str: return (value or "").strip().lower() +def email_domain(value: Optional[str]) -> str: + email = normalize_email(value or "") + if "@" not in email: + return "" + return email.rsplit("@", 1)[1].strip().lower().rstrip(".") + + def is_valid_email(value: str) -> bool: email = normalize_email(value) return bool(email and len(email) <= 254 and EMAIL_RE.match(email)) +def _split_disposable_domain_values(value: str) -> list[str]: + return [item.strip() for item in re.split(r"[,;\s]+", value or "") if item.strip()] + + +def is_disposable_email(value: Optional[str], settings: Settings) -> bool: + domain = email_domain(value) + if not domain: + return False + blocked_domains = getattr(settings, "disposable_email_domains", None) + if blocked_domains is None: + blocked_domains = _split_disposable_domain_values( + str(getattr(settings, "DISPOSABLE_EMAIL_DOMAINS", "") or "") + ) + blocked_domains = blocked_domains or [] + for blocked in blocked_domains: + normalized = str(blocked or "").strip().lower().lstrip("@.") + if normalized and (domain == normalized or domain.endswith(f".{normalized}")): + return True + return False + + def _email_throttle_identifier(email: str, purpose: str, target_user_id: Optional[int]) -> str: target_part = "none" if target_user_id is None else str(target_user_id) return f"{purpose}:{target_part}:{email}" @@ -453,6 +481,7 @@ class EmailAuthService: subject: str, body: str, html_body: Optional[str] = None, + inline_images: Sequence[EmailInlineImage] = (), ) -> None: await asyncio.to_thread( self._send_custom_email_sync, @@ -460,6 +489,7 @@ class EmailAuthService: subject=subject, body=body, html_body=html_body, + inline_images=inline_images, ) async def send_rendered_email( @@ -473,6 +503,7 @@ class EmailAuthService: subject=content.subject, body=content.text, html_body=content.html, + inline_images=content.inline_images, ) def _send_code_email_sync( @@ -493,17 +524,13 @@ class EmailAuthService: i18n=self.i18n, ) - message = EmailMessage() - message["Subject"] = content.subject - message["From"] = formataddr( - ( - self.settings.SMTP_FROM_NAME or self.settings.WEBAPP_TITLE, - self.settings.SMTP_FROM_EMAIL or "", - ) + message = self._build_email_message( + email=email, + subject=content.subject, + body=content.text, + html_body=content.html, + inline_images=content.inline_images, ) - message["To"] = email - message.set_content(content.text) - message.add_alternative(content.html, subtype="html") context = ssl.create_default_context() smtp_host = self.settings.SMTP_HOST @@ -554,19 +581,15 @@ class EmailAuthService: subject: str, body: str, html_body: Optional[str] = None, + inline_images: Sequence[EmailInlineImage] = (), ) -> None: - message = EmailMessage() - message["Subject"] = subject - message["From"] = formataddr( - ( - self.settings.SMTP_FROM_NAME or self.settings.WEBAPP_TITLE, - self.settings.SMTP_FROM_EMAIL or "", - ) + message = self._build_email_message( + email=email, + subject=subject, + body=body, + html_body=html_body, + inline_images=inline_images, ) - message["To"] = email - message.set_content(body) - if html_body: - message.add_alternative(html_body, subtype="html") context = ssl.create_default_context() smtp_host = self.settings.SMTP_HOST @@ -610,6 +633,64 @@ class EmailAuthService: if last_error: raise last_error + def _build_email_message( + self, + *, + email: str, + subject: str, + body: str, + html_body: Optional[str] = None, + inline_images: Sequence[EmailInlineImage] = (), + ) -> EmailMessage: + message = EmailMessage() + message["Subject"] = subject + message["From"] = formataddr( + ( + self.settings.SMTP_FROM_NAME or self.settings.WEBAPP_TITLE, + self.settings.SMTP_FROM_EMAIL or "", + ) + ) + message["To"] = email + message.set_content(body) + if html_body: + message.add_alternative(html_body, subtype="html") + self._attach_inline_images(message, inline_images) + return message + + @staticmethod + def _attach_inline_images( + message: EmailMessage, + inline_images: Sequence[EmailInlineImage], + ) -> None: + if not inline_images: + return + html_part = message.get_body(("html",)) + if html_part is None: + return + + for image in inline_images: + content_type = (image.content_type or "").split(";", 1)[0].strip().lower() + if "/" not in content_type: + continue + maintype, subtype = content_type.split("/", 1) + if maintype != "image" or not subtype: + continue + + body = bytes(image.data or b"") + content_id = (image.content_id or "").strip() + if not body or not content_id: + continue + + cid_header = content_id + if not (cid_header.startswith("<") and cid_header.endswith(">")): + cid_header = f"<{cid_header}>" + html_part.add_related( + body, + maintype=maintype, + subtype=subtype, + cid=cid_header, + ) + def _send_message_via_smtp( self, *, diff --git a/backend/bot/services/email_templates.py b/backend/bot/services/email_templates.py index f8ed96a..57da1eb 100644 --- a/backend/bot/services/email_templates.py +++ b/backend/bot/services/email_templates.py @@ -12,6 +12,7 @@ from __future__ import annotations import html import re from dataclasses import dataclass +from pathlib import Path from typing import TYPE_CHECKING, Optional, Sequence, Tuple from urllib.parse import urlsplit @@ -27,6 +28,27 @@ _TEXT_MUTED = "#9aa3b2" _TEXT_DIM = "#5d6573" _DEFAULT_ACCENT = "#00fe7a" _HEX_RE = re.compile(r"^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$") +_EMAIL_LOGO_CONTENT_ID = "webapp-logo" +_WEBAPP_UPLOADED_LOGO_PATH = "/webapp-uploaded-logo" +_WEBAPP_UPLOADED_LOGO_DIR = Path(__file__).resolve().parents[3] / "data" / "webapp-logo" / "uploads" +_WEBAPP_LOGO_MAX_BYTES = 2 * 1024 * 1024 +_UPLOADED_LOGO_RE = re.compile(r"logo-[0-9a-f]{16}\.(?:gif|ico|jpe?g|png|svg|webp)") +_LOGO_CONTENT_TYPES = { + ".gif": "image/gif", + ".ico": "image/x-icon", + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".png": "image/png", + ".svg": "image/svg+xml", + ".webp": "image/webp", +} + + +@dataclass(frozen=True) +class EmailInlineImage: + content_id: str + content_type: str + data: bytes @dataclass(frozen=True) @@ -34,6 +56,13 @@ class EmailContent: subject: str text: str html: str + inline_images: Tuple[EmailInlineImage, ...] = () + + +@dataclass(frozen=True) +class _EmailLayout: + html: str + inline_images: Tuple[EmailInlineImage, ...] = () def _safe_color(value: Optional[str]) -> str: @@ -48,8 +77,6 @@ def _safe_color(value: Optional[str]) -> str: def _public_logo_url(settings: Settings) -> Optional[str]: """Email recipients can't reach the in-app /webapp-logo proxy, so only a stored public https URL can be used directly. Anything else is dropped.""" - if getattr(settings, "WEBAPP_LOGO_USE_EMOJI", False): - return None raw = (settings.WEBAPP_LOGO_URL or "").strip() if not raw: return None @@ -59,6 +86,55 @@ def _public_logo_url(settings: Settings) -> Optional[str]: return raw +def _uploaded_logo_filename(url: str) -> Optional[str]: + parsed = urlsplit(str(url or "")) + path = parsed.path if parsed.scheme or parsed.netloc else str(url or "") + prefix = f"{_WEBAPP_UPLOADED_LOGO_PATH}/" + if not path.startswith(prefix): + return None + filename = path.removeprefix(prefix) + return filename if _UPLOADED_LOGO_RE.fullmatch(filename) else None + + +def _inline_uploaded_logo(settings: Settings) -> Optional[EmailInlineImage]: + filename = _uploaded_logo_filename((settings.WEBAPP_LOGO_URL or "").strip()) + if not filename: + return None + + content_type = _LOGO_CONTENT_TYPES.get(Path(filename).suffix.lower()) + if not content_type: + return None + + try: + uploads_dir = _WEBAPP_UPLOADED_LOGO_DIR.resolve() + logo_path = (uploads_dir / filename).resolve() + logo_path.relative_to(uploads_dir) + body = logo_path.read_bytes() + except (OSError, ValueError): + return None + + if not body or len(body) > _WEBAPP_LOGO_MAX_BYTES: + return None + + return EmailInlineImage( + content_id=_EMAIL_LOGO_CONTENT_ID, + content_type=content_type, + data=body, + ) + + +def _email_logo(settings: Settings) -> Tuple[Optional[str], Tuple[EmailInlineImage, ...]]: + inline_logo = _inline_uploaded_logo(settings) + if inline_logo: + return f"cid:{inline_logo.content_id}", (inline_logo,) + + public_url = _public_logo_url(settings) + if public_url: + return public_url, () + + return None, () + + def _brand_title(settings: Settings) -> str: title = (settings.WEBAPP_TITLE or "").strip() return title or "Subscription" @@ -98,10 +174,10 @@ def _layout( intro_html: str, body_html: str, footer_html: str, -) -> str: +) -> _EmailLayout: accent = _safe_color(settings.WEBAPP_PRIMARY_COLOR) brand_title = html.escape(_brand_title(settings)) - logo_url = _public_logo_url(settings) + logo_url, inline_images = _email_logo(settings) html_lang = html.escape((language_code or "en").replace("_", "-"), quote=True) logo_block = "" if logo_url: @@ -111,7 +187,7 @@ def _layout( f'border-radius:16px;">' ) - return f""" + layout_html = f""" @@ -151,6 +227,16 @@ def _layout( """ # noqa: E501 + return _EmailLayout(html=layout_html, inline_images=inline_images) + + +def _email_content(*, subject: str, text: str, layout: _EmailLayout) -> EmailContent: + return EmailContent( + subject=subject, + text=text, + html=layout.html, + inline_images=layout.inline_images, + ) def _info_rows_html(rows: Sequence[Tuple[str, str]]) -> str: @@ -319,7 +405,7 @@ def render_login_code( body_html=body_html, footer_html=footer, ) - return EmailContent(subject=subject, text="\n".join(text_lines), html=rendered) + return _email_content(subject=subject, text="\n".join(text_lines), layout=rendered) def render_account_merged( @@ -372,7 +458,7 @@ def render_account_merged( body_html=body_html, footer_html=footer, ) - return EmailContent(subject=subject, text=text, html=rendered) + return _email_content(subject=subject, text=text, layout=rendered) def render_payment_success( @@ -506,7 +592,7 @@ def render_payment_success( body_html="".join(body_parts), footer_html=footer, ) - return EmailContent(subject=subject, text="\n".join(text_lines), html=rendered) + return _email_content(subject=subject, text="\n".join(text_lines), layout=rendered) def render_user_notification( @@ -567,7 +653,7 @@ def render_user_notification( ), ] ) - return EmailContent(subject=final_subject, text="\n".join(text_lines), html=rendered) + return _email_content(subject=final_subject, text="\n".join(text_lines), layout=rendered) def render_subscription_expiring( @@ -631,7 +717,7 @@ def render_subscription_expiring( body_html="".join(body_parts), footer_html=footer, ) - return EmailContent(subject=subject, text="\n".join(text_lines), html=rendered) + return _email_content(subject=subject, text="\n".join(text_lines), layout=rendered) def _subscription_lifecycle_title( @@ -733,7 +819,7 @@ def render_subscription_lifecycle_notification( ), ] ) - return EmailContent(subject=subject, text="\n".join(text_lines), html=rendered) + return _email_content(subject=subject, text="\n".join(text_lines), layout=rendered) def _support_email( @@ -785,7 +871,7 @@ def _support_email( ] if safe_url: text_lines.extend(["", safe_url]) - return EmailContent(subject=subject, text="\n".join(text_lines), html=rendered) + return _email_content(subject=subject, text="\n".join(text_lines), layout=rendered) def render_support_new_ticket_admin( diff --git a/backend/bot/services/locale_override_service.py b/backend/bot/services/locale_override_service.py index 9318a45..7b50740 100644 --- a/backend/bot/services/locale_override_service.py +++ b/backend/bot/services/locale_override_service.py @@ -108,6 +108,7 @@ LOCALE_GROUPS = [ "prefixes": ( "admin_user_", "admin_users_", + "admin_hwid_", "admin_ban_", "admin_unban_", "admin_banned_", @@ -115,6 +116,7 @@ LOCALE_GROUPS = [ "admin_traffic_grant_", "admin_view_banned_", "user_card_", + "user_hwid_", "user_premium_", "user_regular_", "user_traffic_", @@ -203,7 +205,6 @@ LOCALE_GROUPS = [ "admin_settings_field_subscription_mini_app_url", "admin_settings_field_support_link", "admin_settings_field_server_status_url", - "admin_settings_field_terms_", "admin_settings_field_privacy_", "admin_settings_field_user_agreement_", "appearance_", diff --git a/backend/bot/services/panel_api_service.py b/backend/bot/services/panel_api_service.py index 4ff5c43..bac4e60 100644 --- a/backend/bot/services/panel_api_service.py +++ b/backend/bot/services/panel_api_service.py @@ -22,6 +22,11 @@ class PanelApiService: _TRANSIENT_STATUS_CODES = (-1, -3) _SAFE_METHODS = frozenset({"GET", "HEAD"}) _RETRY_BACKOFF_SECONDS = 0.5 + _MIN_TIMEOUT_SECONDS = 0.1 + _DEFAULT_TOTAL_TIMEOUT_SECONDS = 25.0 + _DEFAULT_CONNECT_TIMEOUT_SECONDS = 8.0 + _DEFAULT_SOCK_CONNECT_TIMEOUT_SECONDS = 8.0 + _DEFAULT_SOCK_READ_TIMEOUT_SECONDS = 15.0 def __init__(self, settings: Settings): self.settings = settings @@ -70,17 +75,46 @@ class PanelApiService: async def _get_session(self) -> aiohttp.ClientSession: if self._session is None or self._session.closed: - # Separate connect/read timeouts so a stuck panel does not hold a - # bot worker for the full window; total caps worst-case latency. - timeout = aiohttp.ClientTimeout( - total=15, - connect=3, - sock_connect=3, - sock_read=10, - ) - self._session = aiohttp.ClientSession(timeout=timeout) + self._session = aiohttp.ClientSession(timeout=self._client_timeout()) return self._session + @classmethod + def _timeout_setting(cls, settings: Settings, name: str, default: float) -> float: + raw_value = getattr(settings, name, default) + try: + value = float(raw_value) + except (TypeError, ValueError): + return default + if value <= 0: + return default + return max(cls._MIN_TIMEOUT_SECONDS, value) + + def _client_timeout(self) -> aiohttp.ClientTimeout: + # Separate connect/read timeouts so a slow panel route has more room, + # while genuinely stuck requests still cannot pin a worker forever. + return aiohttp.ClientTimeout( + total=self._timeout_setting( + self.settings, + "PANEL_API_TOTAL_TIMEOUT_SECONDS", + self._DEFAULT_TOTAL_TIMEOUT_SECONDS, + ), + connect=self._timeout_setting( + self.settings, + "PANEL_API_CONNECT_TIMEOUT_SECONDS", + self._DEFAULT_CONNECT_TIMEOUT_SECONDS, + ), + sock_connect=self._timeout_setting( + self.settings, + "PANEL_API_SOCK_CONNECT_TIMEOUT_SECONDS", + self._DEFAULT_SOCK_CONNECT_TIMEOUT_SECONDS, + ), + sock_read=self._timeout_setting( + self.settings, + "PANEL_API_SOCK_READ_TIMEOUT_SECONDS", + self._DEFAULT_SOCK_READ_TIMEOUT_SECONDS, + ), + ) + async def close_session(self): if self._session and not self._session.closed: await self._session.close() @@ -121,6 +155,15 @@ class PanelApiService: for attempt in range(max_attempts): result = await self._request_once(method, endpoint, log_full_response, **kwargs) if attempt + 1 < max_attempts and self._is_transient_error(result): + logging.warning( + "Retrying transient Panel API request method=%s endpoint=%s " + "attempt=%s/%s status_code=%s", + method.upper(), + endpoint, + attempt + 1, + max_attempts, + result.get("status_code") if isinstance(result, dict) else None, + ) await asyncio.sleep(self._RETRY_BACKOFF_SECONDS) continue return result @@ -158,8 +201,8 @@ class PanelApiService: ) except Exception: log_prefix += f" | Payload: {str(json_payload_for_log)[:300]}..." + started = time.monotonic() try: - started = time.monotonic() async with aiohttp_session.request( method.upper(), url_for_request, headers=headers, **kwargs ) as response: @@ -228,15 +271,48 @@ class PanelApiService: return {"error": True, "status_code": response_status, "details": error_details} except aiohttp.ClientConnectorError as e: + logging.info( + "metric panel_latency_seconds=%.3f method=%s endpoint=%s status=connect_error", + time.monotonic() - started, + method.upper(), + endpoint, + ) logging.error(f"Panel API ClientConnectorError to {url_for_request}: {e}") return {"error": True, "status_code": -1, "message": f"Connection error: {str(e)}"} + except aiohttp.ServerTimeoutError as e: + logging.info( + "metric panel_latency_seconds=%.3f method=%s endpoint=%s status=timeout", + time.monotonic() - started, + method.upper(), + endpoint, + ) + logging.warning("Panel API timeout to %s: %s", url_for_request, e) + return {"error": True, "status_code": -3, "message": f"Request timed out: {str(e)}"} except aiohttp.ClientError as e: + logging.info( + "metric panel_latency_seconds=%.3f method=%s endpoint=%s status=client_error", + time.monotonic() - started, + method.upper(), + endpoint, + ) logging.exception("Panel API ClientError to %s.", url_for_request) return {"error": True, "status_code": -2, "message": f"Client error: {str(e)}"} except asyncio.TimeoutError: + logging.info( + "metric panel_latency_seconds=%.3f method=%s endpoint=%s status=timeout", + time.monotonic() - started, + method.upper(), + endpoint, + ) logging.error(f"Panel API request to {url_for_request} timed out.") return {"error": True, "status_code": -3, "message": "Request timed out"} except Exception as e: + logging.info( + "metric panel_latency_seconds=%.3f method=%s endpoint=%s status=unexpected_error", + time.monotonic() - started, + method.upper(), + endpoint, + ) logging.error( f"Unexpected Panel API request error to {url_for_request}: {e}", exc_info=True ) @@ -885,7 +961,14 @@ class PanelApiService: await self._devices_cache.invalidate_remote(f"user:{user_uuid}") async def get_internal_squads(self) -> Optional[List[Dict[str, Any]]]: - return await self._squads_cache.get_or_load("list", self._get_internal_squads_uncached) + squads = await self._squads_cache.get_or_load("list", self._get_internal_squads_uncached) + if squads is not None: + return squads + stale_squads = self._squads_cache.get_stale("list") + if stale_squads is not None: + logging.warning("Using stale internal squads cache after panel fetch failed.") + return stale_squads + return None async def _get_internal_squads_uncached(self) -> Optional[List[Dict[str, Any]]]: response_data = await self._request("GET", "/internal-squads", log_full_response=False) diff --git a/backend/bot/services/promo_code_service.py b/backend/bot/services/promo_code_service.py index b486595..947550d 100644 --- a/backend/bot/services/promo_code_service.py +++ b/backend/bot/services/promo_code_service.py @@ -38,8 +38,12 @@ class PromoCodeService: user_lang: str, ) -> Tuple[bool, datetime | str]: _ = lambda k, **kw: self.i18n.gettext(user_lang, k, **kw) - code_input_upper = (code_input or "").strip().upper()[:100] - code_display = html_escape(code_input_upper[:100], quote=False) + preserve_case = bool( + getattr(self.settings, "MIGRATION_REMNASHOP_PROMO_CODE_COMPAT_ENABLED", False) + ) + code_input_clean = (code_input or "").strip()[:100] + lookup_code = code_input_clean if preserve_case else code_input_clean.upper() + code_display = html_escape(lookup_code[:100], quote=False) throttle_identifier = self._throttle_identifier(user_id) throttle = await security_dal.check_throttle( @@ -54,7 +58,7 @@ class PromoCodeService: ) promo_data = await promo_code_dal.get_active_promo_code_by_code_str( - session, code_input_upper + session, lookup_code, preserve_case=preserve_case ) if not promo_data: @@ -74,6 +78,8 @@ class PromoCodeService: ) return False, _("promo_code_not_found", code=code_display) + applied_code = str(promo_data.code or lookup_code) + code_display = html_escape(applied_code[:100], quote=False) existing_activation = await promo_code_dal.get_user_activation_for_promo( session, promo_data.promo_code_id, user_id ) @@ -86,7 +92,7 @@ class PromoCodeService: session=session, user_id=user_id, bonus_days=bonus_days, - reason=f"promo code {code_input_upper}", + reason=f"promo code {applied_code}", ) if new_end_date: @@ -109,7 +115,7 @@ class PromoCodeService: user = await user_dal.get_user_by_id(session, user_id) await notification_service.notify_promo_activation( user_id=user_id, - promo_code=code_input_upper, + promo_code=applied_code, bonus_days=bonus_days, username=user.username if user else None, email=getattr(user, "email", None) if user else None, diff --git a/backend/bot/services/referral_service.py b/backend/bot/services/referral_service.py index 53efd36..28c2d0e 100644 --- a/backend/bot/services/referral_service.py +++ b/backend/bot/services/referral_service.py @@ -197,6 +197,9 @@ class ReferralService: "status_from_panel": "ACTIVE_BONUS", "traffic_limit_bytes": self.settings.user_traffic_limit_bytes, "auto_renew_enabled": False, + # Short bonus grant: warn only hours before it + # ends, not days ahead. A real payment clears this. + "suppress_early_expiry_notifications": True, } try: await subscription_dal.deactivate_other_active_subscriptions( diff --git a/backend/bot/services/settings_override_service.py b/backend/bot/services/settings_override_service.py index 822b0ec..7702a32 100644 --- a/backend/bot/services/settings_override_service.py +++ b/backend/bot/services/settings_override_service.py @@ -28,10 +28,7 @@ from db.dal import app_settings_dal logger = logging.getLogger(__name__) APPEARANCE_OVERRIDE_KEYS = { - "WEBAPP_LOGO_USE_EMOJI", "WEBAPP_LOGO_URL", - "WEBAPP_LOGO_EMOJI", - "WEBAPP_LOGO_EMOJI_FONT", "WEBAPP_FAVICON_USE_CUSTOM", "WEBAPP_FAVICON_URL", "WEBAPP_LOGO_FAVICON_URL", @@ -167,12 +164,6 @@ def _appearance_snapshot(settings: Settings) -> Dict[str, Any]: snapshot["WEBAPP_FAVICON_URL"] = favicon_url if getattr(settings, "WEBAPP_FAVICON_USE_CUSTOM", False): snapshot["WEBAPP_FAVICON_USE_CUSTOM"] = True - if getattr(settings, "WEBAPP_LOGO_USE_EMOJI", False): - snapshot["WEBAPP_LOGO_USE_EMOJI"] = True - snapshot["WEBAPP_LOGO_EMOJI"] = getattr(settings, "WEBAPP_LOGO_EMOJI", "") - emoji_font = getattr(settings, "WEBAPP_LOGO_EMOJI_FONT", "") - if emoji_font and emoji_font != "system": - snapshot["WEBAPP_LOGO_EMOJI_FONT"] = emoji_font primary_color = getattr(settings, "WEBAPP_PRIMARY_COLOR", None) if primary_color and primary_color != "#00fe7a": snapshot["WEBAPP_PRIMARY_COLOR"] = primary_color diff --git a/backend/bot/services/subscription_notification_worker.py b/backend/bot/services/subscription_notification_worker.py index 674a5d9..74d4c83 100644 --- a/backend/bot/services/subscription_notification_worker.py +++ b/backend/bot/services/subscription_notification_worker.py @@ -153,6 +153,15 @@ class SubscriptionNotificationWorker: hours_before=hours_before, ) + # Trial and registration/referral-bonus subscriptions last only a + # few days, so a multi-day "ending soon" reminder would fire almost + # the moment they are granted and needlessly alarm newcomers. Skip + # the day-before stages for them — they still get the hours-before + # reminder above and the expiry/after-expiry notices below. Paying + # for a real subscription clears the flag and restores all stages. + if bool(getattr(sub, "suppress_early_expiry_notifications", False)): + return None + days_before_limit = max( 0, int(getattr(self.settings, "SUBSCRIPTION_NOTIFY_DAYS_BEFORE", 0) or 0), diff --git a/backend/bot/services/subscription_service_impl/devices.py b/backend/bot/services/subscription_service_impl/devices.py index 5c48e07..917e137 100644 --- a/backend/bot/services/subscription_service_impl/devices.py +++ b/backend/bot/services/subscription_service_impl/devices.py @@ -31,6 +31,49 @@ class HwidDeviceMixin: ) return int(getattr(sub, "extra_hwid_devices", 0) or 0) + async def sync_hwid_device_limit_to_panel( + self, + session: AsyncSession, + user_id: int, + ) -> Optional[int]: + """Push the current local HWID device limit override to the panel.""" + db_user = await user_dal.get_user_by_id(session, user_id) + if not db_user or not db_user.panel_user_uuid: + return None + sub = await subscription_dal.get_active_subscription_by_user_id( + session, user_id, db_user.panel_user_uuid + ) + if not sub: + return None + + tariff = self._resolve_tariff(sub.tariff_key) if sub.tariff_key else None + base_hwid_limit = ( + int(sub.hwid_device_limit) + if sub.hwid_device_limit is not None + else self._base_hwid_limit_for_tariff(tariff) + ) + extra_hwid_devices = await self._active_hwid_extra_devices_for_sub(session, sub) + sub.extra_hwid_devices = extra_hwid_devices + effective_hwid_limit = self._effective_hwid_limit(base_hwid_limit, extra_hwid_devices) + if effective_hwid_limit is None: + return None + + panel_payload = self._build_panel_update_payload( + panel_user_uuid=db_user.panel_user_uuid, + expire_at=sub.end_date, + status="ACTIVE", + hwid_device_limit=effective_hwid_limit, + include_default_squads=False, + ) + panel_payload.update(self._panel_identity_payload_for_user(db_user)) + try: + await self.panel_service.update_user_details_on_panel( + db_user.panel_user_uuid, panel_payload + ) + except Exception: + logging.exception("sync_hwid_device_limit_to_panel failed for user %s", user_id) + return effective_hwid_limit + async def _hwid_topup_validity_window( self, session: AsyncSession, @@ -73,6 +116,64 @@ class HwidDeviceMixin: packages = package_set.for_currency(currency) return next((pkg for pkg in packages if int(pkg.count) == int(device_count)), None) + @staticmethod + def _quote_hwid_full_period_package_price( + tariff: Tariff, + *, + device_count: int, + period_months: int, + currency: str, + ) -> Optional[Dict[str, Any]]: + package_set = tariff.hwid_device_packages + if not package_set: + return None + try: + target_count = int(device_count) + months = max(1, int(period_months)) + except (TypeError, ValueError): + return None + if target_count <= 0: + return None + + packages = [ + package + for package in package_set.for_currency(currency) + if int(getattr(package, "count", 0) or 0) > 0 + ] + if not packages: + return None + + best: Dict[int, tuple[float, List[Any]]] = {0: (0.0, [])} + for count in range(1, target_count + 1): + best_for_count: Optional[tuple[float, List[Any]]] = None + for package in packages: + package_count = int(package.count) + previous = best.get(count - package_count) + if previous is None: + continue + price = previous[0] + float(package.price_for_period(months)) + selected = [*previous[1], package] + if best_for_count is None or price < best_for_count[0]: + best_for_count = (price, selected) + if best_for_count is not None: + best[count] = best_for_count + + resolved = best.get(target_count) + if resolved is None: + return None + full_price, selected_packages = resolved + rounded_price = HwidDeviceMixin._round_hwid_price(full_price, currency=currency) + if currency == "stars": + rounded_price = float(int(math.ceil(rounded_price))) + return { + "price": rounded_price, + "full_price": float(full_price), + "pricing_period_months": months, + "proration_ratio": 1.0, + "currency": currency, + "package_counts": [int(package.count) for package in selected_packages], + } + def _quote_hwid_package_price( self, *, @@ -85,16 +186,10 @@ class HwidDeviceMixin: ) -> Dict[str, Any]: period_months = max(1, int(getattr(sub, "duration_months", None) or 1)) full_price = float(package.price_for_period(period_months)) - period_start = self._as_aware_utc(getattr(sub, "start_date", None)) - period_end = self._as_aware_utc(getattr(sub, "end_date", None)) or valid_until - inferred_period_start = add_months(period_end, -period_months) - if not period_start or period_start >= period_end or period_start < inferred_period_start: - period_start = inferred_period_start - - basis_seconds = max(1.0, (period_end - period_start).total_seconds()) + basis_seconds = max(1.0, float(period_months * 30 * 24 * 60 * 60)) billable_start = max(now, valid_from) billable_seconds = max(0.0, (valid_until - billable_start).total_seconds()) - ratio = billable_seconds / basis_seconds + ratio = min(1.0, billable_seconds / basis_seconds) raw_price = full_price * ratio price = self._round_hwid_price(raw_price, currency=currency) min_price = getattr(package, "min_price", None) @@ -150,7 +245,7 @@ class HwidDeviceMixin: if sub.hwid_device_limit is not None else self._base_hwid_limit_for_tariff(tariff) ) - if base_hwid_limit == 0: + if base_hwid_limit in (None, 0): return None package = self._find_hwid_package(tariff, purchased_devices, currency) @@ -187,6 +282,80 @@ class HwidDeviceMixin: ) return quote + async def quote_hwid_device_renewal_for_subscription( + self, + session: AsyncSession, + *, + user_id: int, + target_tariff_key: str, + months: int, + currency: str = "rub", + now: Optional[datetime] = None, + ) -> Optional[Dict[str, Any]]: + try: + period_months = int(months) + except (TypeError, ValueError): + return None + if period_months <= 0: + return None + + db_user = await user_dal.get_user_by_id(session, user_id) + if not db_user or not db_user.panel_user_uuid: + return None + sub = await subscription_dal.get_active_subscription_by_user_id( + session, user_id, db_user.panel_user_uuid + ) + if not sub or not sub.end_date: + return None + + now = now or datetime.now(timezone.utc) + subscription_end = self._as_aware_utc(sub.end_date) + if not subscription_end or subscription_end <= now: + return None + + try: + tariff = self._resolve_tariff(target_tariff_key) + except Exception: + return None + if not tariff or tariff.billing_model != "period": + return None + base_hwid_limit = self._base_hwid_limit_for_tariff(tariff) + if base_hwid_limit in (None, 0): + return None + + entitlement_summary = await tariff_dal.get_hwid_device_entitlement_summary( + session, + subscription_id=sub.subscription_id, + at=now, + ) + active_devices = int(entitlement_summary.get("active_devices") or 0) + if active_devices <= 0: + return None + + price_quote = self._quote_hwid_full_period_package_price( + tariff, + device_count=active_devices, + period_months=period_months, + currency=currency, + ) + if not price_quote: + return None + + valid_from = subscription_end + valid_until = add_months(valid_from, period_months) + price_quote.update( + { + "subscription_id": sub.subscription_id, + "tariff_key": tariff.key, + "device_count": active_devices, + "renewal": True, + "valid_from": valid_from, + "valid_until": valid_until, + "active_until": entitlement_summary.get("active_until"), + } + ) + return price_quote + async def activate_hwid_device_topup( self, session: AsyncSession, @@ -248,7 +417,7 @@ class HwidDeviceMixin: if sub.hwid_device_limit is not None else self._base_hwid_limit_for_tariff(tariff) ) - if base_hwid_limit == 0: + if base_hwid_limit in (None, 0): logging.info( "Skipping HWID top-up for user %s because current limit is unlimited", user_id ) diff --git a/backend/bot/services/subscription_service_impl/lifecycle.py b/backend/bot/services/subscription_service_impl/lifecycle.py index 4a4eea3..40c62ab 100644 --- a/backend/bot/services/subscription_service_impl/lifecycle.py +++ b/backend/bot/services/subscription_service_impl/lifecycle.py @@ -178,6 +178,7 @@ class SubscriptionLifecycleMixin: user_id: int, target_tariff_key: str, mode: str, + payment_id: Optional[int] = None, ) -> Optional[Dict[str, Any]]: config = self._tariffs_config() if not config: @@ -336,7 +337,7 @@ class SubscriptionLifecycleMixin: "from_tariff_key": before_tariff_key, "to_tariff_key": target.key, "mode": mode, - "payment_id": None, + "payment_id": payment_id, "days_before": options.get("remaining_days"), "days_after": (updated.end_date - now).days if updated.end_date and target.billing_model == "period" @@ -454,27 +455,11 @@ class SubscriptionLifecycleMixin: user_id, tariff_key, "paid_diff", + payment_id=payment_db_id, ) if result: sub = await subscription_dal.get_active_subscription_by_user_id(session, user_id) if sub: - await tariff_dal.create_tariff_change( - session, - { - "subscription_id": sub.subscription_id, - "from_tariff_key": None, - "to_tariff_key": tariff_key, - "mode": "paid_diff", - "payment_id": payment_db_id, - "days_before": None, - "days_after": (sub.end_date - datetime.now(timezone.utc)).days - if sub.end_date - else None, - "converted_bytes": None, - "eff_price_before": None, - "eff_price_after": sub.effective_monthly_price_rub, - }, - ) result["end_date"] = sub.end_date result["is_active"] = sub.is_active db_user = await user_dal.get_user_by_id(session, user_id) @@ -494,10 +479,29 @@ class SubscriptionLifecycleMixin: await self._record_payment_context( session, payment_db_id, - sale_mode=sale_mode_base, + sale_mode=sale_mode, tariff_key=tariff.key if tariff else tariff_key, purchased_gb=None, ) + payment = await payment_dal.get_payment_by_db_id(session, payment_db_id) + try: + hwid_renewal_devices = int(getattr(payment, "purchased_hwid_devices", 0) or 0) + except (TypeError, ValueError): + hwid_renewal_devices = 0 + try: + hwid_renewal_price = ( + float(getattr(payment, "hwid_full_price", 0) or 0) + if hwid_renewal_devices > 0 + else 0.0 + ) + except (TypeError, ValueError): + hwid_renewal_price = 0.0 + hwid_renewal_valid_from = self._as_aware_utc( + getattr(payment, "hwid_valid_from", None) if payment else None + ) + hwid_renewal_valid_until = self._as_aware_utc( + getattr(payment, "hwid_valid_until", None) if payment else None + ) db_user = await user_dal.get_user_by_id(session, user_id) if not db_user: @@ -569,6 +573,26 @@ class SubscriptionLifecycleMixin: promo_code_id_from_payment = None final_end_date = start_date + timedelta(days=duration_days_total) + if hwid_renewal_devices > 0 and hwid_renewal_valid_until and applied_promo_bonus_days: + hwid_renewal_valid_until = hwid_renewal_valid_until + timedelta( + days=applied_promo_bonus_days + ) + if payment: + payment.hwid_valid_until = hwid_renewal_valid_until + elif applied_promo_bonus_days > 0 and current_active_sub: + try: + await tariff_dal.extend_hwid_device_purchases_for_subscription_bonus( + session, + subscription_id=current_active_sub.subscription_id, + at=datetime.now(timezone.utc), + subscription_end_before=start_date, + delta=timedelta(days=applied_promo_bonus_days), + ) + except Exception: + logging.exception( + "Failed to extend HWID device purchases for promo payment bonus of user %s", + user_id, + ) await subscription_dal.deactivate_other_active_subscriptions( session, panel_user_uuid, panel_sub_link_id ) @@ -614,7 +638,8 @@ class SubscriptionLifecycleMixin: premium_topup_balance_bytes, premium_topup_used_bytes, ) - effective_monthly_price = float(payment_amount) / max(1, months_int) + subscription_amount_for_pricing = max(0.0, float(payment_amount) - hwid_renewal_price) + effective_monthly_price = subscription_amount_for_pricing / max(1, months_int) regular_bonus_carry = int(getattr(current_active_sub, "regular_bonus_bytes", 0) or 0) regular_unl_carry = bool(getattr(current_active_sub, "regular_unlimited_override", False)) traffic_limit_bytes = self._traffic_limit_for_period_tariff( @@ -641,6 +666,9 @@ class SubscriptionLifecycleMixin: "traffic_limit_bytes": traffic_limit_bytes, "provider": provider, "skip_notifications": False, + # A real payment restores the full reminder spectrum, clearing any + # trial/bonus suppression carried over on this panel subscription. + "suppress_early_expiry_notifications": False, "auto_renew_enabled": auto_renew_should_enable, "tariff_key": tariff.key if tariff else None, "tier_baseline_bytes": tier_baseline_bytes, @@ -695,6 +723,31 @@ class SubscriptionLifecycleMixin: final_subscription_url = updated_panel_user.get("subscriptionUrl") final_panel_short_uuid = updated_panel_user.get("shortUuid", panel_short_uuid) + hwid_devices_renewed_count = 0 + hwid_devices_renewed_until = None + if hwid_renewal_devices > 0: + if ( + hwid_renewal_valid_from + and hwid_renewal_valid_until + and hwid_renewal_valid_from < hwid_renewal_valid_until + ): + await tariff_dal.create_hwid_device_purchase( + session, + subscription_id=new_or_updated_sub.subscription_id, + payment_id=payment_db_id, + purchased_devices=hwid_renewal_devices, + valid_from=hwid_renewal_valid_from, + valid_until=hwid_renewal_valid_until, + ) + hwid_devices_renewed_count = hwid_renewal_devices + hwid_devices_renewed_until = hwid_renewal_valid_until + else: + logging.warning( + "Skipping HWID renewal purchase for payment %s: invalid window %s -> %s", + payment_db_id, + hwid_renewal_valid_from, + hwid_renewal_valid_until, + ) await self._send_payment_success_email( db_user=db_user, @@ -715,8 +768,12 @@ class SubscriptionLifecycleMixin: "subscription_url": final_subscription_url, "applied_promo_bonus_days": applied_promo_bonus_days, "tariff_key": tariff.key if tariff else None, - "hwid_devices_renewal_recommended_count": extra_hwid_devices, - "hwid_devices_valid_until": hwid_devices_valid_until, + "hwid_devices_renewal_recommended_count": 0 + if hwid_devices_renewed_count + else extra_hwid_devices, + "hwid_devices_valid_until": hwid_devices_renewed_until or hwid_devices_valid_until, + "hwid_devices_renewed_count": hwid_devices_renewed_count, + "hwid_devices_renewed_until": hwid_devices_renewed_until, } async def extend_active_subscription_days( @@ -725,6 +782,7 @@ class SubscriptionLifecycleMixin: user_id: int, bonus_days: int, reason: str = "bonus", + extend_hwid_devices: bool = True, ) -> Optional[datetime]: reason_lower = (reason or "").lower() apply_main_traffic_limit = any( @@ -776,6 +834,9 @@ class SubscriptionLifecycleMixin: "status_from_panel": "ACTIVE_BONUS", "traffic_limit_bytes": traffic_limit, "auto_renew_enabled": False, + # Registration/referral bonus grants are short-lived, like a + # trial: only warn a few hours before they end, not days ahead. + "suppress_early_expiry_notifications": True, } await subscription_dal.deactivate_other_active_subscriptions( session, panel_uuid, panel_sub_uuid @@ -792,6 +853,21 @@ class SubscriptionLifecycleMixin: updated_sub_model = await subscription_dal.update_subscription_end_date( session, active_sub.subscription_id, new_end_date_obj ) + if updated_sub_model and extend_hwid_devices: + try: + await tariff_dal.extend_hwid_device_purchases_for_subscription_bonus( + session, + subscription_id=active_sub.subscription_id, + at=now_utc, + subscription_end_before=current_end_date, + delta=timedelta(days=bonus_days), + ) + except Exception: + logging.exception( + "Failed to extend HWID device purchases for %s bonus of user %s", + reason, + user_id, + ) if ( apply_main_traffic_limit diff --git a/backend/bot/services/subscription_service_impl/payments.py b/backend/bot/services/subscription_service_impl/payments.py index 012c4c4..419bc2a 100644 --- a/backend/bot/services/subscription_service_impl/payments.py +++ b/backend/bot/services/subscription_service_impl/payments.py @@ -14,6 +14,7 @@ class PaymentContextMixin: "severpay": "SeverPay", "wata": "Wata", "cryptopay": "Crypto Pay", + "paykilla": "PayKilla", "telegram_stars": "Telegram Stars", } @@ -38,7 +39,8 @@ class PaymentContextMixin: payment.sale_mode = sale_mode payment.tariff_key = tariff_key payment.purchased_gb = purchased_gb - payment.purchased_hwid_devices = purchased_hwid_devices + if purchased_hwid_devices is not None: + payment.purchased_hwid_devices = purchased_hwid_devices if hwid_valid_from is not None: payment.hwid_valid_from = hwid_valid_from if hwid_valid_until is not None: diff --git a/backend/bot/services/subscription_service_impl/renewal.py b/backend/bot/services/subscription_service_impl/renewal.py index f58e8eb..ed9596d 100644 --- a/backend/bot/services/subscription_service_impl/renewal.py +++ b/backend/bot/services/subscription_service_impl/renewal.py @@ -42,6 +42,8 @@ class RenewalMixin: months = sub.duration_months or 1 currency = default_payment_currency_code_for_settings(self.settings) + tariff_key = str(getattr(sub, "tariff_key", "") or "").strip() or None + sale_mode = f"subscription@{tariff_key}" if tariff_key else "subscription" amount = None tariffs_config = ( self._tariffs_config() if callable(getattr(self, "_tariffs_config", None)) else None @@ -62,11 +64,55 @@ class RenewalMixin: logging.error(f"Auto-renew price missing for {months} months") return False + hwid_quote = None + quote_hwid_renewal = getattr( + self, + "quote_hwid_device_renewal_for_subscription", + None, + ) + if tariff_key and callable(quote_hwid_renewal): + try: + hwid_quote = await quote_hwid_renewal( + session, + user_id=sub.user_id, + target_tariff_key=tariff_key, + months=int(months), + currency=default_currency_key_for_settings(self.settings), + ) + except Exception: + logging.exception( + "Failed to quote HWID devices for auto-renew user %s", + sub.user_id, + ) + hwid_quote = None + if hwid_quote: + amount = float(amount) + float(hwid_quote.get("price") or 0) + metadata = { "user_id": str(sub.user_id), "auto_renew_for_subscription_id": str(sub.subscription_id), "subscription_months": str(months), + "sale_mode": sale_mode, } + if hwid_quote: + metadata["hwid_devices"] = str(int(hwid_quote.get("device_count") or 0)) + for source_key, metadata_key in ( + ("valid_from", "hwid_valid_from"), + ("valid_until", "hwid_valid_until"), + ): + value = hwid_quote.get(source_key) + if value: + metadata[metadata_key] = ( + value.isoformat() if hasattr(value, "isoformat") else str(value) + ) + for key in ( + "pricing_period_months", + "proration_ratio", + "full_price", + ): + value = hwid_quote.get(key) + if value is not None: + metadata[f"hwid_{key}"] = str(value) resp = await yk.create_payment( amount=float(amount), currency=currency, diff --git a/backend/bot/services/subscription_service_impl/tariffs.py b/backend/bot/services/subscription_service_impl/tariffs.py index fcf2df6..a1c9958 100644 --- a/backend/bot/services/subscription_service_impl/tariffs.py +++ b/backend/bot/services/subscription_service_impl/tariffs.py @@ -314,7 +314,7 @@ class TariffMixin: @staticmethod def _effective_hwid_limit(base_limit: Optional[int], extra_devices: int = 0) -> Optional[int]: if base_limit is None: - return None + return 0 base_int = max(0, int(base_limit)) if base_int == 0: return 0 diff --git a/backend/bot/services/subscription_service_impl/trial.py b/backend/bot/services/subscription_service_impl/trial.py index a963a85..1940e4b 100644 --- a/backend/bot/services/subscription_service_impl/trial.py +++ b/backend/bot/services/subscription_service_impl/trial.py @@ -63,6 +63,8 @@ class TrialSubscriptionMixin: "traffic_limit_bytes": self.settings.trial_traffic_limit_bytes, "auto_renew_enabled": False, "provider": "trial", + # Short trial: only warn a few hours before it ends, not days ahead. + "suppress_early_expiry_notifications": True, } try: await subscription_dal.upsert_subscription(session, trial_sub_data) diff --git a/backend/bot/states/admin_states.py b/backend/bot/states/admin_states.py index 870770a..389241f 100644 --- a/backend/bot/states/admin_states.py +++ b/backend/bot/states/admin_states.py @@ -30,6 +30,7 @@ class AdminStates(StatesGroup): waiting_for_user_delete_confirmation = State() waiting_for_premium_override_bonus_gb = State() waiting_for_traffic_grant_gb = State() + waiting_for_hwid_device_limit = State() # Ads campaigns waiting_for_ad_source = State() diff --git a/backend/bot/utils/channel_subscription.py b/backend/bot/utils/channel_subscription.py index 72ba65c..4f08287 100644 --- a/backend/bot/utils/channel_subscription.py +++ b/backend/bot/utils/channel_subscription.py @@ -1,4 +1,9 @@ -from typing import Optional +import logging +import re +from typing import Any, Optional + +_TELEGRAM_LINK_RE = re.compile(r"^(?:https?://|tg://)", re.IGNORECASE) +_TELEGRAM_USERNAME_RE = re.compile(r"^[A-Za-z0-9_]{5,64}$") def normalize_required_channel_id(value: object) -> Optional[int]: @@ -28,6 +33,66 @@ def normalize_required_channel_id(value: object) -> Optional[int]: return -int(f"100{raw_abs}") +def normalize_required_channel_link(value: object) -> Optional[str]: + if value is None: + return None + + raw = str(value).strip() + if not raw: + return None + + if _TELEGRAM_LINK_RE.match(raw): + return raw + + raw = raw.lstrip("@").strip() + if not raw or re.search(r"\s", raw): + return None + + if raw.startswith(("t.me/", "telegram.me/")): + return f"https://{raw}" + + if raw.startswith(("+", "joinchat/", "c/")): + return f"https://t.me/{raw}" + + if _TELEGRAM_USERNAME_RE.fullmatch(raw): + return f"https://t.me/{raw}" + + return None + + +def _required_channel_link_from_chat(chat: Any) -> Optional[str]: + username = str(getattr(chat, "username", "") or "").strip().lstrip("@") + if username: + return f"https://t.me/{username}" + + invite_link = normalize_required_channel_link(getattr(chat, "invite_link", None)) + if invite_link: + return invite_link + + return None + + +async def resolve_required_channel_link( + bot: Any, + required_channel_id: Optional[int], + configured_link: object, +) -> Optional[str]: + if bot is not None and required_channel_id: + try: + chat = await bot.get_chat(required_channel_id) + resolved_link = _required_channel_link_from_chat(chat) + if resolved_link: + return resolved_link + except Exception as error: + logging.warning( + "Failed to resolve required channel link from chat %s: %s", + required_channel_id, + error, + ) + + return normalize_required_channel_link(configured_link) + + def is_required_channel_access_error(error: BaseException) -> bool: message = str(error).lower() configuration_markers = ( diff --git a/backend/bot/utils/ttl_cache.py b/backend/bot/utils/ttl_cache.py index 18408fd..d182ae7 100644 --- a/backend/bot/utils/ttl_cache.py +++ b/backend/bot/utils/ttl_cache.py @@ -29,6 +29,15 @@ class AsyncTTLCache: return None return value + def get_stale(self, key: str) -> Optional[Any]: + entry = self._data.get(key) + if entry is None: + return None + _, value = entry + if not self._is_cacheable(value): + return None + return value + @staticmethod def _is_cacheable(value: Any) -> bool: if value is None: diff --git a/backend/config/settings.py b/backend/config/settings.py index bd09406..43795a3 100644 --- a/backend/config/settings.py +++ b/backend/config/settings.py @@ -1,5 +1,6 @@ import logging import os +import re import secrets from typing import Any, Dict, List, Optional @@ -25,7 +26,117 @@ DEFAULT_SUBSCRIPTION_PURCHASE_DESCRIPTION_EN = ( def _split_csv(value: Optional[str]) -> List[str]: if not value: return [] - return [item.strip() for item in value.split(",") if item.strip()] + return [item.strip() for item in re.split(r"[,;\r\n]+", value) if item.strip()] + + +DEFAULT_DISPOSABLE_EMAIL_DOMAINS = "\n".join( + [ + "10minutemail.com", + "10minutemail.net", + "10minutemail.org", + "20minutemail.com", + "33mail.com", + "anonbox.net", + "anonymbox.com", + "armyspy.com", + "byom.de", + "crazymailing.com", + "cuvox.de", + "dayrep.com", + "deadaddress.com", + "dispostable.com", + "dodgeit.com", + "dodgit.com", + "dropmail.me", + "easytrashmail.com", + "emailfake.com", + "emailondeck.com", + "emailtemporanea.com", + "emailtemporanea.net", + "einrot.com", + "fakeinbox.com", + "filzmail.com", + "fleckens.hu", + "generator.email", + "getairmail.com", + "getnada.com", + "grr.la", + "guerrillamail.biz", + "guerrillamail.com", + "guerrillamail.de", + "guerrillamail.info", + "guerrillamail.net", + "guerrillamail.org", + "guerrillamailblock.com", + "gustr.com", + "hmamail.com", + "incognitomail.org", + "inboxbear.com", + "jetable.org", + "jourrapide.com", + "kasmail.com", + "mail-temp.com", + "mailcatch.com", + "maildrop.cc", + "mailexpire.com", + "mailinator.com", + "mailinator.net", + "mailinator.org", + "mailmetrash.com", + "mailnesia.com", + "mailnull.com", + "mailpoof.com", + "mailtothis.com", + "mail.tm", + "mintemail.com", + "mohmal.com", + "moakt.com", + "mytemp.email", + "mytrashmail.com", + "nada.email", + "no-spam.ws", + "pookmail.com", + "rhyta.com", + "sharklasers.com", + "sofort-mail.de", + "spam4.me", + "spambog.com", + "spamdecoy.net", + "spamfree24.org", + "spamgourmet.com", + "spamhole.com", + "spam.la", + "spammotel.com", + "superrito.com", + "teleworm.us", + "tempail.com", + "temp-mail.io", + "temp-mail.org", + "tempmail.com", + "tempmail.dev", + "tempmail.net", + "tempmailo.com", + "temporaryemail.net", + "temporary-mail.net", + "tempr.email", + "throwawaymail.com", + "trash-mail.com", + "trash-mail.de", + "trashmail.com", + "trashmail.me", + "trashmail.net", + "trashmailer.com", + "trashymail.com", + "weg-werf-email.de", + "wegwerfmail.de", + "wegwerfmail.net", + "wegwerfmail.org", + "yomail.info", + "yopmail.com", + "yopmail.fr", + "yopmail.net", + ] +) class DBSettings(BaseModel): @@ -59,9 +170,6 @@ class WebAppSettings(BaseModel): title: str primary_color: str logo_url: Optional[str] - logo_use_emoji: bool - logo_emoji: str - logo_emoji_font: str favicon_use_custom: bool favicon_url: Optional[str] logo_favicon_url: Optional[str] @@ -100,6 +208,10 @@ class Settings(BaseSettings): PANEL_DEVICES_CACHE_TTL_SECONDS: int = Field(default=5) PANEL_ALL_USERS_CACHE_TTL_SECONDS: int = Field(default=5) PANEL_ALL_USERS_PAGE_SIZE: int = Field(default=1000) + PANEL_API_TOTAL_TIMEOUT_SECONDS: float = Field(default=25) + PANEL_API_CONNECT_TIMEOUT_SECONDS: float = Field(default=8) + PANEL_API_SOCK_CONNECT_TIMEOUT_SECONDS: float = Field(default=8) + PANEL_API_SOCK_READ_TIMEOUT_SECONDS: float = Field(default=15) ADMIN_PANEL_STATS_CACHE_TTL_SECONDS: int = Field(default=15) ADMIN_DB_STATS_CACHE_TTL_SECONDS: int = Field(default=5) ADMIN_USERS_LIST_CACHE_TTL_SECONDS: int = Field(default=3) @@ -147,7 +259,6 @@ class Settings(BaseSettings): SUPPORT_LINK: Optional[str] = Field(default=None) SERVER_STATUS_URL: Optional[str] = Field(default=None) - TERMS_OF_SERVICE_URL: Optional[str] = Field(default=None) PRIVACY_POLICY_URL: Optional[str] = Field(default=None) USER_AGREEMENT_URL: Optional[str] = Field(default=None) REQUIRED_CHANNEL_ID: Optional[int] = Field( @@ -194,7 +305,7 @@ class Settings(BaseSettings): STARS_ADMIN_ONLY_ENABLED: bool = Field(default=False) PAYMENT_METHODS_ORDER: Optional[str] = Field( default=None, - description="Comma-separated list of payment methods to show (e.g., severpay,wata,freekassa,yookassa,platega,stars,cryptopay)", # noqa: E501 + description="Comma-separated list of payment methods to show (e.g., severpay,wata,freekassa,yookassa,platega,stars,cryptopay,heleket,paykilla)", # noqa: E501 ) SUBSCRIPTION_PURCHASE_DESCRIPTION_ENABLED: bool = Field( default=True, @@ -282,10 +393,35 @@ class Settings(BaseSettings): default=3, description="Welcome bonus days granted to a newly registered user who joined via referral link.", # noqa: E501 ) + REFERRAL_WELCOME_BONUS_WITHOUT_TELEGRAM_ENABLED: bool = Field( + default=True, + description=( + "Allow referral welcome bonus grants for users who have not linked Telegram. " + "Disposable email domains are still blocked until Telegram is linked." + ), + ) LEGACY_REFS: bool = Field( default=True, description="Allow legacy referral links like ref_ to continue working. Defaults to True when unset.", # noqa: E501 ) + MIGRATION_REMNASHOP_REFERRAL_CODE_COMPAT_ENABLED: bool = Field( + default=False, + description=( + "Accept referral links imported from snoups/remnashop via legacy_referral_codes." + ), + ) + MIGRATION_REMNASHOP_PROMO_CODE_COMPAT_ENABLED: bool = Field( + default=False, + description="Try exact legacy Remnashop promo codes before uppercase normalization.", + ) + MIGRATION_REMNASHOP_IMPORTED_AT: Optional[str] = Field( + default=None, + description="Timestamp of the latest Remnashop import run, managed by the import script.", + ) + MIGRATION_REMNASHOP_NOTES: Optional[str] = Field( + default=None, + description="Operator notes for instances migrated from Remnashop.", + ) APP_RUNTIME_MODE: str = Field( default="production", @@ -328,6 +464,13 @@ class Settings(BaseSettings): TRIAL_DURATION_DAYS: int = Field(default=3) TRIAL_TRAFFIC_LIMIT_GB: Optional[float] = Field(default=5.0) TRIAL_TRAFFIC_STRATEGY: str = Field(default="NO_RESET") + TRIAL_WITHOUT_TELEGRAM_ENABLED: bool = Field( + default=True, + description=( + "Allow trial activation for users who have not linked Telegram. " + "Disposable email domains are still blocked until Telegram is linked." + ), + ) TRIAL_SQUAD_UUIDS: Optional[str] = Field( default=None, description=( @@ -373,15 +516,6 @@ class Settings(BaseSettings): ), ) WEBAPP_LOGO_URL: Optional[str] = Field(default=None) - WEBAPP_LOGO_USE_EMOJI: bool = Field(default=False) - WEBAPP_LOGO_EMOJI: str = Field(default="🫥") - WEBAPP_LOGO_EMOJI_FONT: str = Field( - default="system", - description=( - "Emoji font for logo fallback: system, noto-color, noto-color-animated, " - "noto-emoji, twemoji, openmoji, apple, segoe, noto-local" - ), - ) WEBAPP_FAVICON_USE_CUSTOM: bool = Field(default=False) WEBAPP_FAVICON_URL: Optional[str] = Field(default=None) WEBAPP_LOGO_FAVICON_URL: Optional[str] = Field(default=None) @@ -440,6 +574,13 @@ class Settings(BaseSettings): SMTP_PASSWORD: Optional[str] = Field(default=None) SMTP_FROM_EMAIL: Optional[str] = Field(default=None) SMTP_FROM_NAME: Optional[str] = Field(default=None) + DISPOSABLE_EMAIL_DOMAINS: str = Field( + default=DEFAULT_DISPOSABLE_EMAIL_DOMAINS, + description=( + "Disposable email domains treated as requiring Telegram for trial and " + "referral welcome bonus abuse protection. Accepts commas or one domain per line." + ), + ) SMTP_STARTTLS: bool = Field(default=True) SMTP_USE_SSL: bool = Field(default=False) EMAIL_CODE_TTL_SECONDS: int = Field(default=10 * 60) @@ -544,9 +685,6 @@ class Settings(BaseSettings): title=self.WEBAPP_TITLE, primary_color=self.WEBAPP_PRIMARY_COLOR, logo_url=self.WEBAPP_LOGO_URL, - logo_use_emoji=self.WEBAPP_LOGO_USE_EMOJI, - logo_emoji=self.WEBAPP_LOGO_EMOJI, - logo_emoji_font=self.WEBAPP_LOGO_EMOJI_FONT, favicon_use_custom=self.WEBAPP_FAVICON_USE_CUSTOM, favicon_url=self.WEBAPP_FAVICON_URL, logo_favicon_url=self.WEBAPP_LOGO_FAVICON_URL, @@ -627,6 +765,16 @@ class Settings(BaseSettings): return trial_squads return self.parsed_user_squad_uuids + @computed_field + @property + def disposable_email_domains(self) -> List[str]: + domains: List[str] = [] + for domain in _split_csv(self.DISPOSABLE_EMAIL_DOMAINS): + normalized = domain.strip().lower().lstrip("@.") + if normalized and normalized not in domains: + domains.append(normalized) + return domains + @computed_field @property def parsed_user_external_squad_uuid(self) -> Optional[str]: @@ -788,21 +936,6 @@ class Settings(BaseSettings): def ignore_deprecated_webapp_logo_url_env(cls, _value): return None - @field_validator("WEBAPP_LOGO_USE_EMOJI", mode="before") - @classmethod - def ignore_deprecated_webapp_logo_use_emoji_env(cls, _value): - return False - - @field_validator("WEBAPP_LOGO_EMOJI", mode="before") - @classmethod - def ignore_deprecated_webapp_logo_emoji_env(cls, _value): - return "🫥" - - @field_validator("WEBAPP_LOGO_EMOJI_FONT", mode="before") - @classmethod - def ignore_deprecated_webapp_logo_emoji_font_env(cls, _value): - return "system" - @field_validator("WEBAPP_FAVICON_USE_CUSTOM", mode="before") @classmethod def ignore_deprecated_webapp_favicon_use_custom_env(cls, _value): @@ -893,6 +1026,7 @@ class Settings(BaseSettings): "stars", "cryptopay", "heleket", + "paykilla", ] # Make sure default_order itself includes every registered spec. for sid in spec_ids: diff --git a/backend/config/webapp_themes_config.py b/backend/config/webapp_themes_config.py index 243697b..bae3ab6 100644 --- a/backend/config/webapp_themes_config.py +++ b/backend/config/webapp_themes_config.py @@ -54,6 +54,8 @@ class ThemeTokens(BaseModel): font_logo: Optional[str] = None font_mono: Optional[str] = None home_logo_scale: Optional[int] = None + home_logo_scale_desktop: Optional[int] = None + home_logo_scale_mobile: Optional[int] = None admin_bg: Optional[str] = None admin_surface: Optional[str] = None admin_surface_2: Optional[str] = None @@ -80,14 +82,14 @@ class ThemeTokens(BaseModel): hex_value = "".join(char * 2 for char in hex_value) return f"#{hex_value}" - @field_validator("home_logo_scale") + @field_validator("home_logo_scale", "home_logo_scale_desktop", "home_logo_scale_mobile") @classmethod def _normalize_home_logo_scale(cls, value: Optional[int]) -> Optional[int]: if value is None: return None scale = int(value) if scale < 50 or scale > 300: - raise ValueError("home_logo_scale must be between 50 and 300 percent") + raise ValueError("home logo scale must be between 50 and 300 percent") return scale @@ -369,6 +371,7 @@ def _builtin_theme_assets_need_refresh(key: str, target_dir: Path) -> bool: "--success-text" not in style or ".theme-key-light.app-shell" not in style or "Install guide theme surfaces" not in style + or "Admin controls: range sliders and sortable rows" not in style ) if key == "ascii": return ( @@ -379,6 +382,7 @@ def _builtin_theme_assets_need_refresh(key: str, target_dir: Path) -> bool: or "Console-style tables" not in style or "New webapp surfaces: support, purchase info, password login" not in style or "Install guide theme surfaces" not in style + or "Admin controls: range sliders and sortable rows" not in style ) if key != "windows95": return False @@ -402,6 +406,7 @@ def _builtin_theme_assets_need_refresh(key: str, target_dir: Path) -> bool: or "lucide-qr-code" not in style or "New webapp surfaces: support, purchase info, password login" not in style or "Install guide theme surfaces" not in style + or "Admin controls: range sliders and sortable rows" not in style or any(not (target_dir / "icons" / icon).exists() for icon in required_icons) ) diff --git a/backend/db/dal/payment_dal.py b/backend/db/dal/payment_dal.py index eec2f4e..f47338a 100644 --- a/backend/db/dal/payment_dal.py +++ b/backend/db/dal/payment_dal.py @@ -51,6 +51,15 @@ async def ensure_payment_with_provider_id( description: str, provider: str, provider_payment_id: str, + sale_mode: Optional[str] = None, + tariff_key: Optional[str] = None, + purchased_gb: Optional[float] = None, + purchased_hwid_devices: Optional[int] = None, + hwid_valid_from: Optional[Any] = None, + hwid_valid_until: Optional[Any] = None, + hwid_pricing_period_months: Optional[int] = None, + hwid_proration_ratio: Optional[float] = None, + hwid_full_price: Optional[float] = None, ) -> Payment: """Idempotently create a payment record for a provider event. @@ -72,6 +81,20 @@ async def ensure_payment_with_provider_id( "provider_payment_id": provider_payment_id, "provider": provider, } + optional_fields = { + "sale_mode": sale_mode, + "tariff_key": tariff_key, + "purchased_gb": purchased_gb, + "purchased_hwid_devices": purchased_hwid_devices, + "hwid_valid_from": hwid_valid_from, + "hwid_valid_until": hwid_valid_until, + "hwid_pricing_period_months": hwid_pricing_period_months, + "hwid_proration_ratio": hwid_proration_ratio, + "hwid_full_price": hwid_full_price, + } + payment_payload.update( + {field: value for field, value in optional_fields.items() if value is not None} + ) return await create_payment_record(session, payment_payload) diff --git a/backend/db/dal/promo_code_dal.py b/backend/db/dal/promo_code_dal.py index 8047792..41a376b 100644 --- a/backend/db/dal/promo_code_dal.py +++ b/backend/db/dal/promo_code_dal.py @@ -22,24 +22,46 @@ async def get_promo_code_by_id(session: AsyncSession, promo_code_id: int) -> Opt return await session.get(PromoCode, promo_code_id) -async def get_promo_code_by_code(session: AsyncSession, code_str: str) -> Optional[PromoCode]: +def _promo_lookup_candidates(code_str: str, *, preserve_case: bool) -> List[str]: + code = str(code_str or "").strip() + if not code: + return [] + candidates = [code] if preserve_case else [] + upper_code = code.upper() + if upper_code not in candidates: + candidates.append(upper_code) + return candidates + + +async def get_promo_code_by_code( + session: AsyncSession, code_str: str, *, preserve_case: bool = False +) -> Optional[PromoCode]: """Get promo code by code string (regardless of active status)""" - stmt = select(PromoCode).where(PromoCode.code == code_str.upper()) - result = await session.execute(stmt) - return result.scalar_one_or_none() + for candidate in _promo_lookup_candidates(code_str, preserve_case=preserve_case): + stmt = select(PromoCode).where(PromoCode.code == candidate) + result = await session.execute(stmt) + promo = result.scalar_one_or_none() + if promo: + return promo + return None async def get_active_promo_code_by_code_str( - session: AsyncSession, code_str: str + session: AsyncSession, code_str: str, *, preserve_case: bool = False ) -> Optional[PromoCode]: - stmt = select(PromoCode).where( - PromoCode.code == code_str.upper(), - PromoCode.is_active == True, - PromoCode.current_activations < PromoCode.max_activations, - or_(PromoCode.valid_until == None, PromoCode.valid_until > datetime.now(timezone.utc)), - ) - result = await session.execute(stmt) - return result.scalar_one_or_none() + now = datetime.now(timezone.utc) + for candidate in _promo_lookup_candidates(code_str, preserve_case=preserve_case): + stmt = select(PromoCode).where( + PromoCode.code == candidate, + PromoCode.is_active == True, + PromoCode.current_activations < PromoCode.max_activations, + or_(PromoCode.valid_until == None, PromoCode.valid_until > now), + ) + result = await session.execute(stmt) + promo = result.scalar_one_or_none() + if promo: + return promo + return None async def get_all_active_promo_codes( diff --git a/backend/db/dal/tariff_dal.py b/backend/db/dal/tariff_dal.py index 0d2c6de..1cd1036 100644 --- a/backend/db/dal/tariff_dal.py +++ b/backend/db/dal/tariff_dal.py @@ -1,5 +1,5 @@ import inspect -from datetime import datetime, timezone +from datetime import datetime, timedelta, timezone from typing import Any, Dict, List, Optional from sqlalchemy import and_, delete, func, or_, select, update @@ -189,6 +189,63 @@ async def expire_hwid_device_purchases( return result.rowcount or 0 +def _normalize_aware_utc(value: datetime) -> datetime: + if value.tzinfo is None: + return value.replace(tzinfo=timezone.utc) + return value + + +async def extend_hwid_device_purchases_for_subscription_bonus( + session: AsyncSession, + *, + subscription_id: int, + at: Optional[datetime] = None, + subscription_end_before: Optional[datetime] = None, + delta: timedelta, +) -> int: + if delta.total_seconds() <= 0: + return 0 + at = _normalize_aware_utc(at or datetime.now(timezone.utc)) + end_before = _normalize_aware_utc(subscription_end_before) if subscription_end_before else None + + target_records: List[HwidDevicePurchase] = [] + if end_before: + tail_result = await session.execute( + select(HwidDevicePurchase).where( + and_( + HwidDevicePurchase.subscription_id == subscription_id, + HwidDevicePurchase.purchased_devices > 0, + HwidDevicePurchase.valid_until.is_not(None), + HwidDevicePurchase.valid_until >= end_before, + HwidDevicePurchase.valid_until > at, + or_( + HwidDevicePurchase.valid_from.is_(None), + HwidDevicePurchase.valid_from < end_before, + ), + ) + ) + ) + target_records = list(tail_result.scalars().all()) + + if not target_records: + active_result = await session.execute( + select(HwidDevicePurchase).where( + and_( + *_hwid_active_conditions(subscription_id, at), + HwidDevicePurchase.valid_until.is_not(None), + ) + ) + ) + target_records = list(active_result.scalars().all()) + + for record in target_records: + if record.valid_until is not None: + record.valid_until = _normalize_aware_utc(record.valid_until) + delta + if target_records: + await session.flush() + return len(target_records) + + async def create_tariff_change( session: AsyncSession, change_data: Dict[str, Any], diff --git a/backend/db/dal/user_dal.py b/backend/db/dal/user_dal.py index 6a3c1af..0acc533 100644 --- a/backend/db/dal/user_dal.py +++ b/backend/db/dal/user_dal.py @@ -4,7 +4,7 @@ import string from datetime import datetime, timedelta, timezone from typing import Any, Dict, List, Optional, Tuple -from sqlalchemy import and_, case, delete, desc, func, or_, update +from sqlalchemy import String, and_, case, cast, delete, desc, func, or_, update from sqlalchemy.dialects.postgresql import insert as pg_insert from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.future import select @@ -14,6 +14,8 @@ from ..models import ( AdAttribution, EmailVerificationCode, HwidDevicePurchase, + LegacyImportMapping, + LegacyReferralCode, MessageLog, Payment, PromoCodeActivation, @@ -76,7 +78,7 @@ async def ensure_referral_code(session: AsyncSession, user: User) -> str: Returns the existing or newly generated code. """ if user.referral_code: - normalized = user.referral_code.strip().upper() + normalized = user.referral_code.strip() if normalized != user.referral_code: user.referral_code = normalized await session.flush() @@ -210,7 +212,7 @@ async def create_user(session: AsyncSession, user_data: Dict[str, Any]) -> Tuple if not user_data.get("referral_code"): user_data["referral_code"] = await generate_unique_referral_code(session) else: - user_data["referral_code"] = user_data["referral_code"].strip().upper() + user_data["referral_code"] = user_data["referral_code"].strip() # Use PostgreSQL upsert to avoid IntegrityError on concurrent inserts stmt = ( @@ -567,6 +569,19 @@ async def merge_users( await session.execute( update(model).where(model.user_id == source_user_id).values(user_id=target_user_id) ) + await session.execute( + update(LegacyReferralCode) + .where(LegacyReferralCode.user_id == source_user_id) + .values(user_id=target_user_id) + ) + await session.execute( + update(LegacyImportMapping) + .where( + LegacyImportMapping.target_table == "users", + LegacyImportMapping.target_id == str(source_user_id), + ) + .values(target_id=str(target_user_id)) + ) await session.execute( update(MessageLog) @@ -590,13 +605,60 @@ async def merge_users( return target -async def get_user_by_referral_code(session: AsyncSession, referral_code: str) -> Optional[User]: - normalized = referral_code.strip().upper() +async def get_user_by_referral_code( + session: AsyncSession, + referral_code: str, + *, + include_legacy: bool = False, +) -> Optional[User]: + normalized = referral_code.strip() if not normalized: return None + stmt = select(User).where(User.referral_code == normalized) result = await session.execute(stmt) - return result.scalar_one_or_none() + user = result.scalar_one_or_none() + if user: + return user + + upper_normalized = normalized.upper() + if upper_normalized != normalized: + stmt = select(User).where(User.referral_code == upper_normalized) + result = await session.execute(stmt) + user = result.scalar_one_or_none() + if user: + return user + + if not include_legacy: + return None + + stmt = ( + select(User) + .join(LegacyReferralCode, LegacyReferralCode.user_id == User.user_id) + .where(LegacyReferralCode.code == normalized, LegacyReferralCode.is_active == True) + .limit(1) + ) + result = await session.execute(stmt) + user = result.scalar_one_or_none() + if user: + return user + + if upper_normalized != normalized: + stmt = ( + select(User) + .join(LegacyReferralCode, LegacyReferralCode.user_id == User.user_id) + .where( + LegacyReferralCode.code == upper_normalized, + LegacyReferralCode.is_active == True, + ) + .limit(1) + ) + result = await session.execute(stmt) + user = result.scalar_one_or_none() + if user: + return user + + return None async def update_user( @@ -857,6 +919,29 @@ async def get_user_ids_without_active_subscription(session: AsyncSession) -> Lis return result.scalars().all() +async def get_user_ids_without_any_subscription(session: AsyncSession) -> List[int]: + """Return non-banned user IDs who never had any subscription or trial. + + These are users who registered but have no ``Subscription`` rows at all — + no active, no expired and no trial history. In other words, accounts that + signed up and never did anything. + """ + any_sub = aliased(Subscription) + + stmt = ( + select(User.user_id) + .outerjoin(any_sub, any_sub.user_id == User.user_id) + .where( + and_( + User.is_banned == False, + any_sub.user_id.is_(None), + ) + ) + ) + result = await session.execute(stmt) + 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, "")) @@ -997,6 +1082,31 @@ async def delete_user_and_relations(session: AsyncSession, user_id: int) -> bool await session.execute(delete(UserBilling).where(UserBilling.user_id == user_id)) await session.execute(delete(AdAttribution).where(AdAttribution.user_id == user_id)) await session.execute(delete(UserTelegramAvatar).where(UserTelegramAvatar.user_id == user_id)) + await session.execute(delete(LegacyReferralCode).where(LegacyReferralCode.user_id == user_id)) + await session.execute( + delete(LegacyImportMapping).where( + or_( + and_( + LegacyImportMapping.target_table == "users", + LegacyImportMapping.target_id == str(user_id), + ), + and_( + LegacyImportMapping.target_table == "subscriptions", + LegacyImportMapping.target_id.in_( + select(cast(Subscription.subscription_id, String)).where( + Subscription.user_id == user_id + ) + ), + ), + and_( + LegacyImportMapping.target_table == "payments", + LegacyImportMapping.target_id.in_( + select(cast(Payment.payment_id, String)).where(Payment.user_id == user_id) + ), + ), + ) + ) + ) await session.execute(delete(Payment).where(Payment.user_id == user_id)) await session.execute(delete(Subscription).where(Subscription.user_id == user_id)) diff --git a/backend/db/migrator.py b/backend/db/migrator.py index 73b3e3c..ad97cce 100644 --- a/backend/db/migrator.py +++ b/backend/db/migrator.py @@ -1070,6 +1070,91 @@ def _migration_0033_add_trial_eligibility_reset_marker(connection: Connection) - ) +def _migration_0034_add_legacy_import_compatibility(connection: Connection) -> None: + inspector = inspect(connection) + table_names = set(inspector.get_table_names()) + + if "users" in table_names: + columns = {col["name"]: col for col in inspector.get_columns("users")} + referral_column = columns.get("referral_code") + length = getattr(referral_column.get("type"), "length", None) if referral_column else None + if referral_column and (length is None or int(length) < 64): + connection.execute( + text("ALTER TABLE users ALTER COLUMN referral_code TYPE VARCHAR(64)") + ) + + connection.execute( + text( + """ + CREATE TABLE IF NOT EXISTS legacy_referral_codes ( + legacy_code_id SERIAL PRIMARY KEY, + source VARCHAR(64) NOT NULL DEFAULT 'remnashop', + code VARCHAR(128) NOT NULL, + user_id BIGINT NOT NULL REFERENCES users(user_id), + is_active BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NULL, + CONSTRAINT uq_legacy_referral_source_code UNIQUE (source, code) + ) + """ + ) + ) + for stmt in [ + ( + "CREATE INDEX IF NOT EXISTS ix_legacy_referral_codes_source " + "ON legacy_referral_codes (source)" + ), + "CREATE INDEX IF NOT EXISTS ix_legacy_referral_codes_code ON legacy_referral_codes (code)", + ( + "CREATE INDEX IF NOT EXISTS ix_legacy_referral_codes_user_id " + "ON legacy_referral_codes (user_id)" + ), + ( + "CREATE INDEX IF NOT EXISTS ix_legacy_referral_codes_is_active " + "ON legacy_referral_codes (is_active)" + ), + ]: + connection.execute(text(stmt)) + + connection.execute( + text( + """ + CREATE TABLE IF NOT EXISTS legacy_import_mappings ( + source VARCHAR(64) NOT NULL, + entity_type VARCHAR(64) NOT NULL, + source_id VARCHAR(128) NOT NULL, + target_table VARCHAR(128) NOT NULL, + target_id VARCHAR(128) NOT NULL, + metadata_json TEXT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NULL, + PRIMARY KEY (source, entity_type, source_id) + ) + """ + ) + ) + connection.execute( + text( + """ + CREATE INDEX IF NOT EXISTS ix_legacy_import_mappings_target + ON legacy_import_mappings (target_table, target_id) + """ + ) + ) + + +def _migration_0035_add_subscription_promo_expiry_flag(connection: Connection) -> None: + inspector = inspect(connection) + columns: Set[str] = {col["name"] for col in inspector.get_columns("subscriptions")} + if "suppress_early_expiry_notifications" not in columns: + connection.execute( + text( + "ALTER TABLE subscriptions ADD COLUMN suppress_early_expiry_notifications " + "BOOLEAN NOT NULL DEFAULT FALSE" + ) + ) + + MIGRATIONS: List[Migration] = [ Migration( id="0001_add_channel_subscription_fields", @@ -1247,6 +1332,16 @@ MIGRATIONS: List[Migration] = [ description="Track admin resets of per-user trial eligibility without deleting history", upgrade=_migration_0033_add_trial_eligibility_reset_marker, ), + Migration( + id="0034_add_legacy_import_compatibility", + description="Store legacy import mappings and referral codes for source-bot migrations", + upgrade=_migration_0034_add_legacy_import_compatibility, + ), + Migration( + id="0035_add_subscription_promo_expiry_flag", + description="Suppress multi-day expiry reminders for trial and bonus subscriptions", + upgrade=_migration_0035_add_subscription_promo_expiry_flag, + ), ] diff --git a/backend/db/models.py b/backend/db/models.py index 78bb8c3..25d70e2 100644 --- a/backend/db/models.py +++ b/backend/db/models.py @@ -43,7 +43,7 @@ class User(Base): registration_date = Column(DateTime(timezone=True), server_default=func.now()) is_banned = Column(Boolean, default=False) panel_user_uuid = Column(String, nullable=True, unique=True, index=True) - referral_code = Column(String(16), nullable=True, unique=True, index=True) + referral_code = Column(String(64), nullable=True, unique=True, index=True) referred_by_id = Column(BigInteger, ForeignKey("users.user_id"), nullable=True) lifetime_used_traffic_bytes = Column(BigInteger, nullable=True) lifetime_used_traffic_synced_at = Column(DateTime(timezone=True), nullable=True) @@ -122,6 +122,12 @@ class Subscription(Base): last_notification_sent = Column(DateTime(timezone=True), nullable=True) provider = Column(String, nullable=True) skip_notifications = Column(Boolean, default=False) + # Trial and registration/referral-bonus subscriptions are only a few days + # long, so the multi-day "ending soon" reminders would fire almost as soon + # as they are granted. While this is set the worker keeps only the + # hours-before reminder plus the expiry/after-expiry notices; a real payment + # clears it so the full reminder spectrum resumes. + suppress_early_expiry_notifications = Column(Boolean, nullable=False, default=False) auto_renew_enabled = Column(Boolean, default=True, index=True) tariff_key = Column(String, nullable=True, index=True) tier_baseline_bytes = Column(BigInteger, nullable=True) @@ -396,6 +402,35 @@ class PromoCodeActivation(Base): ) +class LegacyReferralCode(Base): + __tablename__ = "legacy_referral_codes" + + legacy_code_id = Column(Integer, primary_key=True, autoincrement=True) + source = Column(String(64), nullable=False, default="remnashop", index=True) + code = Column(String(128), nullable=False, index=True) + user_id = Column(BigInteger, ForeignKey("users.user_id"), nullable=False, index=True) + is_active = Column(Boolean, nullable=False, default=True, index=True) + created_at = Column(DateTime(timezone=True), server_default=func.now()) + updated_at = Column(DateTime(timezone=True), onupdate=func.now(), nullable=True) + + user = relationship("User") + + __table_args__ = (UniqueConstraint("source", "code", name="uq_legacy_referral_source_code"),) + + +class LegacyImportMapping(Base): + __tablename__ = "legacy_import_mappings" + + source = Column(String(64), primary_key=True) + entity_type = Column(String(64), primary_key=True) + source_id = Column(String(128), primary_key=True) + target_table = Column(String(128), nullable=False) + target_id = Column(String(128), nullable=False) + metadata_json = Column(Text, nullable=True) + created_at = Column(DateTime(timezone=True), server_default=func.now()) + updated_at = Column(DateTime(timezone=True), onupdate=func.now(), nullable=True) + + class MessageLog(Base): __tablename__ = "message_logs" diff --git a/backend/requirements.txt b/backend/requirements.txt index b1aacda..ac2152a 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -1,6 +1,6 @@ aiogram==3.28.2 python-dotenv==1.2.2 -aiohttp==3.13.5 +aiohttp>=3.13.5,<3.14 pydantic==2.13.4 yookassa==3.10.1 httpx>=0.27.0 @@ -9,6 +9,6 @@ email-validator==2.3.0 sqlalchemy[asyncio]==2.0.49 asyncpg==0.31.0 aiocryptopay==0.4.8 -PyJWT[crypto]==2.12.1 +PyJWT[crypto]==2.13.0 Pillow==12.2.0 redis==6.4.0 diff --git a/backend/scripts/__init__.py b/backend/scripts/__init__.py new file mode 100644 index 0000000..51b3887 --- /dev/null +++ b/backend/scripts/__init__.py @@ -0,0 +1 @@ +"""Operational one-shot scripts shipped with the backend image.""" diff --git a/backend/scripts/import_legacy.py b/backend/scripts/import_legacy.py new file mode 100644 index 0000000..d2352a2 --- /dev/null +++ b/backend/scripts/import_legacy.py @@ -0,0 +1,1796 @@ +"""Import data from legacy source bots into the current shop database. + +Currently supported source: + remnashop + +Example: + python backend/scripts/import_legacy.py \ + --source-type remnashop \ + --source-dsn postgresql://user:pass@localhost:5432/remnashop \ + --dry-run +""" + +from __future__ import annotations + +import argparse +import asyncio +import json +import logging +import re +import shlex +import sys +from collections import defaultdict +from datetime import datetime, timedelta, timezone +from decimal import Decimal, InvalidOperation +from pathlib import Path +from typing import Any, Iterable, Optional + +from sqlalchemy import inspect, select, text +from sqlalchemy.dialects.postgresql import insert as pg_insert +from sqlalchemy.ext.asyncio import ( + AsyncConnection, + AsyncSession, + async_sessionmaker, + create_async_engine, +) + +BACKEND_ROOT = Path(__file__).resolve().parents[1] +if str(BACKEND_ROOT) not in sys.path: + sys.path.insert(0, str(BACKEND_ROOT)) + +from config.settings import Settings # noqa: E402 +from db.dal import user_dal # noqa: E402 +from db.migrator import run_database_migrations # noqa: E402 +from db.models import ( # noqa: E402 + AppSettingOverride, + Base, + LegacyImportMapping, + LegacyReferralCode, + MessageLog, + Payment, + PromoCode, + PromoCodeActivation, + Subscription, + User, +) + +try: # cryptography is already used by the app for payment webhook validation. + from cryptography.fernet import Fernet +except Exception: # pragma: no cover - defensive fallback for minimal tooling. + Fernet = None # type: ignore[assignment] + +SOURCE = "remnashop" +REMNASHOP_ENCRYPTED_PREFIX = "enc_" +PLACEHOLDER_SETTING_VALUES = {"change_me", "changeme"} +GIB = 1024**3 +UUID_RE = re.compile( + r"\b[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-" + r"[0-9a-fA-F]{4}-[0-9a-fA-F]{12}\b" +) +SAFE_SCHEMA_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$") +REMNASHOP_PAYMENT_WEBHOOK_PATH = "/api/v1/payments/{gateway}" +REMNASHOP_PANEL_WEBHOOK_PATH = "/api/v1/remnawave" + +SUPPORTED_REMNASHOP_PROVIDER_TYPES = { + "TELEGRAM_STARS", + "YOOKASSA", + "HELEKET", + "PAYKILLA", + "CRYPTOPAY", + "FREEKASSA", + "PLATEGA", + "WATA", +} +UNSUPPORTED_REMNASHOP_PROVIDER_TYPES = { + "YOOMONEY", + "CRYPTOMUS", + "MULENPAY", + "PAYMASTER", + "ROBOKASSA", + "URLPAY", +} +PAYMENT_WEBHOOK_PATHS = { + "yookassa": "/webhook/yookassa", + "wata": "/webhook/wata", + "cryptopay": "/webhook/cryptopay", + "heleket": "/webhook/heleket", + "paykilla": "/webhook/paykilla", + "freekassa": "/webhook/freekassa", + "platega": "/webhook/platega", +} + +logger = logging.getLogger(__name__) + + +def normalize_async_postgres_dsn(dsn: str) -> str: + value = str(dsn or "").strip() + if value.startswith("postgresql+asyncpg://"): + return value + if value.startswith("postgresql://"): + return "postgresql+asyncpg://" + value.removeprefix("postgresql://") + if value.startswith("postgres://"): + return "postgresql+asyncpg://" + value.removeprefix("postgres://") + return value + + +def _json_default(value: Any) -> str: + if isinstance(value, (datetime, Decimal)): + return str(value) + return str(value) + + +def _json_dumps(value: Any) -> str: + return json.dumps(value, ensure_ascii=False, sort_keys=True, default=_json_default) + + +def _safe_schema_name(schema: str) -> str: + value = str(schema or "public").strip() + if not SAFE_SCHEMA_RE.fullmatch(value): + raise ValueError(f"Unsafe PostgreSQL schema name: {schema!r}") + return value + + +def _qtable(schema: str, table: str) -> str: + schema = _safe_schema_name(schema) + return f'"{schema}"."{table}"' + + +def _as_mapping(row: Any) -> dict[str, Any]: + return dict(row._mapping if hasattr(row, "_mapping") else row) + + +def _as_utc(value: Any) -> Optional[datetime]: + if value is None: + return None + if isinstance(value, datetime): + result = value + else: + text_value = str(value).strip() + if not text_value: + return None + try: + result = datetime.fromisoformat(text_value.replace("Z", "+00:00")) + except ValueError: + return None + if result.tzinfo is None: + return result.replace(tzinfo=timezone.utc) + return result.astimezone(timezone.utc) + + +def _to_decimal(value: Any) -> Optional[Decimal]: + if value is None: + return None + try: + return Decimal(str(value)) + except (InvalidOperation, ValueError): + return None + + +def _to_int(value: Any) -> Optional[int]: + number = _to_decimal(value) + if number is None: + return None + try: + return int(number) + except (OverflowError, ValueError): + return None + + +def _split_name(name: Any) -> tuple[Optional[str], Optional[str]]: + value = str(name or "").strip() + if not value: + return None, None + parts = value.split(maxsplit=1) + if len(parts) == 1: + return parts[0][:255], None + return parts[0][:255], parts[1][:255] + + +def _jsonish(value: Any) -> dict[str, Any]: + if isinstance(value, dict): + return value + if isinstance(value, str) and value.strip(): + try: + decoded = json.loads(value) + except ValueError: + return {} + return decoded if isinstance(decoded, dict) else {} + return {} + + +def _strip_env_value(value: str) -> str: + lexer = shlex.shlex(value, posix=True) + lexer.whitespace_split = True + lexer.commenters = "#" + try: + tokens = list(lexer) + except ValueError: + return value.strip().strip("\"'") + return " ".join(tokens).strip() + + +def parse_remnashop_env_text(text_value: str) -> dict[str, str]: + env: dict[str, str] = {} + for raw_line in str(text_value or "").splitlines(): + line = raw_line.strip() + if not line or line.startswith("#"): + continue + if line.startswith("export "): + line = line[len("export ") :].strip() + if "=" not in line: + continue + key, value = line.split("=", 1) + key = key.strip() + if not key or not re.match(r"^[A-Za-z_][A-Za-z0-9_]*$", key): + continue + env[key] = _strip_env_value(value) + return env + + +def read_remnashop_env_file(path: Optional[str]) -> dict[str, str]: + if not path: + return {} + return parse_remnashop_env_text(Path(path).read_text(encoding="utf-8")) + + +def _is_placeholder_setting_value(value: Any) -> bool: + return isinstance(value, str) and value.strip().lower() in PLACEHOLDER_SETTING_VALUES + + +def _clean_url(value: Any) -> Optional[str]: + if _is_placeholder_setting_value(value): + return None + text_value = str(value or "").strip().rstrip("/") + return text_value or None + + +def _remnashop_panel_api_url(value: Any) -> Optional[str]: + host = _clean_url(value) + if not host: + return None + if "://" not in host: + if "." in host: + host = f"https://{host}" + else: + host = f"http://{host}:3000" + if not host.rstrip("/").endswith("/api"): + host = f"{host.rstrip('/')}/api" + return host + + +def _source_public_base_from_env(env: dict[str, str]) -> Optional[str]: + domain = _clean_url(env.get("APP_DOMAIN")) + if not domain: + return None + if "://" not in domain: + domain = f"https://{domain}" + return domain + + +def _support_link_from_username(value: Any) -> Optional[str]: + if _is_placeholder_setting_value(value): + return None + username = str(value or "").strip().lstrip("@") + if not username: + return None + return f"https://t.me/{username}" + + +def _add_override(overrides: dict[str, Any], key: str, value: Any) -> None: + if value is None: + return + if isinstance(value, str): + value = value.strip() + if not value: + return + if value.lower() in PLACEHOLDER_SETTING_VALUES: + return + overrides[key] = value + + +def remnashop_env_overrides(env: dict[str, str]) -> dict[str, Any]: + overrides: dict[str, Any] = {} + _add_override(overrides, "PANEL_API_URL", _remnashop_panel_api_url(env.get("REMNAWAVE_HOST"))) + _add_override(overrides, "PANEL_API_KEY", env.get("REMNAWAVE_TOKEN")) + _add_override(overrides, "PANEL_WEBHOOK_SECRET", env.get("REMNAWAVE_WEBHOOK_SECRET")) + _add_override( + overrides, + "SUPPORT_LINK", + _support_link_from_username(env.get("BOT_SUPPORT_USERNAME")), + ) + _add_override(overrides, "DEFAULT_LANGUAGE", env.get("APP_DEFAULT_LOCALE")) + return overrides + + +def remnashop_source_urls_from_env(env: dict[str, str]) -> dict[str, str]: + base = _source_public_base_from_env(env) + if not base: + return {} + return { + "telegram": f"{base}/api/v1/telegram", + "remnawave_panel": f"{base}{REMNASHOP_PANEL_WEBHOOK_PATH}", + "payments": f"{base}/api/v1/payments/", + } + + +def _normalize_gateway_type(value: Any) -> str: + if hasattr(value, "value"): + value = value.value + text_value = str(value or "").strip().upper() + if "." in text_value: + text_value = text_value.rsplit(".", 1)[-1] + return re.sub(r"[^A-Z0-9_]+", "_", text_value).strip("_") + + +def _normalize_currency(value: Any) -> Optional[str]: + text_value = str(value or "").strip().upper() + if "." in text_value: + text_value = text_value.rsplit(".", 1)[-1] + aliases = {"RUR": "RUB", "STARS": "XTR", "STAR": "XTR"} + normalized = aliases.get(text_value, text_value) + return normalized or None + + +def _truthy(value: Any) -> bool: + if isinstance(value, bool): + return value + if isinstance(value, (int, float)): + return bool(value) + return str(value or "").strip().lower() in {"1", "true", "yes", "on", "active"} + + +def _is_encrypted_remnashop_value(value: Any) -> bool: + return isinstance(value, str) and value.startswith(REMNASHOP_ENCRYPTED_PREFIX) + + +def remnashop_decrypt_value(value: Any, crypt_key: Optional[str]) -> tuple[Any, bool]: + if not _is_encrypted_remnashop_value(value): + return value, False + if not crypt_key or Fernet is None: + return None, True + try: + token = str(value).removeprefix(REMNASHOP_ENCRYPTED_PREFIX).encode() + return Fernet(crypt_key.encode()).decrypt(token).decode(), False + except Exception: + return None, True + + +def remnashop_decrypt_recursive( + value: Any, + crypt_key: Optional[str], + *, + skipped_paths: Optional[list[str]] = None, + path: str = "", +) -> Any: + if isinstance(value, dict): + return { + key: remnashop_decrypt_recursive( + item, + crypt_key, + skipped_paths=skipped_paths, + path=f"{path}.{key}" if path else str(key), + ) + for key, item in value.items() + } + if isinstance(value, list): + return [ + remnashop_decrypt_recursive( + item, + crypt_key, + skipped_paths=skipped_paths, + path=f"{path}[{index}]", + ) + for index, item in enumerate(value) + ] + decrypted, skipped = remnashop_decrypt_value(value, crypt_key) + if skipped and skipped_paths is not None: + skipped_paths.append(path or "") + return decrypted + + +def _provider_mapping_result( + gateway_type: str, + provider_ids: Iterable[str], + overrides: dict[str, Any], + warnings: Optional[list[str]] = None, +) -> dict[str, Any]: + return { + "source_type": gateway_type, + "provider_ids": [provider for provider in provider_ids if provider], + "overrides": overrides, + "warnings": warnings or [], + "supported": True, + } + + +def remnashop_payment_gateway_overrides( + row: dict[str, Any], + *, + crypt_key: Optional[str] = None, +) -> dict[str, Any]: + gateway_type = _normalize_gateway_type(row.get("type")) + if gateway_type not in SUPPORTED_REMNASHOP_PROVIDER_TYPES: + return { + "source_type": gateway_type, + "provider_ids": [], + "overrides": {}, + "warnings": [], + "supported": False, + } + + skipped_secret_paths: list[str] = [] + settings = remnashop_decrypt_recursive( + _jsonish(row.get("settings")), + crypt_key, + skipped_paths=skipped_secret_paths, + ) + active = _truthy(row.get("is_active")) + currency = _normalize_currency(row.get("currency")) + overrides: dict[str, Any] = {} + warnings = [ + ( + f"Skipped encrypted Remnashop {gateway_type} setting '{path}': " + "APP_CRYPT_KEY is missing or invalid" + ) + for path in skipped_secret_paths + ] + + if gateway_type == "TELEGRAM_STARS": + _add_override(overrides, "STARS_ENABLED", active) + return _provider_mapping_result(gateway_type, ["stars"], overrides, warnings) + + if gateway_type == "YOOKASSA": + _add_override(overrides, "YOOKASSA_ENABLED", active) + _add_override(overrides, "YOOKASSA_SHOP_ID", settings.get("shop_id")) + _add_override(overrides, "YOOKASSA_SECRET_KEY", settings.get("api_key")) + _add_override(overrides, "YOOKASSA_DEFAULT_RECEIPT_EMAIL", settings.get("customer")) + _add_override(overrides, "YOOKASSA_VAT_CODE", settings.get("vat_code")) + if currency and currency != "RUB": + warnings.append( + f"YooKassa supports RUB only in this shop; source currency was {currency}" + ) + return _provider_mapping_result(gateway_type, ["yookassa"], overrides, warnings) + + if gateway_type == "WATA": + _add_override(overrides, "WATA_ENABLED", active) + _add_override(overrides, "WATA_API_TOKEN", settings.get("api_key")) + return _provider_mapping_result(gateway_type, ["wata"], overrides, warnings) + + if gateway_type == "CRYPTOPAY": + _add_override(overrides, "CRYPTOPAY_ENABLED", active) + _add_override(overrides, "CRYPTOPAY_TOKEN", settings.get("api_key")) + if currency and currency != "RUB": + warnings.append( + f"CryptoPay source currency was {currency}; Minishop keeps payment currency " + "controlled by tariffs/default currency. Configure CRYPTOPAY_ASSET manually " + "if this instance needs a different default." + ) + return _provider_mapping_result(gateway_type, ["cryptopay"], overrides, warnings) + + if gateway_type == "HELEKET": + _add_override(overrides, "HELEKET_ENABLED", active) + _add_override(overrides, "HELEKET_MERCHANT_ID", settings.get("merchant_id")) + _add_override(overrides, "HELEKET_API_KEY", settings.get("api_key")) + if currency and currency != "RUB": + warnings.append( + f"Heleket source currency was {currency}; Minishop keeps payment currency " + "controlled by tariffs/default currency. Configure HELEKET_CURRENCY manually " + "if this instance needs a different default." + ) + return _provider_mapping_result(gateway_type, ["heleket"], overrides, warnings) + + if gateway_type == "PAYKILLA": + _add_override(overrides, "PAYKILLA_ENABLED", active) + _add_override( + overrides, + "PAYKILLA_API_KEY", + settings.get("api_key") or settings.get("public_key") or settings.get("publicKey"), + ) + _add_override( + overrides, + "PAYKILLA_SECRET_KEY", + settings.get("secret_key") or settings.get("secretKey"), + ) + if currency and currency != "RUB": + warnings.append( + f"PayKilla source currency was {currency}; Minishop keeps payment currency " + "controlled by tariffs/default currency. Configure PAYKILLA_CURRENCY and " + "PAYKILLA_PAYMENT_CURRENCIES manually if this instance needs a different default." + ) + return _provider_mapping_result(gateway_type, ["paykilla"], overrides, warnings) + + if gateway_type == "FREEKASSA": + _add_override(overrides, "FREEKASSA_ENABLED", active) + _add_override(overrides, "FREEKASSA_MERCHANT_ID", settings.get("shop_id")) + _add_override(overrides, "FREEKASSA_API_KEY", settings.get("api_key")) + _add_override(overrides, "FREEKASSA_SECOND_SECRET", settings.get("secret_word_2")) + _add_override(overrides, "FREEKASSA_PAYMENT_METHOD_ID", settings.get("payment_system_id")) + _add_override(overrides, "FREEKASSA_PAYMENT_IP", settings.get("customer_ip")) + if settings.get("customer_email"): + warnings.append( + "FreeKassa customer_email was captured by Remnashop but is not a " + "Minishop provider setting" + ) + return _provider_mapping_result(gateway_type, ["freekassa"], overrides, warnings) + + if gateway_type == "PLATEGA": + _add_override(overrides, "PLATEGA_ENABLED", active) + _add_override(overrides, "PLATEGA_SBP_ENABLED", active) + _add_override(overrides, "PLATEGA_MERCHANT_ID", settings.get("merchant_id")) + _add_override(overrides, "PLATEGA_SECRET", settings.get("api_key")) + _add_override(overrides, "PLATEGA_PAYMENT_METHOD", settings.get("payment_method")) + _add_override(overrides, "PLATEGA_SBP_METHOD", settings.get("payment_method")) + if currency and currency != "RUB": + warnings.append( + f"Platega source currency was {currency}; Minishop keeps payment currency " + "controlled by tariffs/default currency. Configure PLATEGA_SUPPORTED_CURRENCIES " + "manually if this instance needs a different currency." + ) + return _provider_mapping_result(gateway_type, ["platega_sbp"], overrides, warnings) + + return _provider_mapping_result(gateway_type, [], overrides, warnings) + + +def _target_webhook_url(base_url: Optional[str], path: str) -> Optional[str]: + base = _clean_url(base_url) + if not base: + return None + return f"{base}{path if path.startswith('/') else '/' + path}" + + +def remnashop_post_migration_actions( + *, + target_webhook_base_url: Optional[str], + imported_provider_ids: Iterable[str], + source_env: Optional[dict[str, str]] = None, +) -> dict[str, Any]: + provider_ids = list(dict.fromkeys(imported_provider_ids)) + payment_actions = [] + seen_paths: set[str] = set() + for provider_id in provider_ids: + path = PAYMENT_WEBHOOK_PATHS.get(provider_id) + if not path or path in seen_paths: + continue + seen_paths.add(path) + payment_actions.append( + { + "provider": provider_id, + "new_url": _target_webhook_url(target_webhook_base_url, path), + "where": { + "yookassa": "YooKassa merchant cabinet -> HTTP notifications URL", + "wata": "WATA merchant dashboard -> webhook/callback URL", + "cryptopay": "CryptoBot/Crypto Pay app -> webhook URL", + "heleket": "Heleket merchant dashboard -> payment webhook/callback URL", + "paykilla": "PayKilla Dashboard -> Settings -> Webhooks", + "freekassa": "FreeKassa shop settings -> notification/result URL", + "platega": "Platega merchant/project settings -> webhook URL", + }.get(provider_id, "Payment provider dashboard -> webhook/callback URL"), + } + ) + + return { + "webhook_base_url_configured": bool(_clean_url(target_webhook_base_url)), + "source_urls": remnashop_source_urls_from_env(source_env or {}), + "remnawave_panel": { + "new_url": _target_webhook_url(target_webhook_base_url, "/webhook/panel"), + "where": "Remnawave Panel -> WEBHOOK_URL", + "secret": ( + "Set the Remnawave webhook secret to the value stored in PANEL_WEBHOOK_SECRET." + ), + }, + "payment_providers": payment_actions, + "telegram": { + "new_url": _target_webhook_url(target_webhook_base_url, "/tg/webhook"), + "where": "Telegram webhook is set automatically by Minishop on startup.", + }, + } + + +def _listish(value: Any) -> list[Any]: + if value is None: + return [] + if isinstance(value, list): + return value + if isinstance(value, tuple): + return list(value) + return [value] + + +def remnashop_traffic_gb_to_bytes(value: Any) -> Optional[int]: + number = _to_decimal(value) + if number is None: + return None + return int(number * GIB) + + +def remnashop_pricing_amount(pricing: Any) -> float: + data = _jsonish(pricing) + for key in ("final_amount", "total_amount", "amount", "price"): + number = _to_decimal(data.get(key)) + if number is not None: + return float(number) + return 0.0 + + +def remnashop_pricing_currency(pricing: Any, fallback: Any = None) -> str: + data = _jsonish(pricing) + currency = str(data.get("currency") or fallback or "RUB").strip().upper() + return currency or "RUB" + + +def remnashop_transaction_status(status: Any, gateway_type: Any = None) -> str: + source_status = str(status or "").strip().upper() + provider = str(gateway_type or "").strip().lower() + if source_status == "COMPLETED": + return "succeeded" + if source_status == "PENDING": + return f"pending_{provider}" if provider else "pending" + if source_status == "CANCELED": + return "canceled" + if source_status == "REFUNDED": + return "refunded" + if source_status == "FAILED": + return "failed" + return source_status.lower() or "unknown" + + +def remnashop_sale_mode(purchase_type: Any) -> str: + source_type = str(purchase_type or "").strip().upper() + if source_type in {"NEW", "RENEW"}: + return "subscription" + if source_type == "CHANGE": + return "tariff_upgrade" + return source_type.lower() or "subscription" + + +def remnashop_months_from_plan_snapshot( + plan_snapshot: Any, + *, + created_at: Any = None, + expire_at: Any = None, +) -> Optional[int]: + data = _jsonish(plan_snapshot) + for key in ("duration_months", "months", "month"): + months = _to_int(data.get(key)) + if months and months > 0: + return months + + for key in ("duration_days", "days", "duration"): + days = _to_int(data.get(key)) + if days and days > 0: + return max(1, round(days / 30)) + + start = _as_utc(created_at) + end = _as_utc(expire_at) + if start and end and end > start: + return max(1, round((end - start).days / 30)) + return None + + +def remnashop_tariff_key(plan_snapshot: Any, tariff_map: dict[str, str]) -> Optional[str]: + data = _jsonish(plan_snapshot) + candidates = [ + data.get("id"), + data.get("name"), + data.get("tag"), + data.get("public_code"), + ] + for candidate in candidates: + key = str(candidate or "").strip() + if key and key in tariff_map: + return tariff_map[key] + return None + + +def _provider_value(gateway_type: Any) -> str: + value = str(gateway_type or "remnashop").strip().lower() + if value == "telegram_stars": + return "stars" + return value or "remnashop" + + +def _extract_panel_subscription_uuid(url: Any, panel_user_uuid: Optional[str]) -> Optional[str]: + value = str(url or "") + if not value: + return None + panel_user_uuid = str(panel_user_uuid or "").lower() + for match in UUID_RE.finditer(value): + candidate = match.group(0).lower() + if candidate != panel_user_uuid: + return candidate + return None + + +def _legacy_user_metadata(row: dict[str, Any]) -> dict[str, Any]: + keys = ( + "id", + "points", + "personal_discount", + "purchase_discount", + "role", + "is_rules_accepted", + "is_trial_available", + "language", + "current_subscription_id", + ) + return {key: row.get(key) for key in keys if row.get(key) is not None} + + +def _counter() -> dict[str, int]: + return defaultdict(int) + + +class RemnashopImporter: + def __init__( + self, + *, + source: AsyncConnection, + target: AsyncSession, + source_schema: str, + only: set[str], + on_conflict: str, + dry_run: bool, + created_by_admin_id: int, + tariff_map: dict[str, str], + write_admin_compat_overrides: bool, + source_env: Optional[dict[str, str]] = None, + source_crypt_key: Optional[str] = None, + target_webhook_base_url: Optional[str] = None, + ) -> None: + self.source = source + self.target = target + self.source_schema = _safe_schema_name(source_schema) + self.only = only + self.on_conflict = on_conflict + self.dry_run = dry_run + self.created_by_admin_id = created_by_admin_id + self.tariff_map = tariff_map + self.write_admin_compat_overrides = write_admin_compat_overrides + self.source_env = source_env or {} + self.source_crypt_key = source_crypt_key or self.source_env.get("APP_CRYPT_KEY") + self.target_webhook_base_url = target_webhook_base_url + self.tables: set[str] = set() + self.user_map: dict[int, int] = {} + self.imported_payment_provider_ids: list[str] = [] + self.summary: dict[str, Any] = { + "source": SOURCE, + "dry_run": dry_run, + "on_conflict": on_conflict, + "users": _counter(), + "referrals": _counter(), + "subscriptions": _counter(), + "payments": _counter(), + "promocodes": _counter(), + "payment_provider_settings": _counter(), + "settings": _counter(), + "warnings": [], + } + + async def run(self) -> dict[str, Any]: + self.tables = await self._source_tables() + await self._warn_missing_tables() + + if self._should_run("users"): + await self.import_users() + if self._should_run("referrals"): + await self.import_referrals() + if self._should_run("subscriptions"): + await self.import_subscriptions() + if self._should_run("payments"): + await self.import_payments() + if self._should_run("promocodes"): + await self.import_promocodes() + if self._should_run("settings"): + await self.import_settings() + + self.summary["post_migration_actions"] = remnashop_post_migration_actions( + target_webhook_base_url=self.target_webhook_base_url, + imported_provider_ids=self.imported_payment_provider_ids, + source_env=self.source_env, + ) + + if self.write_admin_compat_overrides: + await self._write_admin_overrides() + + return self._plain_summary() + + def _plain_summary(self) -> dict[str, Any]: + result = dict(self.summary) + for key, value in list(result.items()): + if isinstance(value, defaultdict): + result[key] = dict(value) + return result + + def _should_run(self, key: str) -> bool: + return not self.only or key in self.only or "all" in self.only + + async def _source_tables(self) -> set[str]: + def load_tables(sync_connection: Any) -> set[str]: + return set(inspect(sync_connection).get_table_names(schema=self.source_schema)) + + return await self.source.run_sync(load_tables) + + async def _warn_missing_tables(self) -> None: + required = {"users", "subscriptions", "transactions", "referrals", "settings"} + missing = sorted(required - self.tables) + if missing: + self.summary["warnings"].append(f"Missing source tables: {', '.join(missing)}") + + async def _fetch_rows(self, table: str, *, order_by: str = "id") -> list[dict[str, Any]]: + if table not in self.tables: + return [] + order_sql = f" ORDER BY {order_by}" if order_by else "" + result = await self.source.execute( + text(f"SELECT * FROM {_qtable(self.source_schema, table)}{order_sql}") + ) + return [_as_mapping(row) for row in result.mappings().all()] + + async def _fetch_one(self, table: str) -> Optional[dict[str, Any]]: + rows = await self._fetch_rows(table, order_by="") + return rows[0] if rows else None + + async def _latest_panel_uuid_by_telegram(self) -> dict[int, str]: + if "subscriptions" not in self.tables: + return {} + result = await self.source.execute( + text( + f""" + SELECT DISTINCT ON (user_telegram_id) + user_telegram_id, + user_remna_id + FROM {_qtable(self.source_schema, "subscriptions")} + WHERE user_remna_id IS NOT NULL + ORDER BY user_telegram_id, updated_at DESC NULLS LAST, id DESC + """ + ) + ) + panel_by_tg: dict[int, str] = {} + for row in result.mappings().all(): + telegram_id = _to_int(row.get("user_telegram_id")) + panel_uuid = str(row.get("user_remna_id") or "").strip() + if telegram_id and panel_uuid: + panel_by_tg[telegram_id] = panel_uuid + return panel_by_tg + + async def _target_user_for_telegram(self, telegram_id: Any) -> Optional[User]: + normalized = _to_int(telegram_id) + if normalized is None: + return None + user = await user_dal.get_user_by_telegram_id(self.target, normalized) + if not user: + user = await user_dal.get_user_by_id(self.target, normalized) + if user: + self.user_map[normalized] = int(user.user_id) + return user + + def _can_overwrite(self) -> bool: + return self.on_conflict == "overwrite" + + def _can_merge_existing(self) -> bool: + return self.on_conflict in {"merge", "overwrite"} + + def _assign_if_allowed(self, model: Any, attr: str, value: Any) -> bool: + if value is None: + return False + current = getattr(model, attr, None) + if self._can_overwrite() or current in (None, ""): + setattr(model, attr, value) + return True + return False + + async def _upsert_mapping( + self, + *, + entity_type: str, + source_id: Any, + target_table: str, + target_id: Any, + metadata: Optional[dict[str, Any]] = None, + ) -> None: + now = datetime.now(timezone.utc) + source_id_value = str(source_id) + target_id_value = str(target_id) + stmt = ( + pg_insert(LegacyImportMapping) + .values( + source=SOURCE, + entity_type=entity_type, + source_id=source_id_value, + target_table=target_table, + target_id=target_id_value, + metadata_json=_json_dumps(metadata or {}), + updated_at=now, + ) + .on_conflict_do_update( + index_elements=[ + LegacyImportMapping.source, + LegacyImportMapping.entity_type, + LegacyImportMapping.source_id, + ], + set_={ + "target_table": target_table, + "target_id": target_id_value, + "metadata_json": _json_dumps(metadata or {}), + "updated_at": now, + }, + ) + ) + await self.target.execute(stmt) + + async def _get_mapping(self, entity_type: str, source_id: Any) -> Optional[LegacyImportMapping]: + stmt = select(LegacyImportMapping).where( + LegacyImportMapping.source == SOURCE, + LegacyImportMapping.entity_type == entity_type, + LegacyImportMapping.source_id == str(source_id), + ) + result = await self.target.execute(stmt) + return result.scalar_one_or_none() + + async def _upsert_setting_override(self, key: str, value: Any) -> bool: + from bot.app.web.admin_settings_manifest import coerce_value, get_field_by_key + + field = get_field_by_key(key) + if field is None: + self.summary["warnings"].append(f"Skipped unknown admin setting override: {key}") + return False + try: + value = coerce_value(field, value) + except ValueError as exc: + self.summary["warnings"].append(f"Skipped invalid admin setting override {key}: {exc}") + return False + + now = datetime.now(timezone.utc) + encoded = json.dumps(value, ensure_ascii=False, separators=(",", ":")) + stmt = ( + pg_insert(AppSettingOverride) + .values( + key=key, + value=encoded, + updated_at=now, + updated_by=self.created_by_admin_id or None, + ) + .on_conflict_do_update( + index_elements=[AppSettingOverride.key], + set_={ + "value": encoded, + "updated_at": now, + "updated_by": self.created_by_admin_id or None, + }, + ) + ) + await self.target.execute(stmt) + return True + + async def _write_setting_overrides( + self, + overrides: dict[str, Any], + *, + summary_key: str, + ) -> list[str]: + written: list[str] = [] + for key, value in overrides.items(): + if await self._upsert_setting_override(key, value): + written.append(key) + self.summary[summary_key]["overrides_written"] += 1 + else: + self.summary[summary_key]["overrides_skipped"] += 1 + return written + + async def import_env_settings(self) -> list[str]: + if not self.source_env: + self.summary["settings"]["source_env_missing"] += 1 + return [] + + overrides = remnashop_env_overrides(self.source_env) + if not overrides: + self.summary["settings"]["source_env_no_supported_values"] += 1 + return [] + + written = await self._write_setting_overrides(overrides, summary_key="settings") + if written: + self.summary["settings"]["source_env_overrides_written"] += 1 + await self._upsert_mapping( + entity_type="settings_env", + source_id="remnashop.env", + target_table="app_setting_overrides", + target_id=",".join(written) if written else "none", + metadata={ + "override_keys": written, + "source_keys_used": sorted( + key + for key in ( + "REMNAWAVE_HOST", + "REMNAWAVE_TOKEN", + "REMNAWAVE_WEBHOOK_SECRET", + "BOT_SUPPORT_USERNAME", + "APP_DEFAULT_LOCALE", + ) + if self.source_env.get(key) + and not _is_placeholder_setting_value(self.source_env.get(key)) + ), + "has_app_crypt_key": bool(self.source_env.get("APP_CRYPT_KEY")), + "source_urls": remnashop_source_urls_from_env(self.source_env), + "ignored_keys_present": sorted( + key for key in ("BOT_MINI_APP",) if self.source_env.get(key) + ), + }, + ) + return written + + async def import_payment_provider_settings(self) -> None: + if "payment_gateways" not in self.tables: + self.summary["payment_provider_settings"]["missing_source_table"] += 1 + return + + rows = await self._fetch_rows("payment_gateways", order_by="order_index, id") + if not rows: + self.summary["payment_provider_settings"]["empty_source_table"] += 1 + return + + active_provider_ids: list[str] = [] + for index, row in enumerate(rows): + source_id = row.get("id") or row.get("type") or f"row:{index}" + mapping = remnashop_payment_gateway_overrides( + row, + crypt_key=self.source_crypt_key, + ) + gateway_type = mapping["source_type"] + self.summary["payment_provider_settings"]["seen"] += 1 + + for warning in mapping["warnings"]: + self.summary["warnings"].append(warning) + + if not mapping["supported"]: + self.summary["payment_provider_settings"]["unsupported"] += 1 + display_type = gateway_type or str(row.get("type") or "unknown") + self.summary["warnings"].append( + f"Remnashop payment provider {display_type} is not supported by " + "Minishop; configure it manually if it is still needed." + ) + await self._upsert_mapping( + entity_type="payment_provider_settings", + source_id=source_id, + target_table="manual_configuration_required", + target_id=display_type, + metadata={ + "source_type": display_type, + "active": _truthy(row.get("is_active")), + "currency": _normalize_currency(row.get("currency")), + "supported": False, + }, + ) + continue + + written = await self._write_setting_overrides( + mapping["overrides"], + summary_key="payment_provider_settings", + ) + if written: + self.summary["payment_provider_settings"]["providers_mapped"] += 1 + else: + self.summary["payment_provider_settings"]["providers_without_overrides"] += 1 + + if _truthy(row.get("is_active")): + for provider_id in mapping["provider_ids"]: + if provider_id and provider_id not in active_provider_ids: + active_provider_ids.append(provider_id) + if provider_id and provider_id not in self.imported_payment_provider_ids: + self.imported_payment_provider_ids.append(provider_id) + + await self._upsert_mapping( + entity_type="payment_provider_settings", + source_id=source_id, + target_table="app_setting_overrides", + target_id=",".join(written) if written else "none", + metadata={ + "source_type": gateway_type, + "provider_ids": mapping["provider_ids"], + "active": _truthy(row.get("is_active")), + "currency": _normalize_currency(row.get("currency")), + "override_keys": written, + "source_settings_keys": sorted(_jsonish(row.get("settings")).keys()), + "warnings_count": len(mapping["warnings"]), + "supported": True, + }, + ) + + if active_provider_ids: + order_value = ",".join(active_provider_ids) + if await self._upsert_setting_override("PAYMENT_METHODS_ORDER", order_value): + self.summary["payment_provider_settings"]["payment_order_written"] += 1 + + async def _upsert_legacy_referral_code(self, *, code: str, user_id: int) -> None: + if len(code) > 128: + self.summary["warnings"].append( + f"Skipped overlong legacy referral code for user {user_id}: {len(code)} chars" + ) + return + now = datetime.now(timezone.utc) + stmt = ( + pg_insert(LegacyReferralCode) + .values( + source=SOURCE, + code=code, + user_id=user_id, + is_active=True, + updated_at=now, + ) + .on_conflict_do_update( + index_elements=[LegacyReferralCode.source, LegacyReferralCode.code], + set_={"user_id": user_id, "is_active": True, "updated_at": now}, + ) + ) + await self.target.execute(stmt) + + async def _record_user_state_note( + self, + *, + telegram_id: int, + user_id: int, + metadata: dict[str, Any], + ) -> None: + if not metadata: + return + if await self._get_mapping("user_state", telegram_id): + return + log = MessageLog( + user_id=None, + target_user_id=user_id, + event_type="legacy_remnashop_user_state", + content=_json_dumps(metadata), + is_admin_event=True, + ) + self.target.add(log) + await self.target.flush() + await self._upsert_mapping( + entity_type="user_state", + source_id=telegram_id, + target_table="message_logs", + target_id=log.log_id, + metadata=metadata, + ) + + async def _source_referral_code_conflicts(self, code: str, user_id: int) -> bool: + existing = await user_dal.get_user_by_referral_code( + self.target, + code, + include_legacy=False, + ) + return bool(existing and int(existing.user_id) != int(user_id)) + + async def import_users(self) -> None: + rows = await self._fetch_rows("users", order_by="telegram_id") + panel_by_tg = await self._latest_panel_uuid_by_telegram() + for row in rows: + telegram_id = _to_int(row.get("telegram_id")) + if telegram_id is None: + self.summary["users"]["skipped"] += 1 + continue + + first_name, last_name = _split_name(row.get("name")) + panel_uuid = panel_by_tg.get(telegram_id) + referral_code = str(row.get("referral_code") or "").strip() or None + created_at = _as_utc(row.get("created_at")) or datetime.now(timezone.utc) + language = str(row.get("language") or "ru").strip().lower()[:8] or "ru" + + existing = await self._target_user_for_telegram(telegram_id) + if existing and self.on_conflict == "skip": + target = existing + self.summary["users"]["skipped"] += 1 + elif existing: + target = existing + if self._can_merge_existing(): + self._assign_if_allowed(target, "username", row.get("username")) + self._assign_if_allowed(target, "first_name", first_name) + self._assign_if_allowed(target, "last_name", last_name) + self._assign_if_allowed(target, "language_code", language) + self._assign_if_allowed(target, "panel_user_uuid", panel_uuid) + if bool(row.get("is_blocked")): + target.is_banned = True + elif self._can_overwrite(): + target.is_banned = False + if bool(row.get("is_bot_blocked")): + target.telegram_notifications_status = "blocked" + target.telegram_notifications_checked_at = datetime.now(timezone.utc) + target.telegram_notifications_blocked_at = datetime.now(timezone.utc) + if referral_code and len(referral_code) <= 64 and not target.referral_code: + if not await self._source_referral_code_conflicts( + referral_code, + int(target.user_id), + ): + target.referral_code = referral_code + self.summary["users"]["updated"] += 1 + else: + new_referral_code = None + if referral_code and len(referral_code) <= 64: + conflict = await self._source_referral_code_conflicts( + referral_code, + telegram_id, + ) + if not conflict: + new_referral_code = referral_code + + target, created = await user_dal.create_user( + self.target, + { + "user_id": telegram_id, + "telegram_id": telegram_id, + "username": row.get("username"), + "first_name": first_name, + "last_name": last_name, + "language_code": language, + "registration_date": created_at, + "is_banned": bool(row.get("is_blocked")), + "panel_user_uuid": panel_uuid, + "referral_code": new_referral_code, + "telegram_notifications_status": "blocked" + if bool(row.get("is_bot_blocked")) + else "unknown", + "telegram_notifications_checked_at": datetime.now(timezone.utc) + if bool(row.get("is_bot_blocked")) + else None, + "telegram_notifications_blocked_at": datetime.now(timezone.utc) + if bool(row.get("is_bot_blocked")) + else None, + }, + ) + self.summary["users"]["created" if created else "updated"] += 1 + + if not target: + self.summary["users"]["skipped"] += 1 + continue + + self.user_map[telegram_id] = int(target.user_id) + if referral_code: + await self._upsert_legacy_referral_code(code=referral_code, user_id=target.user_id) + + metadata = _legacy_user_metadata(row) + if panel_uuid: + metadata["panel_user_uuid"] = panel_uuid + await self._upsert_mapping( + entity_type="user", + source_id=telegram_id, + target_table="users", + target_id=target.user_id, + metadata=metadata, + ) + await self._record_user_state_note( + telegram_id=telegram_id, + user_id=int(target.user_id), + metadata=metadata, + ) + + await self.target.flush() + + async def import_referrals(self) -> None: + rows = await self._fetch_rows("referrals", order_by="id") + for row in rows: + referrer = await self._target_user_for_telegram(row.get("referrer_telegram_id")) + referred = await self._target_user_for_telegram(row.get("referred_telegram_id")) + if not referrer or not referred or referrer.user_id == referred.user_id: + self.summary["referrals"]["skipped"] += 1 + continue + if referred.referred_by_id and not self._can_overwrite(): + self.summary["referrals"]["skipped"] += 1 + continue + referred.referred_by_id = int(referrer.user_id) + self.summary["referrals"]["updated"] += 1 + await self._upsert_mapping( + entity_type="referral", + source_id=row.get("id") or f"{referrer.user_id}:{referred.user_id}", + target_table="users", + target_id=referred.user_id, + metadata={ + "referrer_user_id": referrer.user_id, + "referred_user_id": referred.user_id, + }, + ) + await self.target.flush() + + async def import_subscriptions(self) -> None: + rows = await self._fetch_rows("subscriptions", order_by="id") + now = datetime.now(timezone.utc) + for row in rows: + user = await self._target_user_for_telegram(row.get("user_telegram_id")) + if not user: + self.summary["subscriptions"]["skipped"] += 1 + continue + + panel_user_uuid = str(row.get("user_remna_id") or user.panel_user_uuid or "").strip() + if not panel_user_uuid: + self.summary["subscriptions"]["skipped"] += 1 + continue + if not user.panel_user_uuid or self._can_overwrite(): + user.panel_user_uuid = panel_user_uuid + + source_id = row.get("id") + mapping = await self._get_mapping("subscription", source_id) + existing: Optional[Subscription] = None + if mapping and str(mapping.target_id).isdigit(): + existing = await self.target.get(Subscription, int(mapping.target_id)) + + panel_sub_uuid = _extract_panel_subscription_uuid(row.get("url"), panel_user_uuid) + if not existing and panel_sub_uuid: + existing = ( + await self.target.execute( + select(Subscription).where( + Subscription.panel_subscription_uuid == panel_sub_uuid + ) + ) + ).scalar_one_or_none() + + status = str(row.get("status") or "UNKNOWN").strip().upper() + expire_at = _as_utc(row.get("expire_at")) or now + created_at = _as_utc(row.get("created_at")) or now + plan_snapshot = _jsonish(row.get("plan_snapshot")) + traffic_limit_bytes = remnashop_traffic_gb_to_bytes(row.get("traffic_limit")) + payload = { + "user_id": int(user.user_id), + "panel_user_uuid": panel_user_uuid, + "panel_subscription_uuid": panel_sub_uuid, + "start_date": created_at, + "end_date": expire_at, + "duration_months": remnashop_months_from_plan_snapshot( + plan_snapshot, + created_at=created_at, + expire_at=expire_at, + ), + "is_active": status in {"ACTIVE", "LIMITED"} and expire_at > now, + "status_from_panel": status, + "traffic_limit_bytes": traffic_limit_bytes, + "provider": "trial" if bool(row.get("is_trial")) else SOURCE, + "skip_notifications": True, + "auto_renew_enabled": False, + "tariff_key": remnashop_tariff_key(plan_snapshot, self.tariff_map), + "tier_baseline_bytes": traffic_limit_bytes, + "period_start_at": created_at, + "hwid_device_limit": _to_int(row.get("device_limit")), + } + metadata = { + "source": SOURCE, + "source_subscription_id": source_id, + "traffic_limit_strategy": str(row.get("traffic_limit_strategy") or ""), + "tag": row.get("tag"), + "internal_squads": [str(item) for item in _listish(row.get("internal_squads"))], + "external_squad": str(row.get("external_squad") or "") or None, + "url": row.get("url"), + "plan_snapshot": plan_snapshot, + } + + if existing: + if self.on_conflict == "skip": + self.summary["subscriptions"]["skipped"] += 1 + else: + for key, value in payload.items(): + self._assign_if_allowed(existing, key, value) + self.summary["subscriptions"]["updated"] += 1 + target_subscription_id = existing.subscription_id + else: + subscription = Subscription(**payload) + self.target.add(subscription) + await self.target.flush() + target_subscription_id = subscription.subscription_id + self.summary["subscriptions"]["created"] += 1 + + await self._upsert_mapping( + entity_type="subscription", + source_id=source_id, + target_table="subscriptions", + target_id=target_subscription_id, + metadata=metadata, + ) + + await self.target.flush() + + async def import_payments(self) -> None: + rows = await self._fetch_rows("transactions", order_by="id") + for row in rows: + user = await self._target_user_for_telegram(row.get("user_telegram_id")) + if not user: + self.summary["payments"]["skipped"] += 1 + continue + + provider_payment_id = f"{SOURCE}:{row.get('payment_id') or row.get('id')}" + existing = ( + await self.target.execute( + select(Payment).where(Payment.provider_payment_id == provider_payment_id) + ) + ).scalar_one_or_none() + + provider = _provider_value(row.get("gateway_type")) + plan_snapshot = _jsonish(row.get("plan_snapshot")) + created_at = _as_utc(row.get("created_at")) + payload = { + "user_id": int(user.user_id), + "provider_payment_id": provider_payment_id, + "provider": provider, + "amount": remnashop_pricing_amount(row.get("pricing")), + "currency": remnashop_pricing_currency(row.get("pricing"), row.get("currency")), + "status": remnashop_transaction_status(row.get("status"), provider), + "description": self._payment_description(row), + "subscription_duration_months": remnashop_months_from_plan_snapshot( + plan_snapshot, + created_at=row.get("created_at"), + expire_at=None, + ), + "sale_mode": remnashop_sale_mode(row.get("purchase_type")), + "tariff_key": remnashop_tariff_key(plan_snapshot, self.tariff_map), + "created_at": created_at, + } + payload = {key: value for key, value in payload.items() if value is not None} + + if existing: + if self.on_conflict == "skip": + self.summary["payments"]["skipped"] += 1 + else: + for key, value in payload.items(): + self._assign_if_allowed(existing, key, value) + self.summary["payments"]["updated"] += 1 + target_payment_id = existing.payment_id + else: + payment = Payment(**payload) + self.target.add(payment) + await self.target.flush() + target_payment_id = payment.payment_id + self.summary["payments"]["created"] += 1 + + await self._upsert_mapping( + entity_type="payment", + source_id=row.get("payment_id") or row.get("id"), + target_table="payments", + target_id=target_payment_id, + metadata={ + "source_transaction_id": row.get("id"), + "is_test": row.get("is_test"), + "purchase_type": str(row.get("purchase_type") or ""), + "gateway_type": str(row.get("gateway_type") or ""), + "plan_snapshot": plan_snapshot, + }, + ) + + await self.target.flush() + + def _payment_description(self, row: dict[str, Any]) -> str: + snapshot = _jsonish(row.get("plan_snapshot")) + plan_name = str(snapshot.get("name") or snapshot.get("tag") or "").strip() + purchase_type = str(row.get("purchase_type") or "").strip().upper() + if plan_name: + return f"Remnashop import: {purchase_type} {plan_name}".strip() + return f"Remnashop import: {purchase_type}".strip() + + async def import_promocodes(self) -> None: + if "promocodes" not in self.tables: + self.summary["promocodes"]["missing_source_table"] += 1 + return + + activation_rows_by_code = await self._source_promocode_activation_rows() + rows = await self._fetch_rows("promocodes", order_by="id") + for row in rows: + code = str(row.get("code") or "").strip() + if not code: + self.summary["promocodes"]["skipped"] += 1 + continue + + bonus_days = self._promo_bonus_days(row) + if bonus_days is None or bonus_days <= 0: + self.summary["promocodes"]["unsupported_reward"] += 1 + continue + + existing = ( + await self.target.execute(select(PromoCode).where(PromoCode.code == code)) + ).scalar_one_or_none() + activations = activation_rows_by_code.get(code, []) + valid_until = None + lifetime_days = _to_int(row.get("lifetime")) + if lifetime_days and _as_utc(row.get("created_at")): + valid_until = _as_utc(row.get("created_at")) + if valid_until: + valid_until = valid_until + timedelta(days=lifetime_days) + + payload = { + "code": code, + "bonus_days": int(bonus_days), + "max_activations": _to_int(row.get("max_activations")) or 1_000_000, + "current_activations": len(activations), + "is_active": bool(row.get("is_active")), + "created_by_admin_id": self.created_by_admin_id, + "created_at": _as_utc(row.get("created_at")), + "valid_until": valid_until, + } + payload = {key: value for key, value in payload.items() if value is not None} + + if existing: + if self.on_conflict == "skip": + self.summary["promocodes"]["skipped"] += 1 + else: + for key, value in payload.items(): + self._assign_if_allowed(existing, key, value) + self.summary["promocodes"]["updated"] += 1 + promo = existing + else: + promo = PromoCode(**payload) + self.target.add(promo) + await self.target.flush() + self.summary["promocodes"]["created"] += 1 + + await self._upsert_mapping( + entity_type="promocode", + source_id=row.get("id") or code, + target_table="promo_codes", + target_id=promo.promo_code_id, + metadata={ + "reward_type": str(row.get("reward_type") or ""), + "reward": row.get("reward"), + "plan": _jsonish(row.get("plan")), + "lifetime": row.get("lifetime"), + }, + ) + await self._import_promocode_activations(promo, activations) + + await self.target.flush() + + async def _source_promocode_activation_rows(self) -> dict[str, list[dict[str, Any]]]: + if "promocode_activations" not in self.tables: + return {} + result = await self.source.execute( + text( + f""" + SELECT a.*, p.code + FROM {_qtable(self.source_schema, "promocode_activations")} a + JOIN {_qtable(self.source_schema, "promocodes")} p + ON p.id = a.promocode_id + ORDER BY a.id + """ + ) + ) + by_code: dict[str, list[dict[str, Any]]] = defaultdict(list) + for row in result.mappings().all(): + mapping = _as_mapping(row) + code = str(mapping.get("code") or "").strip() + if code: + by_code[code].append(mapping) + return by_code + + def _promo_bonus_days(self, row: dict[str, Any]) -> Optional[int]: + reward_type = str(row.get("reward_type") or "").strip().upper() + if reward_type == "DURATION": + return _to_int(row.get("reward")) + if reward_type == "SUBSCRIPTION": + plan = _jsonish(row.get("plan")) + return ( + _to_int(plan.get("duration_days")) + or _to_int(plan.get("days")) + or _to_int(row.get("reward")) + ) + return None + + async def _import_promocode_activations( + self, + promo: PromoCode, + activations: Iterable[dict[str, Any]], + ) -> None: + for activation in activations: + user = await self._target_user_for_telegram(activation.get("user_telegram_id")) + if not user: + self.summary["promocodes"]["activation_skipped"] += 1 + continue + stmt = ( + pg_insert(PromoCodeActivation) + .values( + promo_code_id=promo.promo_code_id, + user_id=user.user_id, + activated_at=_as_utc(activation.get("activated_at")) + or datetime.now(timezone.utc), + ) + .on_conflict_do_nothing( + index_elements=[ + PromoCodeActivation.promo_code_id, + PromoCodeActivation.user_id, + ] + ) + ) + await self.target.execute(stmt) + self.summary["promocodes"]["activation_imported"] += 1 + + async def import_settings(self) -> None: + source_settings = await self._fetch_one("settings") + plans = ( + await self._fetch_rows("plans", order_by="order_index") + if "plans" in self.tables + else [] + ) + notes = { + "default_currency": ( + source_settings.get("default_currency") if source_settings else None + ), + "settings": { + key: source_settings.get(key) + for key in ("access", "requirements", "notifications", "referral", "menu") + if source_settings and source_settings.get(key) is not None + }, + "plans_count": len(plans), + "plans": [ + { + "id": plan.get("id"), + "name": plan.get("name"), + "type": str(plan.get("type") or ""), + "traffic_limit": plan.get("traffic_limit"), + "device_limit": plan.get("device_limit"), + "tag": plan.get("tag"), + } + for plan in plans[:100] + ], + "source_env": { + "provided": bool(self.source_env), + "supported_keys_present": sorted( + key + for key in ( + "REMNAWAVE_HOST", + "REMNAWAVE_TOKEN", + "REMNAWAVE_WEBHOOK_SECRET", + "BOT_SUPPORT_USERNAME", + "APP_DEFAULT_LOCALE", + "APP_DOMAIN", + "APP_CRYPT_KEY", + ) + if self.source_env.get(key) + and not _is_placeholder_setting_value(self.source_env.get(key)) + ), + "source_urls": remnashop_source_urls_from_env(self.source_env), + "ignored_keys_present": sorted( + key for key in ("BOT_MINI_APP",) if self.source_env.get(key) + ), + }, + } + env_override_keys = await self.import_env_settings() + await self.import_payment_provider_settings() + notes["env_override_keys"] = env_override_keys + notes["payment_provider_ids"] = list(dict.fromkeys(self.imported_payment_provider_ids)) + await self._upsert_mapping( + entity_type="settings", + source_id="singleton", + target_table="app_setting_overrides", + target_id="MIGRATION_REMNASHOP_NOTES", + metadata=notes, + ) + self.summary["settings"]["captured"] += 1 + + async def _write_admin_overrides(self) -> None: + now = datetime.now(timezone.utc).isoformat() + plain_summary = self._plain_summary() + await self._upsert_setting_override( + "MIGRATION_REMNASHOP_REFERRAL_CODE_COMPAT_ENABLED", + True, + ) + await self._upsert_setting_override( + "MIGRATION_REMNASHOP_PROMO_CODE_COMPAT_ENABLED", + "promocodes" in self.tables, + ) + await self._upsert_setting_override("MIGRATION_REMNASHOP_IMPORTED_AT", now) + await self._upsert_setting_override( + "MIGRATION_REMNASHOP_NOTES", + _json_dumps(plain_summary), + ) + self.summary["settings"]["admin_overrides_written"] += 1 + + +def parse_only(value: str) -> set[str]: + if not value: + return set() + return {item.strip().lower() for item in value.split(",") if item.strip()} + + +def parse_tariff_map(value: Optional[str]) -> dict[str, str]: + if not value: + return {} + path = Path(value) + raw = path.read_text(encoding="utf-8") if path.exists() else value + decoded = json.loads(raw) + if not isinstance(decoded, dict): + raise ValueError("--tariff-map-json must be a JSON object or a path to one") + return {str(key): str(mapped) for key, mapped in decoded.items()} + + +def build_arg_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description="Import legacy bot data into this shop.") + parser.add_argument("--source-type", choices=[SOURCE], default=SOURCE) + parser.add_argument("--source-dsn", required=True) + parser.add_argument("--source-schema", default="public") + parser.add_argument( + "--source-env-file", + help=( + "Path to the source Remnashop .env. Used for APP_CRYPT_KEY, Remnawave " + "API settings and selected safe compatibility values." + ), + ) + parser.add_argument( + "--source-crypt-key", + help="Explicit Remnashop APP_CRYPT_KEY. Overrides the value from --source-env-file.", + ) + parser.add_argument("--target-dsn") + parser.add_argument( + "--only", + default="all", + help=( + "Comma-separated sections: " + "all,users,referrals,subscriptions,payments,promocodes,settings" + ), + ) + parser.add_argument( + "--on-conflict", + choices=["merge", "skip", "overwrite"], + default="merge", + ) + parser.add_argument("--dry-run", action="store_true") + parser.add_argument("--created-by-admin-id", type=int, default=0) + parser.add_argument( + "--tariff-map-json", + help="JSON object or path mapping remnashop plan id/name/tag to local tariff_key.", + ) + parser.add_argument( + "--no-admin-compat-overrides", + action="store_true", + help="Do not enable migration compatibility toggles in admin settings.", + ) + return parser + + +async def _prepare_target_schema(engine: Any) -> None: + async with engine.begin() as connection: + await connection.run_sync(Base.metadata.create_all) + await connection.run_sync(run_database_migrations) + + +async def run_import(args: argparse.Namespace) -> dict[str, Any]: + settings = Settings() + source_env = read_remnashop_env_file(args.source_env_file) + source_crypt_key = args.source_crypt_key or source_env.get("APP_CRYPT_KEY") + source_engine = create_async_engine(normalize_async_postgres_dsn(args.source_dsn)) + target_engine = create_async_engine( + normalize_async_postgres_dsn(args.target_dsn or settings.DATABASE_URL) + ) + await _prepare_target_schema(target_engine) + + session_factory = async_sessionmaker( + bind=target_engine, + class_=AsyncSession, + expire_on_commit=False, + autocommit=False, + autoflush=False, + ) + + async with source_engine.connect() as source, session_factory() as target: + importer = RemnashopImporter( + source=source, + target=target, + source_schema=args.source_schema, + only=parse_only(args.only), + on_conflict=args.on_conflict, + dry_run=bool(args.dry_run), + created_by_admin_id=args.created_by_admin_id, + tariff_map=parse_tariff_map(args.tariff_map_json), + write_admin_compat_overrides=not args.no_admin_compat_overrides, + source_env=source_env, + source_crypt_key=source_crypt_key, + target_webhook_base_url=settings.WEBHOOK_BASE_URL, + ) + summary = await importer.run() + if args.dry_run: + await target.rollback() + else: + await target.commit() + + await source_engine.dispose() + await target_engine.dispose() + return summary + + +def main() -> None: + logging.basicConfig(level=logging.INFO, format="%(levelname)s %(message)s") + args = build_arg_parser().parse_args() + summary = asyncio.run(run_import(args)) + print(_json_dumps(summary)) + + +if __name__ == "__main__": + main() diff --git a/deploy/docker/frontend/nginx.conf b/deploy/docker/frontend/nginx.conf index 00bcfab..fa71122 100644 --- a/deploy/docker/frontend/nginx.conf +++ b/deploy/docker/frontend/nginx.conf @@ -84,7 +84,7 @@ server { proxy_set_header X-Forwarded-Proto $scheme; } - location ~ ^/(webapp-logo|webapp-uploaded-logo|webapp-favicon|webapp-emoji|webapp-theme-css|webapp-theme-assets)/ { + location ~ ^/(webapp-logo|webapp-uploaded-logo|webapp-favicon|webapp-theme-css|webapp-theme-assets)/ { proxy_pass http://backend:8081; proxy_http_version 1.1; proxy_set_header Host $host; diff --git a/docs-site/astro.config.mjs b/docs-site/astro.config.mjs index 5f1b806..0b0e2ff 100644 --- a/docs-site/astro.config.mjs +++ b/docs-site/astro.config.mjs @@ -96,6 +96,7 @@ export default defineConfig({ items: [ { label: 'Обзор миграций', slug: 'migrations' }, { label: 'remnawave-tg-shop', slug: 'migrations/remnawave-tg-shop' }, + { label: 'remnashop', slug: 'migrations/remnashop' }, ], }, { diff --git a/docs-site/public/demo/demo-shell.js b/docs-site/public/demo/demo-shell.js index 542012f..5c334ee 100644 --- a/docs-site/public/demo/demo-shell.js +++ b/docs-site/public/demo/demo-shell.js @@ -1,5 +1,6 @@ const frame = document.getElementById("demo-frame"); const runtimeBase = "/demo/runtime"; +const runtimeAppPath = `${runtimeBase}/app/index.html`; const demoBase = "/demo"; const defaultMock = "tariffs"; const publicRouteAliases = new Map([["/app", "/home"]]); @@ -8,6 +9,8 @@ const stateMocks = new Set([ "depleted", "no-subscription", "trial", + "trial-telegram", + "referral-telegram", "devices", "notifications", "auth", @@ -145,7 +148,7 @@ if (initialMock !== "emails") { params.delete("screen"); params.delete("admin_section"); params.set("path", initialRoute); - frame.src = `${runtimeBase}/app/?${params.toString()}${window.location.hash || ""}`; + frame.src = `${runtimeAppPath}?${params.toString()}${window.location.hash || ""}`; } const routeFromRuntimeUrl = (url) => { @@ -155,7 +158,11 @@ const routeFromRuntimeUrl = (url) => { const runtimePath = normalizePath( url.pathname.slice(runtimeBase.length) || "/home", ); - if (runtimePath === "/app" || runtimePath === "/app.html") { + if ( + runtimePath === "/app" || + runtimePath === "/app/index.html" || + runtimePath === "/app.html" + ) { return normalizePath(url.searchParams.get("path") || "/home"); } return runtimePath; @@ -171,7 +178,7 @@ const runtimeSrc = (route, searchParams = new URLSearchParams()) => { nextParams.delete("screen"); nextParams.delete("admin_section"); nextParams.set("path", normalizePath(route)); - return `${runtimeBase}/app/?${nextParams.toString()}${window.location.hash || ""}`; + return `${runtimeAppPath}?${nextParams.toString()}${window.location.hash || ""}`; }; const topbar = document.querySelector(".demo-topbar"); const toggle = document.querySelector(".demo-topbar__toggle"); @@ -226,7 +233,8 @@ const setCollapsed = (collapsed) => { toggle?.addEventListener("click", () => setCollapsed(false)); hide?.addEventListener("click", () => setCollapsed(true)); -if (stateSelect) stateSelect.value = normalizeStateMock(params.get("mock") || initialMock); +if (stateSelect) + stateSelect.value = normalizeStateMock(params.get("mock") || initialMock); setDemoMode(initialMock); stateSelect?.addEventListener("change", () => { const mock = normalizeStateMock(stateSelect.value); diff --git a/docs-site/scripts/generate-email-previews.py b/docs-site/scripts/generate-email-previews.py index 84d54a4..facbe3e 100644 --- a/docs-site/scripts/generate-email-previews.py +++ b/docs-site/scripts/generate-email-previews.py @@ -1,5 +1,8 @@ +import base64 +import hashlib import json import sys +import tempfile from pathlib import Path from types import SimpleNamespace @@ -9,6 +12,7 @@ sys.path.insert(0, str(BACKEND_ROOT)) if hasattr(sys.stdout, "reconfigure"): sys.stdout.reconfigure(encoding="utf-8") +from bot.services import email_templates as email_templates_module # noqa: E402 from bot.services.email_templates import ( # noqa: E402 render_account_merged, render_login_code, @@ -23,6 +27,34 @@ from bot.services.email_templates import ( # noqa: E402 ) LANGUAGE = "ru" +PREVIEW_LOGO_FILE = ( + REPO_ROOT + / "backend" + / "bot" + / "app" + / "web" + / "templates" + / "default-brand" + / "favicons" + / "19b2a242e5b7bc2d" + / "icon-180.png" +) +PREVIEW_LOGO_TEMP_DIR = tempfile.TemporaryDirectory() + + +def prepare_preview_logo_url() -> str: + if not PREVIEW_LOGO_FILE.exists(): + return "" + body = PREVIEW_LOGO_FILE.read_bytes() + digest = hashlib.sha256(body).hexdigest()[:16] + filename = f"logo-{digest}.png" + logo_dir = Path(PREVIEW_LOGO_TEMP_DIR.name) + (logo_dir / filename).write_bytes(body) + email_templates_module._WEBAPP_UPLOADED_LOGO_DIR = logo_dir + return f"/webapp-uploaded-logo/{filename}" + + +PREVIEW_LOGO_URL = prepare_preview_logo_url() class PreviewI18n: @@ -63,8 +95,7 @@ def settings(): return SimpleNamespace( DEFAULT_LANGUAGE=LANGUAGE, EMAIL_CODE_TTL_SECONDS=600, - WEBAPP_LOGO_URL="", - WEBAPP_LOGO_USE_EMOJI=False, + WEBAPP_LOGO_URL=PREVIEW_LOGO_URL, WEBAPP_PRIMARY_COLOR="#00fe7a", WEBAPP_TITLE="remnawave-minishop", ) @@ -95,10 +126,20 @@ def preview(item_id: str, category: str, title: str, content): "category": category, "title": title, "subject": content.subject, - "html": content.html, + "html": preview_html(content), } +def preview_html(content): + rendered = content.html + for image in content.inline_images: + data_url = ( + f"data:{image.content_type};base64,{base64.b64encode(image.data).decode('ascii')}" + ) + rendered = rendered.replace(f"cid:{image.content_id}", data_url) + return rendered + + def payment_preview( item_id: str, title: str, diff --git a/docs-site/scripts/sync-docs.mjs b/docs-site/scripts/sync-docs.mjs index acb15ca..cb3ce42 100644 --- a/docs-site/scripts/sync-docs.mjs +++ b/docs-site/scripts/sync-docs.mjs @@ -30,6 +30,7 @@ const descriptions = { 'features/support.md': 'Пользовательские тикеты, список обращений в админке, уведомления и лимиты поддержки.', 'migrations/index.md': 'Готовые сценарии миграции в Remnawave Minishop с других ботов.', 'migrations/remnawave-tg-shop.md': 'Перенос данных со старого remnawave-tg-shop на split-архитектуру Minishop.', + 'migrations/remnashop.md': 'Импорт данных из Remnashop через install wizard или скрипт import_legacy.py.', 'troubleshooting/issues.md': 'Короткие чеклисты для частых проблем запуска, вебхуков, Mini App и платежей.', 'troubleshooting/logs.md': 'Какие логи смотреть при диагностике backend, worker, frontend, миграций и вебхуков.', 'troubleshooting/maintenance.md': 'Обновления, миграции, резервные копии и проверки продакшен-стека.', diff --git a/docs-site/src/lib/emailPreviews.mjs b/docs-site/src/lib/emailPreviews.mjs index 3a0d6b8..1822f42 100644 --- a/docs-site/src/lib/emailPreviews.mjs +++ b/docs-site/src/lib/emailPreviews.mjs @@ -31,6 +31,7 @@ for (const command of pythonCommands) { const result = spawnSync(command, [generatorPath], { cwd: repoRoot, encoding: "utf8", + maxBuffer: 20 * 1024 * 1024, env: { ...process.env, PYTHONIOENCODING: "utf-8", diff --git a/docs-site/src/pages/demo.astro b/docs-site/src/pages/demo.astro index f5ce8e4..0a7ec7d 100644 --- a/docs-site/src/pages/demo.astro +++ b/docs-site/src/pages/demo.astro @@ -1,7 +1,7 @@ --- import { emailPreviews } from '../lib/emailPreviews.mjs'; -const defaultDemoSrc = '/demo/runtime/app/?path=/home&mock=tariffs'; +const defaultDemoSrc = '/demo/runtime/app/index.html?path=/home&mock=tariffs'; const docsHref = '/getting-started/demo/'; --- @@ -467,6 +467,8 @@ const docsHref = '/getting-started/demo/'; + + @@ -529,7 +531,7 @@ const docsHref = '/getting-started/demo/'; title={`Email preview: ${preview.title}`} srcdoc={preview.html} loading="lazy" - sandbox="" + sandbox="allow-scripts" > diff --git a/docs/configuration/env-vars.md b/docs/configuration/env-vars.md index b7dbdf7..0bf7f96 100644 --- a/docs/configuration/env-vars.md +++ b/docs/configuration/env-vars.md @@ -59,6 +59,10 @@ | `PANEL_DEVICES_CACHE_TTL_SECONDS` | TTL кеша устройств пользователя Remnawave. | | `PANEL_ALL_USERS_CACHE_TTL_SECONDS` | TTL кеша полных сканов пользователей Remnawave. | | `PANEL_ALL_USERS_PAGE_SIZE` | Размер страницы Remnawave `/users`. | +| `PANEL_API_TOTAL_TIMEOUT_SECONDS` | Общий timeout запроса к Remnawave API. | +| `PANEL_API_CONNECT_TIMEOUT_SECONDS` | Timeout получения соединения с Remnawave API. | +| `PANEL_API_SOCK_CONNECT_TIMEOUT_SECONDS` | Timeout TCP/TLS-подключения к Remnawave API. | +| `PANEL_API_SOCK_READ_TIMEOUT_SECONDS` | Timeout ожидания данных ответа Remnawave API. | | `ADMIN_PANEL_STATS_CACHE_TTL_SECONDS` | TTL статистики Remnawave в админке. | | `ADMIN_DB_STATS_CACHE_TTL_SECONDS` | TTL дорогих DB-агрегатов админки. | | `ADMIN_USERS_LIST_CACHE_TTL_SECONDS` | TTL списка пользователей админки. | @@ -101,11 +105,10 @@ | `DEFAULT_CURRENCY_SYMBOL` | Символ/код валюты в интерфейсе. | | `SUPPORT_LINK` | Внешняя ссылка поддержки. | | `SERVER_STATUS_URL` | Страница статуса сервиса. | -| `TERMS_OF_SERVICE_URL` | Условия использования. | | `PRIVACY_POLICY_URL` | Политика конфиденциальности. | | `USER_AGREEMENT_URL` | Пользовательское соглашение. | -| `REQUIRED_CHANNEL_ID` | ID обязательного Telegram-канала. | -| `REQUIRED_CHANNEL_LINK` | Ссылка на обязательный канал. | +| `REQUIRED_CHANNEL_ID` | ID обязательного Telegram-канала. Используется для проверки подписки и автоматического получения ссылки кнопки, если бот видит канал. | +| `REQUIRED_CHANNEL_LINK` | Необязательная запасная ссылка на обязательный канал (`@username` или invite-link), если ссылку нельзя получить по ID. | | `START_COMMAND_DESCRIPTION` | Описание `/start` для меню Telegram. | | `DISABLE_WELCOME_MESSAGE` | Отключить приветствие на `/start`. | @@ -157,9 +160,6 @@ | `TELEGRAM_OAUTH_REQUEST_ACCESS` | `.env` | Дополнительные разрешения, например `write`. | | `WEBAPP_PRIMARY_COLOR` | Админка | Устаревшее env-поле, игнорируется. | | `WEBAPP_LOGO_URL` | Админка | Устаревшее env-поле, игнорируется. | -| `WEBAPP_LOGO_USE_EMOJI` | Админка | Устаревшее env-поле, игнорируется. | -| `WEBAPP_LOGO_EMOJI` | Админка | Устаревшее env-поле, игнорируется. | -| `WEBAPP_LOGO_EMOJI_FONT` | Админка | Устаревшее env-поле, игнорируется. | | `WEBAPP_FAVICON_USE_CUSTOM` | Админка | Устаревшее env-поле, игнорируется. | | `WEBAPP_FAVICON_URL` | Админка | Устаревшее env-поле, игнорируется. | | `WEBAPP_LOGO_FAVICON_URL` | Админка | Устаревшее env-поле, игнорируется. | @@ -197,7 +197,7 @@ | Переменная | Назначение | | --- | --- | -| `PAYMENT_METHODS_ORDER` | Порядок кнопок оплаты: `severpay,wata,freekassa,platega,yookassa,stars,cryptopay,heleket`. | +| `PAYMENT_METHODS_ORDER` | Порядок кнопок оплаты: `severpay,wata,freekassa,platega,yookassa,stars,cryptopay,heleket,paykilla`. | | `SUBSCRIPTION_PURCHASE_DESCRIPTION_ENABLED` | Показывать описание подписки перед выбором срока. | | `SUBSCRIPTION_PURCHASE_DESCRIPTION_RU` / `SUBSCRIPTION_PURCHASE_DESCRIPTION_EN` | Локализованное описание подписки. | | `PAYMENT__WEBAPP_LABEL_RU` / `PAYMENT__WEBAPP_LABEL_EN` | Текст кнопки провайдера в Web App. | @@ -213,6 +213,7 @@ | `WATA_ENABLED` | Включает Wata. | | `CRYPTOPAY_ENABLED` | Включает CryptoPay. | | `HELEKET_ENABLED` | Включает Heleket. | +| `PAYKILLA_ENABLED` | Включает PayKilla. | Конкретные ключи отображения: @@ -271,6 +272,12 @@ PAYMENT_HELEKET_WEBAPP_ICON PAYMENT_HELEKET_TELEGRAM_LABEL_RU PAYMENT_HELEKET_TELEGRAM_LABEL_EN PAYMENT_HELEKET_TELEGRAM_EMOJI +PAYMENT_PAYKILLA_WEBAPP_LABEL_RU +PAYMENT_PAYKILLA_WEBAPP_LABEL_EN +PAYMENT_PAYKILLA_WEBAPP_ICON +PAYMENT_PAYKILLA_TELEGRAM_LABEL_RU +PAYMENT_PAYKILLA_TELEGRAM_LABEL_EN +PAYMENT_PAYKILLA_TELEGRAM_EMOJI ``` ### YooKassa @@ -357,6 +364,33 @@ PAYMENT_HELEKET_TELEGRAM_EMOJI | `HELEKET_VERIFY_WEBHOOK_SIGNATURE` | Проверять подпись webhook. | | `HELEKET_TRUSTED_IPS` | Список доверенных IP webhook-источников. | +### PayKilla + +Для приема оплат нужен API key типа **HMAC** с правом **INVOICE**. Право **WITHDRAWAL** для оплаты подписок не требуется; включайте его только для отдельной интеграции выплат. + +Webhook настраивается в PayKilla Dashboard: **Settings -> Webhooks**. Укажите `WEBHOOK_BASE_URL` + `/webhook/paykilla`, например `https://bot.example.com/webhook/paykilla`. Включите события `INVOICE_PAID` и `INVOICE_EXPIRED` как минимум. Рекомендуемый набор галочек: `INVOICE_PAID`, `PAYMENT_COMPLETED`, `PAYMENT_FAILED`, `PAYMENT_OVERPAID`, `PAYMENT_UNDERPAID`, `PAYMENT_PARTIAL`, `INVOICE_EXPIRED`, `COMPLIANCE_FAILED`. Если хотите видеть промежуточные статусы в логах PayKilla, дополнительно включите `INVOICE_CREATED`, `PAYMENT_PENDING`, `TRANSACTION_CONFIRMED` и `TRANSACTION_FINAL`. + +| Переменная | Назначение | +| --- | --- | +| `PAYKILLA_BASE_URL` | Базовый URL API, по умолчанию `https://account-api.paykilla.com`. | +| `PAYKILLA_WIDGET_URL` | URL hosted checkout, по умолчанию `https://gopay.paykilla.com`. | +| `PAYKILLA_API_KEY` / `PAYKILLA_V2_API_KEY` | Public HMAC key с правом `INVOICE`. | +| `PAYKILLA_SECRET_KEY` / `PAYKILLA_V2_SECRET_KEY` | Secret HMAC key для подписи API-запросов и проверки webhook. | +| `PAYKILLA_CURRENCY` | Резервная валюта инвойса PayKilla для платежей, чья валюта тарифа не входит в `PAYKILLA_INVOICE_CURRENCIES`. По умолчанию `USD`. | +| `PAYKILLA_INVOICE_CURRENCIES` | Валюты, которые PayKilla принимает в поле `currency` при создании invoice. По умолчанию `USD,EUR`. Если тариф в `RUB`, Minishop конвертирует сумму в `PAYKILLA_CURRENCY`. | +| `PAYKILLA_PAYMENT_CURRENCIES` | Crypto tickers для оплаты. Рекомендуемый стартовый вариант: `USDTTRC`; добавляйте `BTC`, `ETH` и другие тикеры только если они доступны в PayKilla Dashboard для merchant account. | +| `PAYKILLA_SUPPORTED_CURRENCIES` | Валюты тарифов/платежей, которым разрешено использовать PayKilla в этом магазине. | +| `PAYKILLA_INVOICE_TYPE` | Необязательный override: `FIAT_BASED`, `FIXED_AMOUNT` или `OPEN_AMOUNT`. | +| `PAYKILLA_LIFETIME_SECONDS` | TTL инвойса, отправляется как `expiredAt`. | +| `PAYKILLA_RECV_WINDOW_MS` | `recvWindow` для подписанных API-запросов. | +| `PAYKILLA_USER_PAYS_SERVICE_FEE` | `true`, если пользователь оплачивает service fee. | +| `PAYKILLA_USER_PAYS_NETWORK_FEE` | `true`, если пользователь оплачивает network fee. | +| `PAYKILLA_EXCHANGE_RATE_URL` | Бесплатный no-key endpoint курса для конвертации валюты тарифа в валюту инвойса. По умолчанию `https://open.er-api.com/v6/latest/{source}`. Поддерживает placeholders `{source}` и `{target}`. | +| `PAYKILLA_EXCHANGE_RATE_CACHE_SECONDS` | Кэш курса и PayKilla currency limits в секундах. По умолчанию `3600`. | +| `PAYKILLA_VERIFY_WEBHOOK_SIGNATURE` | Проверять `X-API-SIGN` по raw body webhook. | +| `PAYKILLA_WEBHOOK_URL` | Точный публичный webhook URL для проверки подписи, если он отличается от `WEBHOOK_BASE_URL` + `/webhook/paykilla`. | +| `PAYKILLA_TRUSTED_IPS` | Необязательный список доверенных IP webhook-источников. | + ## Тарифы и legacy-цены Рекомендуемый способ настройки тарифов - раздел **Система -> Тарифы** в админке. Он сохраняет JSON в `TARIFFS_CONFIG_PATH`. @@ -386,10 +420,13 @@ PAYMENT_HELEKET_TELEGRAM_EMOJI | `TRIAL_DURATION_DAYS` | Длительность пробного периода. | | `TRIAL_TRAFFIC_LIMIT_GB` | Лимит трафика пробного периода. | | `TRIAL_TRAFFIC_STRATEGY` | Стратегия лимита пробного периода. | +| `TRIAL_WITHOUT_TELEGRAM_ENABLED` | Разрешает активацию trial пользователям без привязанного Telegram. Disposable email домены всё равно требуют Telegram. | | `TRIAL_SQUAD_UUIDS` | Internal Squads для trial через запятую. Если пусто, используется `USER_SQUAD_UUIDS`. | | `REFERRAL_ONE_BONUS_PER_REFEREE` | Ограничить бонусы одним успешным платежом приглашенного. | | `REFERRAL_WELCOME_BONUS_DAYS` | Приветственный бонус пришедшему по реферальной ссылке. | +| `REFERRAL_WELCOME_BONUS_WITHOUT_TELEGRAM_ENABLED` | Разрешает начислять реферальный приветственный бонус пользователям без привязанного Telegram. Disposable email домены всё равно требуют Telegram. | | `LEGACY_REFS` | Разрешить ссылки `ref_`. | +| `DISPOSABLE_EMAIL_DOMAINS` | Домены одноразовой почты через запятую. Для таких email trial и реферальный welcome bonus доступны только после привязки Telegram. | | `REFERRAL_BONUS_DAYS_1_MONTH`, `REFERRAL_BONUS_DAYS_3_MONTHS`, `REFERRAL_BONUS_DAYS_6_MONTHS`, `REFERRAL_BONUS_DAYS_12_MONTHS` | Legacy-бонусы пригласившему без JSON-каталога. В JSON-тарифах используйте `referral_bonus_days_inviter`. | | `REFEREE_BONUS_DAYS_1_MONTH`, `REFEREE_BONUS_DAYS_3_MONTHS`, `REFEREE_BONUS_DAYS_6_MONTHS`, `REFEREE_BONUS_DAYS_12_MONTHS` | Legacy-бонусы приглашенному без JSON-каталога. В JSON-тарифах используйте `referral_bonus_days_referee`. | | `SUBSCRIPTION_NOTIFICATIONS_ENABLED` | Включает напоминания о подписке. | diff --git a/docs/features/admin-panel.md b/docs/features/admin-panel.md index 1847075..1bc7f36 100644 --- a/docs/features/admin-panel.md +++ b/docs/features/admin-panel.md @@ -9,7 +9,7 @@ - блокировка пользователей, входящий список тикетов поддержки, рассылки, промокоды и просмотр логов; - ручная синхронизация с Remnawave; - редактор разрешенных настроек приложения из manifest-файла; -- раздел **Внешний вид** для логотипа, emoji-логотипа, выбора темы, accent-цвета, масштаба логотипа и предпросмотра тем; +- раздел **Внешний вид** для логотипа, выбора темы, accent-цвета, масштаба логотипа и предпросмотра тем; - раздел **Инструкции подключения** для встроенной страницы установки, поведения кнопок бота и Remnawave Subscription Page config; - раздел **Бэкапы** для просмотра локальных ZIP-архивов, загрузки архива и восстановления БД/compose-папки; - редактор JSON-каталога тарифов; @@ -49,7 +49,7 @@ В manifest сейчас входят: - общие параметры: язык, валюта, ссылки поддержки, документы, обязательный канал, Remnawave-доступы и поведение `/start`; -- внешний вид и доступность Web App: название, цвет, логотип, emoji-логотип и `WEBAPP_ENABLED`; +- внешний вид и доступность Web App: название, цвет, логотип и `WEBAPP_ENABLED`; - инструкции подключения: `SUBSCRIPTION_GUIDES_ENABLED`, `SUBSCRIPTION_GUIDES_BOT_MENU_ENABLED`, чтение конфига из Remnawave Panel, JSON-переопределение и резервный путь к файлу; - legacy-тарифы без JSON-каталога: периоды подписки, RUB/Stars цены, реферальные бонусы и пакеты трафика; - платежные провайдеры: включение методов, порядок кнопок, публичные параметры и секреты YooKassa, FreeKassa, Platega, SeverPay, Wata, CryptoPay, Heleket и Stars, а также текст и иконки кнопок оплаты; @@ -102,9 +102,11 @@ ## Внешний вид -Раздел **Внешний вид** объединяет настройки бренда и темы Web App. Логотип можно загрузить файлом или по HTTPS-ссылке; backend сохраняет файл в `data/webapp-logo/uploads` и подставляет локальный URL. Если включен emoji-логотип, картинка скрывается, а для emoji можно выбрать системный, Twemoji, Noto Color, animated Noto и другие варианты отрисовки. +Раздел **Внешний вид** объединяет настройки бренда и темы Web App. Логотип можно загрузить файлом или по HTTPS-ссылке; backend сохраняет файл в `data/webapp-logo/uploads` и подставляет локальный URL. Если логотип не задан, показывается логотип проекта по умолчанию. Favicon генерируется из логотипа или загружается отдельно. -В блоке тем админка читает каталог из `WEBAPP_THEMES_DIR`, показывает встроенные и кастомные темы, позволяет выбрать текущую тему, изменить accent, включить или выключить тему для админки и настроить масштаб логотипа на главной и экране входа. Кнопка предпросмотра открывает `/home?theme_preview=` и не меняет глобальную тему до сохранения. +Этот же бренд используется в HTML-письмах. Загруженный локальный логотип встраивается в письмо как inline image (`cid:webapp-logo`), поэтому email-клиенту не нужен прямой доступ к `/webapp-uploaded-logo/...`. Логотип по публичной HTTPS-ссылке остается внешней картинкой в письме. + +В блоке тем админка читает каталог из `WEBAPP_THEMES_DIR`, показывает встроенные и кастомные темы, позволяет выбрать текущую тему, изменить accent, включить или выключить тему для админки и настроить отдельный масштаб логотипа для desktop и mobile layout. Кнопка предпросмотра открывает `/home?theme_preview=` и не меняет глобальную тему до сохранения. Подробный формат `theme.json`, CSS/asset-роуты и пошаговый пайплайн создания новой темы описаны в [webapp-themes.md](webapp-themes.md). @@ -122,6 +124,8 @@ - **Premium**: названия premium-раздела RU/EN, premium Internal Squads, месячный premium-лимит и RUB/Stars пакеты premium-докупки; - **Устройства**: RUB/Stars пакеты докупки HWID-устройств. +Порядок периодов, traffic-пакетов, обычных докупок, premium-докупок и HWID-пакетов меняется перетаскиванием строк в редакторе. Этот порядок сохраняется в JSON и используется на витрине Web App и в Telegram-боте. + Базовые и premium Internal Squads выбираются из Remnawave через `/api/admin/panel/internal-squads`. Если панель недоступна, можно сохранить уже существующие UUID в JSON, но выпадающий список не загрузится. ## Практические замечания diff --git a/docs/features/email-login.md b/docs/features/email-login.md index c200518..0a30fa1 100644 --- a/docs/features/email-login.md +++ b/docs/features/email-login.md @@ -44,6 +44,12 @@ BRUTE_FORCE_WINDOW_SECONDS=900 BRUTE_FORCE_LOCK_SECONDS=900 ``` +## Брендинг писем + +HTML-письма используют тот же бренд, что и Mini App: название из `WEBAPP_TITLE`, accent из внешнего вида и логотип из раздела **Внешний вид**. Если логотип загружен через админку файлом, backend прикладывает его к письму как inline image (`cid:webapp-logo`), поэтому получателю не нужен доступ к внутреннему `/webapp-uploaded-logo/...`. + +Если в качестве логотипа задан публичный `https://` URL, письмо использует его как обычный внешний ``. В этом режиме некоторые почтовые клиенты могут скрыть картинку, пока получатель не разрешит загрузку внешних изображений. + Для Brevo обычно подходит порт `587` с STARTTLS. Если основной порт недоступен, приложение пробует порты из `SMTP_FALLBACK_PORTS`; порт `465` используется через SSL wrapper автоматически. `SMTP_FROM_EMAIL` должен быть подтвержден у SMTP-провайдера, иначе письмо часто отклоняется или попадает в спам. `SMTP_FROM_NAME` можно оставить пустым, тогда используется название Web App. diff --git a/docs/features/notifications.md b/docs/features/notifications.md index 1090eac..8450d4e 100644 --- a/docs/features/notifications.md +++ b/docs/features/notifications.md @@ -6,6 +6,8 @@ Minishop отправляет уведомления в Telegram и на email. Для уведомлений жизненного цикла подписки есть отдельный флаг `SUBSCRIPTION_EMAIL_NOTIFICATIONS_ENABLED`. Если он включен, пользовательские уведомления об окончании подписки отправляются в Telegram при наличии привязанного Telegram-аккаунта и на email при наличии привязанной почты. +Все HTML-письма используют общий email-шаблон с брендом из Web App: заголовком, accent-цветом и логотипом. Логотип, загруженный через раздел **Внешний вид**, отправляется как inline image, а публичный HTTPS-логотип остается внешней картинкой. + ## Сводная таблица | Событие | Получатель | Telegram | Email | Условия и ограничения | @@ -16,7 +18,7 @@ Minishop отправляет уведомления в Telegram и на email. | Успешная покупка отдельного пакета трафика | Пользователь | ✓ | ✓ | Для `traffic` / `traffic_package`; email отправляется, если SMTP настроен и у пользователя есть email. | | Успешная докупка обычного трафика к тарифу | Пользователь | ✓ | ✓ | Для `topup`; email отправляется, если SMTP настроен и у пользователя есть email. | | Успешная покупка premium-трафика | Пользователь | ✓ | ✓ | Для `premium_topup`; email отправляется, если SMTP настроен и у пользователя есть email. | -| Успешная покупка HWID-устройств | Пользователь | ✓ | ✓ | Отправляется после оплаты `hwid_devices` или `hwid_devices_renewal`; email отправляется, если SMTP настроен и у пользователя есть email. | +| Успешная покупка HWID-устройств | Пользователь | ✓ | ✓ | Отправляется после отдельной оплаты `hwid_devices`; при продлении устройств вместе с подпиской добавляется примечание к уведомлению об успешной оплате подписки. Email отправляется, если SMTP настроен и у пользователя есть email. | | Платное повышение тарифа | Пользователь | ✓ | ✓ | Для `tariff_upgrade`; email отправляется, если SMTP настроен и у пользователя есть email. | | Способ оплаты YooKassa привязан | Пользователь | ✓ | ✓ | Отправляется после успешного сохранения платежного метода через webhook YooKassa; email отправляется, если SMTP настроен и у пользователя есть email. | | Ошибка оплаты по webhook провайдера | Пользователь | ✓ | ✓ | Отправляется, когда платежный провайдер сообщает о неуспешном платеже; email отправляется, если SMTP настроен и у пользователя есть email. | diff --git a/docs/features/payments.md b/docs/features/payments.md index 91db907..662daac 100644 --- a/docs/features/payments.md +++ b/docs/features/payments.md @@ -22,6 +22,30 @@ - [Тарифы](tariffs.md) — цены, Telegram Stars и сценарии покупки. - [Логи](../troubleshooting/logs.md) — проверка webhook и создания платежных ссылок. +## Webhook URL провайдеров + +Все платежные webhook URL строятся от `WEBHOOK_BASE_URL` — публичного HTTPS-адреса backend/webhook-домена. + +Это должен быть домен, который проксируется на backend-сервер вебхуков (`backend:8080`), а не frontend/Mini App домен из `SUBSCRIPTION_MINI_APP_URL`. + +Если `WEBHOOK_BASE_URL=https://bot.example.com`, полный webhook URL получается как `https://bot.example.com` + путь из таблицы. + +| Провайдер | URL | +| --- | --- | +| YooKassa | `WEBHOOK_BASE_URL` + `/webhook/yookassa` | +| FreeKassa | `WEBHOOK_BASE_URL` + `/webhook/freekassa` | +| Platega | `WEBHOOK_BASE_URL` + `/webhook/platega` | +| SeverPay | `WEBHOOK_BASE_URL` + `/webhook/severpay` | +| Wata | `WEBHOOK_BASE_URL` + `/webhook/wata` | +| CryptoPay | `WEBHOOK_BASE_URL` + `/webhook/cryptopay` | +| Heleket | `WEBHOOK_BASE_URL` + `/webhook/heleket` | +| PayKilla | `WEBHOOK_BASE_URL` + `/webhook/paykilla` | +| Telegram Stars | `WEBHOOK_BASE_URL` + `/tg/webhook` | + +После настройки сделайте тестовый платеж и проверьте, что в логах `backend` виден входящий `POST` на нужный путь. + +Если провайдер сообщает, что адрес недоступен, проверьте DNS, HTTPS и reverse proxy для `WEBHOOK_BASE_URL`. Путь должен начинаться с `/webhook/...` без `/api`, `/auth` и frontend-домена. + ## YooKassa YooKassa используется для рублевых оплат. Провайдер также может участвовать в сценариях автопродления period-подписок. @@ -29,7 +53,7 @@ YooKassa используется для рублевых оплат. Прова ### Настройка 1. Включите `YOOKASSA_ENABLED`. -2. Заполните `YOOKASSA_SHOP_ID`, `YOOKASSA_SECRET_KEY`, `YOOKASSA_RETURN_URL`. +2. Заполните `YOOKASSA_SHOP_ID`, `YOOKASSA_SECRET_KEY` и `YOOKASSA_RETURN_URL`. 3. Скопируйте URL вебхука из админ-панели и укажите его в кабинете YooKassa. ### Справочник @@ -43,10 +67,10 @@ FreeKassa подключается как отдельный платежный ### Настройка 1. Включите `FREEKASSA_ENABLED`. -2. Заполните `FREEKASSA_MERCHANT_ID`, `FREEKASSA_FIRST_SECRET`, `FREEKASSA_SECOND_SECRET`, `FREEKASSA_API_KEY`. +2. Заполните `FREEKASSA_MERCHANT_ID`, `FREEKASSA_FIRST_SECRET`, `FREEKASSA_SECOND_SECRET` и `FREEKASSA_API_KEY`. 3. Проверьте настройки подписи. 4. Скопируйте URL вебхука из админ-панели и укажите его в кабинете FreeKassa. -5. При необходимости заполните список доверенных IP. +5. При необходимости заполните `FREEKASSA_TRUSTED_IPS`. ### Справочник @@ -59,11 +83,20 @@ Platega подключается как отдельный платежный п ### Настройка 1. Включите `PLATEGA_ENABLED`. -2. Укажите `PLATEGA_MERCHANT_ID` и `PLATEGA_SECRET`. +2. Укажите `PLATEGA_BASE_URL`, `PLATEGA_MERCHANT_ID` и `PLATEGA_SECRET`. 3. Скопируйте URL вебхука из админ-панели и укажите его в кабинете Platega. 4. Проверьте `PLATEGA_RETURN_URL` и `PLATEGA_FAILED_URL`. 5. При необходимости укажите `PLATEGA_PAYMENT_METHOD`. +### Дополнительные кнопки + +- `PLATEGA_SBP_ENABLED` — отдельная кнопка СБП/карта. +- `PLATEGA_SBP_METHOD` — ID метода для СБП/карты. +- `PLATEGA_CRYPTO_ENABLED` — отдельная crypto-кнопка Platega. +- `PLATEGA_CRYPTO_METHOD` — ID метода для crypto-кнопки. +- `PAYMENT_PLATEGA_SBP_*` — текст и иконка кнопки СБП/карта. +- `PAYMENT_PLATEGA_CRYPTO_*` — текст и иконка crypto-кнопки. + ### Справочник - [Platega](../configuration/env-vars.md#platega) @@ -75,9 +108,11 @@ SeverPay подключается как отдельный платежный ### Настройка 1. Включите `SEVERPAY_ENABLED`. -2. Укажите `SEVERPAY_MID`, `SEVERPAY_TOKEN`, `SEVERPAY_BASE_URL` -3. Скопируйте URL вебхука из админ-панели и укажите его в кабинете SeverPay. -4. При необходимости задайте `SEVERPAY_LIFETIME_MINUTES`. +2. Укажите `SEVERPAY_BASE_URL`. +3. Заполните `SEVERPAY_MID` и `SEVERPAY_TOKEN`. +4. Проверьте `SEVERPAY_RETURN_URL`. +5. Скопируйте URL вебхука из админ-панели и укажите его в кабинете SeverPay. +6. При необходимости задайте `SEVERPAY_LIFETIME_MINUTES`. ### Справочник @@ -116,8 +151,8 @@ CryptoPay используется для криптовалютных плат 2. Укажите `CRYPTOPAY_TOKEN`. 3. Выберите `CRYPTOPAY_NETWORK`: `mainnet` или `testnet`. 4. Задайте `CRYPTOPAY_CURRENCY_TYPE`: `fiat` или `crypto`. -5. Скопируйте URL вебхука из админ-панели и укажите его в CryptoPay. -6. Проверьте `CRYPTOPAY_ASSET`. +5. Проверьте `CRYPTOPAY_ASSET`, например `RUB`, `USDT` или `BTC`. +6. Скопируйте URL вебхука из админ-панели и укажите его в CryptoPay. ### Проверка @@ -138,10 +173,12 @@ Heleket используется для крипто-инвойсов с merchan 1. Включите `HELEKET_ENABLED`. 2. Укажите `HELEKET_BASE_URL`, `HELEKET_MERCHANT_ID` и `HELEKET_API_KEY`. 3. Настройте `HELEKET_CURRENCY`. -4. Скопируйте URL вебхука из админ-панели и укажите его в кабинете Heleket. -5. При необходимости задайте `HELEKET_TO_CURRENCY` и `HELEKET_NETWORK`. -7. При необходимости включите `HELEKET_VERIFY_WEBHOOK_SIGNATURE`. -8. Для IP-фильтрации заполните `HELEKET_TRUSTED_IPS`. +4. При необходимости задайте `HELEKET_TO_CURRENCY` и `HELEKET_NETWORK`. +5. Проверьте `HELEKET_RETURN_URL` и `HELEKET_SUCCESS_URL`. +6. Настройте `HELEKET_LIFETIME_SECONDS`. +7. Скопируйте URL вебхука из админ-панели и укажите его в кабинете Heleket. +8. При необходимости включите `HELEKET_VERIFY_WEBHOOK_SIGNATURE`. +9. Для IP-фильтрации заполните `HELEKET_TRUSTED_IPS`. ### Ограничения @@ -151,6 +188,64 @@ Heleket используется для крипто-инвойсов с merchan - [Heleket](../configuration/env-vars.md#heleket) +## PayKilla + +PayKilla используется для крипто-инвойсов V2 через hosted checkout `https://gopay.paykilla.com/{invoice_id}`. + +API-запросы подписываются HMAC-SHA256. Webhook проверяется по заголовку `X-API-SIGN` и raw body. + +### Особенности + +- PayKilla строго валидирует текстовые поля invoice. +- В `purpose` и `description` Minishop отправляет простой английский текст ` payment `. +- Локализованное описание платежа остается только внутри Minishop. +- ASCII-safe sanitizer допускает ASCII-буквы, цифры, пробелы, `_`, `.`, `,`. + +### Валюта invoice + +Minishop создает invoice в валюте, которую PayKilla принимает в поле `currency`. + +Если валюта тарифа входит в `PAYKILLA_INVOICE_CURRENCIES`, сумма отправляется как есть. + +Если валюта тарифа не входит в список, сумма конвертируется в `PAYKILLA_CURRENCY`. По умолчанию рублевые тарифы конвертируются в `USD` через ExchangeRate-API с кэшем `PAYKILLA_EXCHANGE_RATE_CACHE_SECONDS`. + +### Payload invoice + +Payload создания invoice содержит обязательные поля `type`, `purpose`, `currency`, `totalPrice` и `paymentCurrencies`. + +Дополнительно отправляются `clientOrderId`, `description`, `expiredAt`, `userPaysServiceFee` и `userPaysNetworkFee`. + +Redirect URLs в PayKilla не отправляются. Завершение платежа обрабатывается через webhook. + +### API key + +1. В PayKilla Dashboard откройте **Settings -> API keys**. +2. Создайте ключ типа **HMAC**. +3. Для приема оплат включите permission **INVOICE**. +4. Permission **WITHDRAWAL** не нужен для Minishop-платежей. +5. Сохраните `publicKey` в `PAYKILLA_API_KEY`. +6. Сохраните `secretKey` в `PAYKILLA_SECRET_KEY`. + +### Webhook + +1. В PayKilla Dashboard откройте **Settings -> Webhooks**. +2. Скопируйте URL вебхука из админ-панели и укажите его в PayKilla. +3. Включите минимальные события: `INVOICE_PAID`, `INVOICE_EXPIRED`. +4. Для production также включите `PAYMENT_COMPLETED`, `PAYMENT_FAILED`, `PAYMENT_OVERPAID`, `PAYMENT_UNDERPAID`, `PAYMENT_PARTIAL`, `COMPLIANCE_FAILED`. +5. Оставьте `PAYKILLA_VERIFY_WEBHOOK_SIGNATURE=True`. + +### Настройка + +1. Включите `PAYKILLA_ENABLED`. +2. Укажите `PAYKILLA_API_KEY` и `PAYKILLA_SECRET_KEY`. +3. Оставьте `PAYKILLA_CURRENCY=USD`, если PayKilla не принимает валюту тарифов как invoice currency. +4. В `PAYKILLA_INVOICE_CURRENCIES` укажите валюты invoice, например `USD,EUR`. +5. В `PAYKILLA_PAYMENT_CURRENCIES` начните с `USDTTRC`. + +### Справочник + +- [PayKilla](../configuration/env-vars.md#paykilla) + ## Telegram Stars Telegram Stars используются напрямую и поддерживаются в legacy-ценах и JSON-каталоге тарифов. @@ -171,9 +266,11 @@ Telegram Stars используются напрямую и поддержива ### Ограничения +- Отдельный платежный webhook не нужен. +- Stars-события приходят через webhook Telegram-бота: `WEBHOOK_BASE_URL` + `/tg/webhook`. - XTR/Stars-докупки не конвертируются без явно заданного курса. ### Справочник - [Переменные платежей](../configuration/env-vars.md#платежи) -- [Тарифы](tariffs.md) +- [Тарифы](tariffs.md) \ No newline at end of file diff --git a/docs/features/tariffs.md b/docs/features/tariffs.md index 6fcf5cb..8780c26 100644 --- a/docs/features/tariffs.md +++ b/docs/features/tariffs.md @@ -28,6 +28,8 @@ JSON-каталог может содержать несколько тариф - настройка premium-раздела: названия RU/EN, premium Internal Squads, месячный premium-лимит и пакеты докупки premium-трафика в платежной валюте/Stars; - настройка базового HWID-лимита и пакетов докупки устройств. +Порядок продаваемых вариантов управляется в админке перетаскиванием строк: это работает для периодов покупки подписки, traffic-пакетов, обычных докупок трафика, premium-докупок и HWID-пакетов. Такой же порядок сохраняется в JSON и затем используется в Web App и Telegram-боте. + После сохранения изменения применяются к новым запросам Web App сразу, потому что конфиг тарифов загружается из JSON при обращении. Уже созданные подписки сохраняют свой `tariff_key`; при удалении или отключении тарифа проверьте, что активные подписки с этим ключом не требуют дальнейшего продления или смены. Подробности по админ-панели, правам доступа, сохранению настроек и списку разделов есть в [админ-панели](admin-panel.md). @@ -56,16 +58,16 @@ Legacy-поля остаются алиасами: `prices_rub`, `conversion_rat Платежные провайдеры не принимают произвольный код валюты одинаково. Бот фильтрует способы оплаты и блокирует создание платежа, если текущая валюта каталога не поддерживается провайдером: -| Провайдер | Валюты по умолчанию | -| --- | --- | -| 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-цены | +| Провайдер | Валюты по умолчанию | +| -------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| 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 список валют нужно держать в соответствии с условиями вашего мерчанта. @@ -113,43 +115,43 @@ Legacy-поля остаются алиасами: `prices_rub`, `conversion_rat Основные поля: -| Поле | Назначение | -| --- | --- | -| `default_tariff` | Тариф по умолчанию для первичного выбора и привязки активных подписок без `tariff_key`. | -| `default_currency` | Валюта цен по умолчанию для JSON-каталога. По умолчанию `rub`; `stars` запрещен, потому что Stars используют отдельные цены. | -| `tariffs[].key` | Стабильный ключ тарифа. Используется в платежах, подписках и смене тарифа. | -| `tariffs[].names` | Названия тарифа по языкам. | -| `tariffs[].descriptions` | Описания тарифа по языкам. | -| `tariffs[].enabled` | Доступность тарифа на витрине. | -| `tariffs[].squad_uuids` | Internal Squads Remnawave для пользователей тарифа. | -| `tariffs[].premium_names` | Название premium-раздела по языкам. Используется в карточке лимита, модалке докупки premium-трафика и предупреждениях. Если поле не задано, используется `Premium-серверы` / `Premium servers`. | -| `tariffs[].premium_squad_uuids` | Internal Squads с отдельным premium-лимитом. Ноды для учета берутся автоматически из accessible nodes этих сквадов через API панели. | -| `tariffs[].premium_monthly_gb` | Отдельный месячный лимит трафика по premium-сквадам. `0` или отсутствие поля отключает отдельное ограничение. | -| `tariffs[].premium_topup_packages` | Пакеты докупки premium-трафика в формате `{ "rub": [{ "gb": 10, "price": 99 }], "stars": [...] }`. Требуют `premium_squad_uuids`. | -| `tariffs[].billing_model` | Модель тарифа: `period` или `traffic`. | -| `tariffs[].hwid_device_limit` | Базовый лимит HWID-устройств. `0` означает безлимит, отсутствие поля использует `USER_HWID_DEVICE_LIMIT`. | -| `tariffs[].hwid_device_packages` | Пакеты докупки устройств. `price` — legacy/monthly fallback, `prices` задаёт полную цену пакета для периодов тарифа (`"1"`, `"3"`, `"6"`, `"12"`), `min_price` задаёт минимальную цену prorate-докупки. | +| Поле | Назначение | +| ---------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `default_tariff` | Тариф по умолчанию для первичного выбора и привязки активных подписок без `tariff_key`. | +| `default_currency` | Валюта цен по умолчанию для JSON-каталога. По умолчанию `rub`; `stars` запрещен, потому что Stars используют отдельные цены. | +| `tariffs[].key` | Стабильный ключ тарифа. Используется в платежах, подписках и смене тарифа. | +| `tariffs[].names` | Названия тарифа по языкам. | +| `tariffs[].descriptions` | Описания тарифа по языкам. | +| `tariffs[].enabled` | Доступность тарифа на витрине. | +| `tariffs[].squad_uuids` | Internal Squads Remnawave для пользователей тарифа. | +| `tariffs[].premium_names` | Название premium-раздела по языкам. Используется в карточке лимита, модалке докупки premium-трафика и предупреждениях. Если поле не задано, используется `Premium-серверы` / `Premium servers`. | +| `tariffs[].premium_squad_uuids` | Internal Squads с отдельным premium-лимитом. Ноды для учета берутся автоматически из accessible nodes этих сквадов через API панели. | +| `tariffs[].premium_monthly_gb` | Отдельный месячный лимит трафика по premium-сквадам. `0` или отсутствие поля отключает отдельное ограничение. | +| `tariffs[].premium_topup_packages` | Пакеты докупки premium-трафика в формате `{ "rub": [{ "gb": 10, "price": 99 }], "stars": [...] }`. Требуют `premium_squad_uuids`. Порядок строк задает порядок premium-докупок на витрине и меняется drag&drop в админке. | +| `tariffs[].billing_model` | Модель тарифа: `period` или `traffic`. | +| `tariffs[].hwid_device_limit` | Базовый лимит HWID-устройств. `0` означает безлимит, отсутствие поля использует `USER_HWID_DEVICE_LIMIT`. | +| `tariffs[].hwid_device_packages` | Пакеты докупки устройств. `price` — legacy/monthly fallback, `prices` задаёт полную цену пакета для периодов тарифа (`"1"`, `"3"`, `"6"`, `"12"`), `min_price` задаёт минимальную цену prorate-докупки. Порядок строк задает порядок HWID-докупок и меняется drag&drop в админке. | Для `period`-тарифа также используются: -| Поле | Назначение | -| --- | --- | -| `monthly_gb` | Базовый месячный лимит трафика тарифа. `0` означает безлимит. | -| `prices` | Generic-цены периодов по валютам, например `{ "usd": { "1": 4.99 } }`. | -| `prices_rub` | Legacy-цены периодов в рублях, ключ - количество месяцев. Эквивалент `prices.rub`. | -| `prices_stars` | Цены периодов в Telegram Stars. | -| `referral_bonus_days_inviter` | Бонус пригласившему в днях для каждого периода. Ключ - количество месяцев, как в `enabled_periods`. | -| `referral_bonus_days_referee` | Бонус приглашенному в днях для каждого периода. Ключ - количество месяцев, как в `enabled_periods`. | -| `enabled_periods` | Периоды, доступные для покупки. | -| `topup_packages` | Пакеты докупки трафика именно для этого тарифа. Если поле не задано или списки пустые, докупка для тарифа не показывается в Web App и Telegram-боте. | +| Поле | Назначение | +| ----------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `monthly_gb` | Базовый месячный лимит трафика тарифа. `0` означает безлимит. | +| `prices` | Generic-цены периодов по валютам, например `{ "usd": { "1": 4.99 } }`. | +| `prices_rub` | Legacy-цены периодов в рублях, ключ - количество месяцев. Эквивалент `prices.rub`. | +| `prices_stars` | Цены периодов в Telegram Stars. | +| `referral_bonus_days_inviter` | Бонус пригласившему в днях для каждого периода. Ключ - количество месяцев, как в `enabled_periods`. | +| `referral_bonus_days_referee` | Бонус приглашенному в днях для каждого периода. Ключ - количество месяцев, как в `enabled_periods`. | +| `enabled_periods` | Периоды, доступные для покупки. Порядок элементов в массиве задаёт порядок периодов на витрине (в Telegram-боте и Web App) — отсортируйте их так, как нужно показывать. В веб-админке этот порядок меняется перетаскиванием строк периодов. | +| `topup_packages` | Пакеты докупки трафика именно для этого тарифа. Если поле не задано или списки пустые, докупка для тарифа не показывается в Web App и Telegram-боте. Порядок строк задает порядок докупок на витрине и меняется drag&drop в админке. | Для `traffic`-тарифа используются: -| Поле | Назначение | -| --- | --- | -| `traffic_packages` | Пакеты трафика в GB по валютам каталога и Telegram Stars. | -| `conversion_rate_per_gb` | Курс для конвертации оставшихся дней period-тарифа в GB при смене на traffic-тариф в валюте каталога. | -| `conversion_rate_rub_per_gb` | Legacy-алиас для рублевых каталогов. | +| Поле | Назначение | +| ---------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `traffic_packages` | Пакеты трафика в GB по валютам каталога и Telegram Stars. Порядок пакетов в списке задаёт порядок на витрине (в Telegram-боте и Web App): сначала идут пакеты валюты каталога, затем пакеты, доступные только за Stars. В веб-админке порядок меняется перетаскиванием строк. | +| `conversion_rate_per_gb` | Курс для конвертации оставшихся дней period-тарифа в GB при смене на traffic-тариф в валюте каталога. | +| `conversion_rate_rub_per_gb` | Legacy-алиас для рублевых каталогов. | Если у traffic-тарифа нет пакетов в `default_currency`, `conversion_rate_per_gb` обязателен. @@ -278,7 +280,10 @@ limit_after = current_used + balance_after - полная цена HWID-пакета берется из `prices[duration_months]`; если периода нет, используется fallback `price * duration_months`; - фактическая цена докупки считается пропорционально оплачиваемому окну `valid_from -> valid_until` относительно периода подписки и фиксируется в платежe; - для Telegram Stars цена округляется вверх до целого Stars, для платежной валюты — вверх до копеек; `min_price` защищает от микроплатежей в конце периода; -- при продлении подписки докупленные устройства не продлеваются автоматически: старая докупка действует до прежнего `end_date`, а для нового срока создается отдельная `hwid_devices_renewal`-покупка; +- кнопка докупки устройств всегда покупает устройства только для текущей активной подписки и только до текущего срока ее действия; +- при продлении подписки пользователь видит отдельный чекбокс продления действующих докупленных устройств; чекбокс включен по умолчанию, цена считается по текущему тарифу и добавляется в тот же платеж подписки; +- если пользователь продлил подписку без продления устройств, старая докупка продолжает действовать до своего `valid_until`, а Web App показывает предупреждение о возможном временном возврате к базовому лимиту; +- админские продления, промокоды и реферальные бонусы добавляют фиксированное количество дней отдельно к подписке и к действующим докупкам устройств, не склеивая даты окончания; - `traffic`-тарифы не показывают и не принимают докупку HWID-устройств, потому что у них нет срока подписки; - при смене тарифа базовый лимит берется из целевого тарифа, а неиспользованная стоимость HWID-докупок в платежной валюте конвертируется в дни нового period-тарифа или GB traffic-тарифа; XTR/Stars-докупки не конвертируются без явного курса и продолжают жить по своему `valid_until`; - история докупок пишется в `hwid_device_purchases`; @@ -292,12 +297,12 @@ limit_after = current_used + balance_after Варианты расчета: -| Переход | Поведение | -| --- | --- | -| `period -> period` | Остаток оплаченных дней оценивается по legacy-полю `effective_monthly_price_rub`, где хранится месячная цена в платежной валюте каталога, затем пересчитывается в дни целевого тарифа через месячную цену целевого тарифа. Неиспользованная стоимость HWID-докупок в платежной валюте добавляется к этому расчету как дополнительные дни. Количество дней округляется вниз. | -| `period -> period` с доплатой | Если целевой тариф дороже, может быть создан платеж `tariff_upgrade`; неиспользованная стоимость HWID-докупок в платежной валюте уменьшает сумму доплаты. После оплаты применяется целевой тариф, а конвертированные HWID-окна закрываются. | -| `period -> traffic` | Остаток оплаченных дней и неиспользованная стоимость HWID-докупок в платежной валюте конвертируются в GB по `conversion_rate_per_gb` или минимальной цене GB из пакетов целевого тарифа. | -| `traffic -> period` | Пользователь выбирает и оплачивает период целевого тарифа; остаток GB сохраняется как `topup_balance_bytes` поверх лимита period-тарифа. | +| Переход | Поведение | +| ----------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `period -> period` | Остаток оплаченных дней оценивается по legacy-полю `effective_monthly_price_rub`, где хранится месячная цена в платежной валюте каталога, затем пересчитывается в дни целевого тарифа через месячную цену целевого тарифа. Неиспользованная стоимость HWID-докупок в платежной валюте добавляется к этому расчету как дополнительные дни. Количество дней округляется вниз. | +| `period -> period` с доплатой | Если целевой тариф дороже, может быть создан платеж `tariff_upgrade`; неиспользованная стоимость HWID-докупок в платежной валюте уменьшает сумму доплаты. После оплаты применяется целевой тариф, а конвертированные HWID-окна закрываются. | +| `period -> traffic` | Остаток оплаченных дней и неиспользованная стоимость HWID-докупок в платежной валюте конвертируются в GB по `conversion_rate_per_gb` или минимальной цене GB из пакетов целевого тарифа. | +| `traffic -> period` | Пользователь выбирает и оплачивает период целевого тарифа; остаток GB сохраняется как `topup_balance_bytes` поверх лимита period-тарифа. | При смене тарифа бот меняет: @@ -313,15 +318,15 @@ limit_after = current_used + balance_after В платежах используются поля: -| Поле | Назначение | -| --- | --- | -| `sale_mode` | Тип продажи: `subscription`, `traffic_package`, `topup`, `premium_topup`, `tariff_upgrade`, `hwid_devices`. | -| `tariff_key` | Ключ тарифа, к которому относится платеж. | -| `purchased_gb` | Купленный объем GB для traffic-пакетов и докупки трафика. | -| `purchased_hwid_devices` | Количество устройств при докупке HWID. | -| `hwid_valid_from`, `hwid_valid_until` | Зафиксированное окно действия HWID-докупки на момент создания платежа. | -| `hwid_pricing_period_months`, `hwid_proration_ratio`, `hwid_full_price` | Метаданные расчета цены HWID-докупки: период тарифа, коэффициент prorate и полная цена пакета для периода. | -| `subscription_duration_months` | Количество месяцев для подписки на срок; также используется платежными обработчиками как числовое поле покупки. | +| Поле | Назначение | +| ----------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------- | +| `sale_mode` | Тип продажи: `subscription`, `traffic_package`, `topup`, `premium_topup`, `tariff_upgrade`, `hwid_devices`. | +| `tariff_key` | Ключ тарифа, к которому относится платеж. | +| `purchased_gb` | Купленный объем GB для traffic-пакетов и докупки трафика. | +| `purchased_hwid_devices` | Количество устройств при докупке HWID. | +| `hwid_valid_from`, `hwid_valid_until` | Зафиксированное окно действия HWID-докупки на момент создания платежа. | +| `hwid_pricing_period_months`, `hwid_proration_ratio`, `hwid_full_price` | Метаданные расчета цены HWID-докупки: период тарифа, коэффициент prorate и полная цена пакета для периода. | +| `subscription_duration_months` | Количество месяцев для подписки на срок; также используется платежными обработчиками как числовое поле покупки. | В callback и metadata платежных провайдеров `sale_mode` может передаваться с суффиксом тарифа, например `subscription@standard` или `topup@standard`. При активации платежа тариф сохраняется отдельно в `tariff_key`. @@ -345,12 +350,14 @@ Remnawave ограничивает доступ при достижении `tra Автопродление через YooKassa применяется к подпискам на срок. Для режима продажи трафика без JSON-каталога автопродление пропускается. Для traffic-тарифов JSON-каталога покупка является пакетом трафика, а не периодической подпиской. -Пробный период использует настройки `TRIAL_DURATION_DAYS`, `TRIAL_TRAFFIC_LIMIT_GB`, `TRIAL_TRAFFIC_STRATEGY` и `TRIAL_SQUAD_UUIDS`. Он не выбирает тариф из JSON-каталога, но его можно настроить на странице **Система → Тарифы** рядом с каталогом продаж. Если `TRIAL_SQUAD_UUIDS` пустой, для trial применяются squads из `USER_SQUAD_UUIDS`. +Пробный период использует настройки `TRIAL_DURATION_DAYS`, `TRIAL_TRAFFIC_LIMIT_GB`, `TRIAL_TRAFFIC_STRATEGY` и `TRIAL_SQUAD_UUIDS`. Он не выбирает тариф из JSON-каталога, но его можно настроить на странице **Система → Тарифы** рядом с каталогом продаж. Если `TRIAL_SQUAD_UUIDS` пустой, для trial применяются squads из `USER_SQUAD_UUIDS`. Переключатель `TRIAL_WITHOUT_TELEGRAM_ENABLED` управляет активацией trial для аккаунтов без Telegram, а домены из `DISPOSABLE_EMAIL_DOMAINS` требуют привязки Telegram независимо от этого переключателя. Промокоды с бонусными днями применяются к покупке period-подписки. Реферальные бонусы за оплату в JSON-каталоге задаются прямо в period-тарифе рядом с ценами периода: `referral_bonus_days_inviter` для пригласившего и `referral_bonus_days_referee` для приглашенного. Ключи этих словарей - месяцы периода (`"1"`, `"3"`, `"6"`, `"12"` или любые другие периоды тарифа, например `"2"`, `"4"`, `"8"`, `"16"`). Для `traffic`-тарифов такие бонусы не применяются. +Приветственный бонус приглашённому (`REFERRAL_WELCOME_BONUS_DAYS`) настраивается в отдельном блоке **Реферальная программа** на странице тарифов. `REFERRAL_WELCOME_BONUS_WITHOUT_TELEGRAM_ENABLED` разрешает или запрещает выдачу этого бонуса аккаунтам без Telegram; disposable email домены из `DISPOSABLE_EMAIL_DOMAINS` всегда требуют Telegram перед начислением. + Если приглашенный покупает один тариф, а пригласивший находится на другом, размер бонуса берется из тарифа и периода, который купил приглашенный. При этом подписка пригласившего только продлевается на бонусные дни: лимиты, Internal Squads и другие параметры его текущего тарифа не пересчитываются под тариф приглашенного. В Web App и Telegram-меню подробные строки по периодам показываются только для legacy-режима или когда активен один period-тариф. Если включено несколько period-тарифов, Web App показывает сообщение, что бонус зависит от тарифа и периода оплаты друга, затем список тарифов с диапазонами "от N до N дней" и раскрытием подробностей по иконке вопроса. Telegram-меню в этом случае показывает только диапазоны по каждому тарифу. diff --git a/docs/features/web-app.md b/docs/features/web-app.md index a92408e..bdfb761 100644 --- a/docs/features/web-app.md +++ b/docs/features/web-app.md @@ -67,7 +67,7 @@ SUPPORT_TICKET_RATE_LIMIT_PER_HOUR=5 Если `WEBAPP_ENABLED=False`, пользовательское веб-приложение и админ-панель не регистрируются. Чтобы снова попасть в админку, включите `WEBAPP_ENABLED=True` в `.env` и перезапустите backend/frontend контейнеры. -Внешний вид настраивается в админке: раздел **Внешний вид** управляет логотипом, emoji-логотипом, accent-цветом, выбранной темой и масштабом логотипа. Кастомные темы читаются из `WEBAPP_THEMES_DIR`, а `WEBAPP_DEFAULT_THEME` может принудительно выбрать тему по ключу. Подробный контракт `theme.json`, CSS/asset-роуты и пайплайн создания темы описаны в [webapp-themes.md](webapp-themes.md). +Внешний вид настраивается в админке: раздел **Внешний вид** управляет логотипом, favicon, accent-цветом, выбранной темой и отдельным масштабом логотипа для desktop/mobile layout. Кастомные темы читаются из `WEBAPP_THEMES_DIR`, а `WEBAPP_DEFAULT_THEME` может принудительно выбрать тему по ключу. Подробный контракт `theme.json`, CSS/asset-роуты и пайплайн создания темы описаны в [webapp-themes.md](webapp-themes.md). ## Авторизация diff --git a/docs/features/webapp-themes.md b/docs/features/webapp-themes.md index 4ee81c9..7357d65 100644 --- a/docs/features/webapp-themes.md +++ b/docs/features/webapp-themes.md @@ -13,7 +13,6 @@ Web App поддерживает файловые темы, предпросмо - включить или выключить применение темы в админ-панели; - настроить масштаб логотипа на главной и экране входа; - загрузить логотип файлом или по HTTPS-ссылке; -- включить emoji-логотип и выбрать способ его отрисовки; - открыть предпросмотр темы через `/home?theme_preview=`. Через файлы темы можно менять намного больше: @@ -51,7 +50,9 @@ WEBAPP_DEFAULT_THEME= В compose-примерах `data/themes` - это локальная папка рядом с выбранным `docker-compose.yml`; она монтируется в контейнер как `/app/data/themes`. Правки в `backend/bot/app/web/themes` попадают в прод только при сборке собственного образа; опубликованный образ их не видит. -Важно: `WEBAPP_PRIMARY_COLOR`, `WEBAPP_LOGO_URL`, `WEBAPP_LOGO_USE_EMOJI`, `WEBAPP_LOGO_EMOJI` и `WEBAPP_LOGO_EMOJI_FONT` больше не являются рабочим способом первичной настройки через `.env`. Эти значения редактируются в админке и сохраняются как overrides в базе. Тема при этом может использовать сохраненный primary color как fallback accent. +Важно: `WEBAPP_PRIMARY_COLOR` и `WEBAPP_LOGO_URL` больше не являются рабочим способом первичной настройки через `.env`. Эти значения редактируются в админке и сохраняются как overrides в базе. Тема при этом может использовать сохраненный primary color как fallback accent. + +Email-шаблоны берут тот же бренд из настроек внешнего вида. Загруженный логотип добавляется в письма как inline image (`cid:webapp-logo`), а публичный HTTPS-логотип остается внешней картинкой, которую почтовый клиент может скрыть до разрешения загрузки изображений. ## Контракт `theme.json` @@ -114,7 +115,8 @@ WEBAPP_DEFAULT_THEME= "font_sans": "Inter, system-ui, sans-serif", "font_logo": "Inter, system-ui, sans-serif", "font_mono": "\"JetBrains Mono\", \"Fira Code\", monospace", - "home_logo_scale": 120, + "home_logo_scale_desktop": 120, + "home_logo_scale_mobile": 95, "admin_bg": "#05040a", "admin_surface": "#11101c", "admin_surface_2": "#090815", @@ -130,52 +132,54 @@ WEBAPP_DEFAULT_THEME= Поля верхнего уровня: -| Поле | Назначение | -| --- | --- | -| `key` | Уникальный ключ темы, 1-64 символа: латиница, цифры, `_` и `-`. Если ключ не указан, берется имя папки. | -| `names` | Локализованные названия, например `ru` и `en`. | -| `enabled` | Показывать тему пользователям. Отключенная тема не попадает в публичный каталог. | -| `default` | Делает тему выбранной по умолчанию, если `WEBAPP_DEFAULT_THEME` не задан. | +| Поле | Назначение | +| -------------------- | ------------------------------------------------------------------------------------------------------- | +| `key` | Уникальный ключ темы, 1-64 символа: латиница, цифры, `_` и `-`. Если ключ не указан, берется имя папки. | +| `names` | Локализованные названия, например `ru` и `en`. | +| `enabled` | Показывать тему пользователям. Отключенная тема не попадает в публичный каталог. | +| `default` | Делает тему выбранной по умолчанию, если `WEBAPP_DEFAULT_THEME` не задан. | | `use_primary_accent` | Если `true`, тема может получить accent из настройки внешнего вида, когда в `tokens.accent` ничего нет. | -| `use_in_admin` | Если `false`, пользовательская часть использует тему, но админка откатывается на `dark`. | -| `css_file` | CSS-файл внутри папки темы. Может быть `style.css` или вложенный путь вроде `css/theme.css`. | -| `assets_version` | Версия ассетов. Для встроенных тем используется для обновления старых файлов в `data/themes`. | -| `tokens` | Дизайн-токены, которые превращаются в CSS-переменные на `.app-shell`. | +| `use_in_admin` | Если `false`, пользовательская часть использует тему, но админка откатывается на `dark`. | +| `css_file` | CSS-файл внутри папки темы. Может быть `style.css` или вложенный путь вроде `css/theme.css`. | +| `assets_version` | Версия ассетов. Для встроенных тем используется для обновления старых файлов в `data/themes`. | +| `tokens` | Дизайн-токены, которые превращаются в CSS-переменные на `.app-shell`. | ## Токены Поддерживаемые токены: -| Токен | CSS-переменная | Что меняет | -| --- | --- | --- | -| `color_scheme` | `color-scheme` | Нативная светлая/темная схема браузера: `dark` или `light`. | -| `style_preset` | CSS-класс пресета | Сейчас `win95`/`windows95` добавляет `theme-preset-win95`; остальные значения не дают специального класса. | -| `accent` | `--accent` | Главный акцент: активные элементы, кнопки, прогресс, фокус. Только hex `#RGB` или `#RRGGBB`. | -| `bg` | `--bg` | Основной фон приложения. | -| `panel` | `--panel` | Основные карточки и поверхности. | -| `panel_2` | `--panel-2` | Вторичные поверхности. | -| `panel_3` | `--panel-3` | Поверхности повышенной вложенности, dropdown/popover. | -| `border` | `--border` | Обычные границы. | -| `border_strong` | `--border-strong` | Усиленные границы и hover-состояния. | -| `text` | `--text` | Основной текст. | -| `muted` | `--muted` | Вторичный текст. | -| `dim` | `--dim` | Еще более тихий текст и служебные подписи. | -| `danger` | `--danger` | Ошибки и опасные действия. | -| `blue` | `--blue` | Синий вспомогательный цвет. | -| `radius` | `--radius` | Базовый радиус карточек, кнопок и контролов. | -| `font_sans` | `--font-sans` | Основной шрифт интерфейса. | -| `font_logo` | `--font-logo` | Шрифт бренда и заголовка. | -| `font_mono` | `--font-mono` | Моноширинный шрифт. | -| `home_logo_scale` | `--home-logo-scale` | Масштаб логотипа на главной и входе, от `50` до `300` процентов. | -| `admin_bg` | `--admin-bg` | Фон админ-панели. | -| `admin_surface` | `--admin-surface` | Основные карточки админки. | -| `admin_surface_2` | `--admin-surface-2` | Вторичные поверхности админки. | -| `admin_elev` | `--admin-elev` | Elevated-поверхности админки. | -| `admin_border` | `--admin-border` | Границы админки. | -| `admin_border_strong` | `--admin-border-strong` | Усиленные границы админки. | -| `admin_text` | `--admin-text` | Основной текст админки. | -| `admin_muted` | `--admin-muted` | Вторичный текст админки. | -| `admin_dim` | `--admin-dim` | Тихие подписи админки. | +| Токен | CSS-переменная | Что меняет | +| ------------------------- | --------------------------- | ---------------------------------------------------------------------------------------------------------- | +| `color_scheme` | `color-scheme` | Нативная светлая/темная схема браузера: `dark` или `light`. | +| `style_preset` | CSS-класс пресета | Сейчас `win95`/`windows95` добавляет `theme-preset-win95`; остальные значения не дают специального класса. | +| `accent` | `--accent` | Главный акцент: активные элементы, кнопки, прогресс, фокус. Только hex `#RGB` или `#RRGGBB`. | +| `bg` | `--bg` | Основной фон приложения. | +| `panel` | `--panel` | Основные карточки и поверхности. | +| `panel_2` | `--panel-2` | Вторичные поверхности. | +| `panel_3` | `--panel-3` | Поверхности повышенной вложенности, dropdown/popover. | +| `border` | `--border` | Обычные границы. | +| `border_strong` | `--border-strong` | Усиленные границы и hover-состояния. | +| `text` | `--text` | Основной текст. | +| `muted` | `--muted` | Вторичный текст. | +| `dim` | `--dim` | Еще более тихий текст и служебные подписи. | +| `danger` | `--danger` | Ошибки и опасные действия. | +| `blue` | `--blue` | Синий вспомогательный цвет. | +| `radius` | `--radius` | Базовый радиус карточек, кнопок и контролов. | +| `font_sans` | `--font-sans` | Основной шрифт интерфейса. | +| `font_logo` | `--font-logo` | Шрифт бренда и заголовка. | +| `font_mono` | `--font-mono` | Моноширинный шрифт. | +| `home_logo_scale_desktop` | `--home-logo-scale-desktop` | Масштаб логотипа на desktop layout, от `50` до `300` процентов. | +| `home_logo_scale_mobile` | `--home-logo-scale-mobile` | Масштаб логотипа на mobile layout, от `50` до `300` процентов. | +| `home_logo_scale` | `--home-logo-scale` | Legacy fallback для старых тем; используется, если desktop/mobile token не задан. | +| `admin_bg` | `--admin-bg` | Фон админ-панели. | +| `admin_surface` | `--admin-surface` | Основные карточки админки. | +| `admin_surface_2` | `--admin-surface-2` | Вторичные поверхности админки. | +| `admin_elev` | `--admin-elev` | Elevated-поверхности админки. | +| `admin_border` | `--admin-border` | Границы админки. | +| `admin_border_strong` | `--admin-border-strong` | Усиленные границы админки. | +| `admin_text` | `--admin-text` | Основной текст админки. | +| `admin_muted` | `--admin-muted` | Вторичный текст админки. | +| `admin_dim` | `--admin-dim` | Тихие подписи админки. | Если `css_file` не задан, интерфейс полностью строится на токенах и общих стилях. Если `css_file` задан, токены все равно применяются первыми, а CSS темы может уточнить или полностью переопределить внешний вид. @@ -254,7 +258,8 @@ CSS можно писать для пользовательской части content: ""; width: 16px; height: 16px; - background: url("/webapp-theme-assets/neon/icons/spark.png") center / contain no-repeat; + background: url("/webapp-theme-assets/neon/icons/spark.png") center / contain + no-repeat; } ``` @@ -302,7 +307,7 @@ CSS можно писать для пользовательской части 7. Подберите accent и масштаб логотипа. - В админке можно менять accent и `home_logo_scale` без ручного редактирования JSON. При сохранении backend перепишет `theme.json` в `WEBAPP_THEMES_DIR`, выставит ровно один `default` и сбросит кеш публичных настроек. + В админке можно менять accent, `home_logo_scale_desktop` и `home_logo_scale_mobile` без ручного редактирования JSON. При сохранении backend перепишет `theme.json` в `WEBAPP_THEMES_DIR`, выставит ровно один `default` и сбросит кеш публичных настроек. Старый `home_logo_scale` сохраняется как fallback для уже существующих тем. 8. Добавьте `style.css`, если токенов мало. @@ -329,7 +334,6 @@ CSS можно писать для пользовательской части 11. Сделайте тему дефолтной. Есть два способа: - - в админке выбрать тему и сохранить; - указать `WEBAPP_DEFAULT_THEME=neon` в `.env`, если нужен жесткий override на уровне окружения. diff --git a/docs/getting-started/configuration.md b/docs/getting-started/configuration.md index 7941128..93230c7 100644 --- a/docs/getting-started/configuration.md +++ b/docs/getting-started/configuration.md @@ -87,7 +87,7 @@ openssl rand -hex 32 Перед первым запуском создайте каталоги и отдайте их пользователю контейнера: ```bash -mkdir -p data/themes data/webapp-logo data/webapp-emoji data/tariffs +mkdir -p data/themes data/webapp-logo data/tariffs touch data/locales-overrides.json chown -R 10001:10001 data chmod -R u+rwX data diff --git a/docs/getting-started/demo.md b/docs/getting-started/demo.md index c3e6caa..1da0a6c 100644 --- a/docs/getting-started/demo.md +++ b/docs/getting-started/demo.md @@ -11,6 +11,8 @@ - [Админка: пользователи](/demo/admin/users) - [Админка: бэкапы](/demo/admin/backups) - [Пробный период](/demo/home?mock=trial) +- [Email-only: нужен Telegram для триала](/demo/home?mock=trial-telegram) +- [Email-only: нужен Telegram для реферального бонуса](/demo/home?mock=referral-telegram) - [Докупка устройств](/demo/devices?mock=devices) - [Запуск бота для Telegram-уведомлений](/demo/home?mock=notifications) - [Вход и регистрация](/demo/login?mock=auth) diff --git a/docs/getting-started/deployment.md b/docs/getting-started/deployment.md index cee2da1..dadfc08 100644 --- a/docs/getting-started/deployment.md +++ b/docs/getting-started/deployment.md @@ -13,6 +13,44 @@ docker compose ps docker compose logs -f backend worker frontend ``` +## Интерактивный install wizard + +Для нового сервера скачайте install-скрипт и запустите его: + +```bash +curl -fsSL https://raw.githubusercontent.com/3252a8/remnawave-minishop/main/scripts/install.sh -o install.sh +sh install.sh +``` + +Wizard работает через меню с цифрами и подтверждениями `y/n`. Он умеет: + +- скачать выбранный compose-профиль (`Caddy`, `Nginx`, `Pangolin/Newt` или `no-proxy`); +- сгенерировать минимальный `.env`, включая пароли и стабильные secrets; +- сохранить backup существующих файлов перед перезаписью; +- подготовить writable `data/` для файлов приложения; +- запустить `docker compose pull && docker compose up -d`; +- проверить текущий стек через `docker compose ps` и логи `migrate`; +- запустить миграцию из поддерживаемых ботов: Remnashop и старый + `remnawave-tg-shop`; + +Для тестирования другой ветки или форка задайте источник перед запуском: + +```bash +MINISHOP_INSTALL_REPO=3252a8/remnawave-minishop \ +MINISHOP_INSTALL_REF=main \ +sh install.sh +``` + +Миграция Remnashop в wizard сначала запускает `dry-run`, показывает JSON-сводку +и только после отдельного подтверждения применяет изменения в целевую БД. Если +указать старый Remnashop `.env`, wizard передаст importer-у `APP_CRYPT_KEY`, +Remnawave API settings и поддерживаемые payment provider settings из таблицы +`payment_gateways`. После применения wizard печатает новые webhook URL для +Remnawave Panel и платежных провайдеров. +Миграция со старого `remnawave-tg-shop` работает как upgrade совместимой БД: +либо копирует старый Docker volume, либо делает `pg_dump` по source DSN, +восстанавливает дамп в целевую compose-БД и запускает сервис `migrate`. + Обычный `docker compose up -d --build` поднимает: - `postgres` и `redis` с проверками здоровья; @@ -339,7 +377,7 @@ distributed lock; код подготовлен к нескольким репл Перед первым запуском на сервере заранее дайте права пользователю контейнера `10001`: ```bash -mkdir -p data/themes data/webapp-logo data/webapp-emoji data/tariffs +mkdir -p data/themes data/webapp-logo data/tariffs touch data/locales-overrides.json chown -R 10001:10001 data chmod -R u+rwX data diff --git a/docs/migrations/index.md b/docs/migrations/index.md index 501a37a..42d2a74 100644 --- a/docs/migrations/index.md +++ b/docs/migrations/index.md @@ -5,3 +5,4 @@ | Источник | Поддерживаемый случай | Документы | | --- | --- | --- | | [remnawave-tg-shop](https://github.com/kavore/remnawave-tg-shop/) | Полный перенос всех данных | [Инструкция](remnawave-tg-shop.md) | +| [Remnashop](https://github.com/snoups/remnashop/) | Автоматический импорт пользователей, подписок, платежей, рефералов, промокодов и поддерживаемых платежных настроек | [Инструкция](remnashop.md) | diff --git a/docs/migrations/remnashop.md b/docs/migrations/remnashop.md new file mode 100644 index 0000000..00d6056 --- /dev/null +++ b/docs/migrations/remnashop.md @@ -0,0 +1,124 @@ +# Миграция из Remnashop + +Remnashop импортируется через общий скрипт импорта `backend/scripts/import_legacy.py`. +Самый удобный путь - интерактивный install wizard: + +```bash +curl -fsSL https://raw.githubusercontent.com/3252a8/remnawave-minishop/main/scripts/install.sh -o install.sh +sh install.sh +``` + +В меню выберите `Install new stack and run migration` для нового сервера +или `Run migration only`, если compose-папка и `.env` уже готовы. + +## Что переносится + +- пользователи Telegram, username, email, Remnawave UUID и метаданные профиля; +- старые referral codes и связи рефералов; +- подписки, сроки, лимиты трафика, HWID/device limit и UUID подписок панели; +- платежи и статусы платежей; +- промокоды на дни подписки и их активации, если таблицы есть в source DB; +- служебные mappings, чтобы повторный запуск мог работать в режиме `merge`; +- настройки совместимости Remnashop в админке: старые ref-ссылки и promo codes. + +Данные, которые не имеют прямого аналога, сохраняются в служебных таблицах миграции или +message logs как заметки, чтобы администратор мог проверить их после переноса. + +## Настройки и платежные провайдеры + +Если указать старый Remnashop `.env`, importer дополнительно переносит часть +настроек в админские overrides: + +- `REMNAWAVE_HOST` -> `PANEL_API_URL`; +- `REMNAWAVE_TOKEN` -> `PANEL_API_KEY`; +- `REMNAWAVE_WEBHOOK_SECRET` -> `PANEL_WEBHOOK_SECRET`; +- `BOT_SUPPORT_USERNAME` -> `SUPPORT_LINK`; +- `APP_DEFAULT_LOCALE` -> `DEFAULT_LANGUAGE`. + +`BOT_MINI_APP` из Remnashop не переносится автоматически. В Remnashop эта +переменная управляет кнопкой подключения к subscription page или внешнему Mini +App, а не веб-кабинетом Remnashop. В Minishop `SUBSCRIPTION_MINI_APP_URL` +должен указывать на текущий frontend/Mini App этого стека; wizard настраивает +его из `WEBHOOK_HOST`/`MINIAPP_HOST` или `MINIAPP_PUBLIC_URL`. + +Значения-заглушки вроде `change_me` importer пропускает, чтобы случайно не +записать шаблонные секреты в рабочую конфигурацию. + +Платежные провайдеры берутся из таблицы Remnashop `payment_gateways`. +Поддерживаются и автоматически маппятся: Telegram Stars, YooKassa, WATA, +CryptoPay, Heleket, PayKilla, FreeKassa и Platega. Для них importer переносит флаги +включения, API-ключи/merchant IDs и прямые технические параметры, без которых +провайдер не сможет работать: YooKassa receipt email/VAT, FreeKassa second +secret/payment method/server IP и Platega payment method. + +Provider currency и supported-currency ограничения не переносятся автоматически: +в Minishop валюта платежа управляется тарифами и `DEFAULT_CURRENCY_SYMBOL`. +Если старый gateway Remnashop был настроен на нестандартную валюту, importer +оставит предупреждение в JSON-сводке; проверьте `CRYPTOPAY_ASSET`, +`HELEKET_CURRENCY`, `HELEKET_SUPPORTED_CURRENCIES`, `PAYKILLA_CURRENCY`, +`PAYKILLA_PAYMENT_CURRENCIES` или +`PLATEGA_SUPPORTED_CURRENCIES` вручную. + +Провайдеры YooMoney, Cryptomus, MulenPay, PayMaster, RoboKassa и UrlPay сейчас +не имеют прямого аналога в Minishop. Если они были в Remnashop, importer +оставит предупреждение в JSON-сводке и notes миграции, а настроить их нужно +вручную или через будущий отдельный provider. + +Remnashop может хранить секреты в формате `enc_...`. Для расшифровки нужен +старый `APP_CRYPT_KEY`; проще всего указать путь к старому `.env` в wizard или +передать `--source-env-file`. Если ключ не передан или неверный, зашифрованные +значения будут пропущены с предупреждением, остальные данные продолжат +импортироваться. + +После успешного применения wizard печатает список новых адресов webhook. Их +нужно указать во внешних сервисах вместо старых Remnashop URL: + +- Remnawave Panel -> `WEBHOOK_URL`: `WEBHOOK_BASE_URL` + `/webhook/panel`; +- YooKassa HTTP notifications URL: `WEBHOOK_BASE_URL` + `/webhook/yookassa`; +- WATA webhook/callback URL: `WEBHOOK_BASE_URL` + `/webhook/wata`; +- CryptoBot/Crypto Pay webhook URL: `WEBHOOK_BASE_URL` + `/webhook/cryptopay`; +- Heleket payment webhook/callback URL: `WEBHOOK_BASE_URL` + `/webhook/heleket`; +- PayKilla webhook URL: `WEBHOOK_BASE_URL` + `/webhook/paykilla`; +- FreeKassa notification/result URL: `WEBHOOK_BASE_URL` + `/webhook/freekassa`; +- Platega webhook URL: `WEBHOOK_BASE_URL` + `/webhook/platega`; +- Telegram webhook `WEBHOOK_BASE_URL` + `/tg/webhook` выставляется ботом + автоматически при старте. + +## Flow wizard + +1. Wizard скачивает compose-профиль и `backend/scripts/import_legacy.py` через + `raw.githubusercontent.com`, без клонирования репозитория. +2. Вы указываете source PostgreSQL DSN Remnashop и schema, обычно `public`. +3. Опционально указываете путь к старому Remnashop `.env` для `APP_CRYPT_KEY`, + Remnawave API settings и переносимых settings. +4. Вы выбираете целевую БД: текущую compose-БД или ручной target DSN. +5. При необходимости указываете JSON map тарифов Remnashop в локальные + `tariff_key`, например `{"basic": "standard_month"}`. +6. Wizard запускает `dry-run` и показывает JSON-сводку. +7. После подтверждения `y` importer применяет изменения, печатает список новых + webhook URL для Remnawave Panel и платежных провайдеров, затем перезапускает + `backend`/`worker`, чтобы настройки совместимости перечитались. + +Если source DB находится на том же Docker host, помните, что DSN выполняется +из backend-контейнера. Для подключения к сервису вне compose-сети может +понадобиться host name вроде `host.docker.internal`, внешний адрес сервера или +ручное подключение контейнеров к общей Docker network. + +## Ручной запуск + +Если нужно запустить importer без wizard: + +```bash +docker compose run --rm backend \ + python backend/scripts/import_legacy.py \ + --source-type remnashop \ + --source-dsn 'postgresql://old_user:old_password@old_host:5432/remnashop' \ + --source-schema public \ + --source-env-file /path/to/remnashop/.env \ + --dry-run +``` + +После успешного `dry-run` повторите команду без `--dry-run`. По умолчанию +режим конфликтов `merge`: существующие пользователи и платежи сопоставляются, +а новые записи добавляются. Для узкого импорта используйте `--only`, например +`--only users,referrals,promocodes`. diff --git a/docs/migrations/remnawave-tg-shop.md b/docs/migrations/remnawave-tg-shop.md index b7e16b0..640336f 100644 --- a/docs/migrations/remnawave-tg-shop.md +++ b/docs/migrations/remnawave-tg-shop.md @@ -1,90 +1,60 @@ -# Миграция с `remnawave-tg-shop` (≤ v2.7.0) на `remnawave-minishop` (v3.4+) +# Миграция с `remnawave-tg-shop` на `remnawave-minishop` -Эта страница - готовый сценарий для legacy-стека `remnawave-tg-shop`. Это единственная миграция с другого бота, которая сейчас описана в документации. Для других Telegram-ботов, самописных панелей и ручных таблиц готового сценария пока нет: их нельзя переносить по этой инструкции без отдельного анализа схемы БД, тарифов, платежей и связи с Remnawave Panel. - -Автоматический скрипт ниже рассчитан именно на родственный стек `remnawave-tg-shop`, где структура БД и Docker volumes известны заранее. Для других ботов нужен отдельный адаптер экспорта/импорта. - -## Короткий путь без смены ветки и сборки - -Если вы используете только готовые Docker-образы и не собираете проект -локально, git-команды из ручного способа не нужны. Достаточно обновить -compose-файл до одного из готовых примеров в `deploy/examples` и -перенести/обновить БД. Самый прямой вариант без встроенного обратного прокси - -`deploy/examples/no-proxy/docker-compose.yml`; для Caddy, Nginx и Newt есть -такие же самостоятельные папки. - -Минимальная последовательность: +Для переноса со старого родственного стека используйте общий install wizard: ```bash -docker compose down - -# Скопируйте старый .env в выбранную папку примера и обновите значения там. -cp .env deploy/examples/no-proxy/.env -nano deploy/examples/no-proxy/.env - -# Подготовьте стек из готовых образов. -IMAGE_TAG=3.4.0 docker compose \ - --env-file deploy/examples/no-proxy/.env \ - -f deploy/examples/no-proxy/docker-compose.yml \ - up --no-start - -# Нужно только при переходе со старого имени volume remnawave-tg-shop-db-data. -# Если у вас уже есть remnawave-minishop-db-data, этот шаг пропустите. -docker run --rm \ - -v remnawave-tg-shop-db-data:/from:ro \ - -v remnawave-minishop-db-data:/to \ - alpine sh -c "cd /from && cp -a . /to" - -IMAGE_TAG=3.4.0 docker compose \ - --env-file deploy/examples/no-proxy/.env \ - -f deploy/examples/no-proxy/docker-compose.yml \ - up -d -docker compose \ - --env-file deploy/examples/no-proxy/.env \ - -f deploy/examples/no-proxy/docker-compose.yml \ - logs migrate +curl -fsSL https://raw.githubusercontent.com/3252a8/remnawave-minishop/main/scripts/install.sh -o install.sh +sh install.sh ``` -Сервис `migrate` сам применит недостающие схемные миграции к перенесённому -тому PostgreSQL. Новые тома `remnawave-minishop-redis-data` и -`remnawave-minishop-shop-data` переносить не нужно: они создаются пустыми. +В меню выберите `Install new stack and run migration` для нового +сервера или `Run migration only`, если compose-папка уже готова. Затем +выберите источник `Old remnawave-tg-shop`. -Этот документ описывает обновление стека, поднятого по `remnawave-tg-shop` -(включая последний релиз `v2.7.0` форка `kavore/remnawave-tg-shop`), до -текущей версии `remnawave-minishop` (v3.4+). Между этими версиями произошли -две независимые перетряски, и скрипт пытается отработать обе одной командой: +Wizard поддерживает два способа переноса: -1. **Переименование стека** (v3.1.0): контейнеры и тома `remnawave-tg-shop-*` - стали `remnawave-minishop-*`. Простой `docker compose up -d` после - `git pull` создаёт пустую БД — без переноса тома данные теряются. -2. **Разделение бота на сервисы** (v3.4.0): из одного контейнера выделены - `backend`, `worker`, `frontend`, `migrate` + новые `postgres`, `redis`. - Появились новые volumes `redis-data` и `shop-data`, новые обязательные - переменные окружения, а схема БД обновляется автоматически one-shot - сервисом `migrate`. +- `Copy old Docker volumes` - для старого compose-стека на том же Docker host. + Скрипт подготавливает новый stack, копирует + `remnawave-tg-shop-db-data` в `remnawave-minishop-db-data`, опционально + переносит Caddy volumes и запускает новый stack. +- `Dump from a source PostgreSQL DSN` - для старой БД, доступной по DSN. + Скрипт поднимает целевой `postgres`, сбрасывает целевую БД, делает + `pg_dump` из старой БД, восстанавливает дамп в compose-БД и запускает + сервис `migrate`. -После миграции `docker compose ps` должен показать как минимум: `backend`, -`worker`, `frontend`, `postgres`, `redis` (running) и `migrate` (exited 0). -Логи: `docker compose logs -f backend worker frontend`. +В обоих режимах старые volumes и старая БД не удаляются автоматически. -Доступные пути: +## Как работает перенос -- [Автоматический](#автоматический-способ-через-скрипт) — скрипт-обёртка - останавливает старый стек, накатывает свежий код, переносит том БД, - поднимает новые сервисы. Идемпотентный. -- [Ручной](#ручной-способ) — те же шаги командами, для тех, кому нужно - понимать каждое действие или выполнить выборочно. +`remnawave-tg-shop` и `remnawave-minishop` имеют совместимую историю схемы. +После переноса старой PostgreSQL-БД сервис `migrate` накатывает недостающие +миграции из `backend/db/migrator.py`: сначала применяются `Base.metadata`, +затем последовательные записи `schema_migrations`. Это one-shot сервис: он +должен завершиться с кодом `0`, после чего стартуют `backend` и `worker`. -В обоих случаях: +При volume-миграции wizard: -- старые тома **не удаляются** автоматически — это безопасный бэкап на случай - отката; -- сертификаты Caddy (если используется `deploy/examples/caddy/docker-compose.yml`) - тоже переносятся, чтобы Let's Encrypt не выписывал их заново и не упереться - в rate limit; -- схема БД обновляется автоматически: при первом `docker compose up -d` сервис - `migrate` накатывает на перенесённый том все недостающие миграции (от - alembic-схемы v2.7.0 до текущей). +1. Останавливает известные контейнеры старого и переходного стеков, если вы + подтверждаете этот шаг. +2. Запускает `docker compose up --no-start`, чтобы Docker Compose создал новые + volumes. +3. Копирует старый volume БД: + + ```bash + docker run --rm \ + -v remnawave-tg-shop-db-data:/from:ro \ + -v remnawave-minishop-db-data:/to \ + alpine sh -c "cd /from && cp -a . /to" + ``` + +4. Если старые Caddy volumes существуют, переносит + `remnawave-tg-shop-caddy-data` -> `remnawave-minishop-caddy-data` и + `remnawave-tg-shop-caddy-config` -> `remnawave-minishop-caddy-config`. +5. Запускает новый stack через Docker Compose. + +Если целевой DB volume уже непустой, wizard не перетирает его молча: он +останавливается и просит отдельное подтверждение на продолжение без копирования +старой БД. ## Что меняется в архитектуре @@ -93,256 +63,55 @@ docker compose \ | Версия | Сервисы | | --- | --- | | `v2.7.0` | `remnawave-tg-shop`, `remnawave-tg-shop-db` | -| `v3.1.x–v3.3.x` | `remnawave-minishop`, `remnawave-minishop-db` | -| `v3.4+` (текущая) | `remnawave-minishop-backend`, `remnawave-minishop-worker`, `remnawave-minishop-frontend`, `remnawave-minishop-migrate`, `remnawave-minishop-postgres`, `remnawave-minishop-redis` | - -Внутри Docker-сети сервисы доступны по коротким DNS-именам (`backend`, `worker`, -`frontend`, `postgres`, `redis`), а не по полному `container_name`. Это важно -для внешнего reverse-proxy — см. раздел [Внешний reverse-proxy](#внешний-reverse-proxy) ниже. +| `v3.1.x-v3.3.x` | `remnawave-minishop`, `remnawave-minishop-db` | +| `v3.4+` | `remnawave-minishop-backend`, `remnawave-minishop-worker`, `remnawave-minishop-frontend`, `remnawave-minishop-migrate`, `remnawave-minishop-postgres`, `remnawave-minishop-redis` | **Volumes**: -| Volume | v2.7.0 | v3.4+ | Что внутри | -| --- | --- | --- | --- | -| `remnawave-minishop-db-data` | переименовать из `remnawave-tg-shop-db-data` | переносится скриптом | PostgreSQL | -| `remnawave-minishop-redis-data` | — | создаётся пустым | Redis (FSM, rate-limit, cache, очередь вебхуков, distributed locks) | -| `remnawave-minishop-shop-data` | — | создаётся пустым | `/app/data`: `tariffs.json`, темы Web App, кэш логотипа/emoji | -| `remnawave-minishop-caddy-data` / `remnawave-minishop-caddy-config` | переименовать из `remnawave-tg-shop-caddy-*` | переносится скриптом | только при Caddy-варианте | +| Volume | Что происходит | +| --- | --- | +| `remnawave-minishop-db-data` | переносится из `remnawave-tg-shop-db-data` или восстанавливается из source DSN | +| `remnawave-minishop-redis-data` | создается пустым | +| `remnawave-minishop-shop-data` | создается пустым; runtime-файлы в `/app/data` дальше настраиваются через админку или вручную | +| `remnawave-minishop-caddy-data` / `remnawave-minishop-caddy-config` | переносятся из `remnawave-tg-shop-caddy-*`, если старый стек использовал Caddy | -`redis-data` и `shop-data` стартуют пустыми — это нормально. Redis ничего -долгоживущего не хранит (всё либо FSM, либо кеш с TTL), а `data/` инициализируется -из образа при первом старте (`tariffs.json` пуст пока вы не сконфигурируете -тарифы через админ-панель). +Доступные compose-профили: `docker-compose.yml`, +`deploy/examples/caddy/docker-compose.yml`, +`deploy/examples/nginx/docker-compose.yml`, +`deploy/examples/newt/docker-compose.yml`, +`deploy/examples/no-proxy/docker-compose.yml`. -## Переменные окружения, которые могли исчезнуть или переехать +## Переменные окружения -Перед запуском нового стека проверьте `.env`. Ниже — только то, что точно -менялось между v2.7.0 и v3.4+: +Перед запуском нового стека проверьте `.env`. Самые важные изменения: -| Было (v2.7.0) | Стало (v3.4+) | Действие | +| Было | Стало | Действие | | --- | --- | --- | -| `TELEGRAM_WEBHOOK_SECRET` | `WEBHOOK_SECRET_TOKEN` | Переименовать. Если пусто — будет сгенерирован при старте, но тогда Telegram переустановит webhook (на это не реагирует существующий запрос). | -| `TELEGRAM_WEBHOOK_PATH` | удалена | Путь вебхука теперь генерируется из `BOT_TOKEN` автоматически. | -| `REQUIRED_CHANNEL_SUBSCRIBE_TO_USE` | удалена | Гейт включается автоматически, как только задан `REQUIRED_CHANNEL_ID`. | -| `STARS_PROVIDER_TOKEN` | удалена | Telegram Stars (XTR) используются напрямую. | -| `REFERRAL_ENABLED` | удалена | Реферальная программа активна по умолчанию. В legacy-режиме без JSON-каталога отключайте платежные бонусы через нули в `REFERRAL_BONUS_DAYS_*` и `REFEREE_BONUS_DAYS_*`; в JSON-тарифах обнуляйте или удаляйте `referral_bonus_days_inviter` и `referral_bonus_days_referee` у period-тарифов. | -| `POSTGRES_HOST=remnawave-tg-shop-db` | в `.env` — `remnawave-minishop-db` или пусто | Под compose значение всё равно переопределяется на сервисное имя `postgres` (см. `environment:` в compose-файлах), поэтому скрипт правит `.env` только для bare-metal сценариев. | -| `WEBHOOK_BASE_URL` | **обязательна** | Polling-режим удалён, без публичного URL бот не стартует. | -| — | `REDIS_URL=redis://redis:6379/0` | Обязательна для воркера, очередей и rate-limit. По умолчанию в compose-файлах уже задана. | -| — | `WEBAPP_SESSION_SECRET`, `WEBAPP_ENABLED`, `WEBAPP_SERVER_PORT`, `WEBAPP_THEMES_DIR`, `TARIFFS_CONFIG_PATH` | Новые настройки Web App / тарифного каталога. Безопасные дефолты есть в `.env.example`. | +| `TELEGRAM_WEBHOOK_SECRET` | `WEBHOOK_SECRET_TOKEN` | Перенести значение или сгенерировать новый stable secret. | +| `TELEGRAM_WEBHOOK_PATH` | удалена | Путь вебхука теперь рассчитывается автоматически. | +| `REQUIRED_CHANNEL_SUBSCRIBE_TO_USE` | удалена | Гейт включается, когда задан `REQUIRED_CHANNEL_ID`. | +| `STARS_PROVIDER_TOKEN` | удалена | Telegram Stars используются напрямую. | +| `POSTGRES_HOST=remnawave-tg-shop-db` | `postgres` внутри Compose | В compose-файлах `POSTGRES_HOST` переопределяется service name `postgres`. | +| `WEBHOOK_BASE_URL` | обязательна | Без публичного URL backend не стартует корректно. | +| - | `REDIS_URL=redis://redis:6379/0` | В compose-профилях задано автоматически. | +| - | `WEBAPP_SESSION_SECRET`, `WEBAPP_ENABLED`, `TARIFFS_CONFIG_PATH` | Новые настройки Web App и каталога тарифов. | -Полный референс — [docs/getting-started/configuration.md](../getting-started/configuration.md). Скрипт миграции -эти переменные **не правит** автоматически (только `POSTGRES_HOST`), потому -что у каждой инсталляции свой шаблон `.env` с кастомными значениями. Лучше -сравнить свой `.env` с `.env.example` глазами один раз, чем получить -несовместимый шаблон автоматом. +Остальные продуктовые настройки удобнее проверить после первого входа в +админку. -## Автоматический способ (через скрипт) +## Reverse Proxy -Если helper ещё не лежит у вас локально, запускайте его прямо из `raw` из -корня старого репозитория: +В старом стеке часто был один upstream `remnawave-tg-shop:8000`. В текущем +split-arch stack маршруты разделены: -```bash -bash <(curl -fsSL https://raw.githubusercontent.com/3252a8/remnawave-minishop/main/scripts/migrate_to_minishop.sh) -``` - -> Команда выше рассчитана на `bash` / Git Bash / WSL. Если вы запускаете из -> PowerShell, удобнее сначала открыть Git Bash. - -Если вы уже подтянули новую версию и файл есть локально, можно запускать так: - -```bash -bash scripts/migrate_to_minishop.sh -``` - -По умолчанию скрипт работает с `docker-compose.yml` и переключается на ветку -`main`. Можно переопределить через переменные окружения: - -| Переменная | Назначение | По умолчанию | -| ----------------- | ----------------------------------------------------------------------- | ---------------------- | -| `PROJECT_ROOT` | Явный путь к корню старого репозитория, если запуск не из него | текущая директория | -| `COMPOSE_FILE` | Какой compose-файл стартовать в конце | `docker-compose.yml` | -| `TARGET_BRANCH` | На какую ветку переключаться и подтягивать обновления | `main` | -| `GIT_REMOTE` | Какой remote использовать для `fetch`/`pull` | `origin` | -| `NEW_ORIGIN_URL` | Если задано и не совпадает с URL выбранного remote — он будет обновлён | (не меняется) | -| `ASSUME_YES` | `1` — не задавать интерактивных вопросов | `0` | - -Примеры: - -```bash -# Caddy-вариант из raw-файла. -# Перед запуском скопируйте старый .env в deploy/examples/caddy/.env -# и заполните WEBHOOK_HOST / MINIAPP_HOST. -COMPOSE_FILE=deploy/examples/caddy/docker-compose.yml \ - bash <(curl -fsSL https://raw.githubusercontent.com/3252a8/remnawave-minishop/main/scripts/migrate_to_minishop.sh) - -# С переключением origin на форк 3252a8 -NEW_ORIGIN_URL=https://github.com/3252a8/remnawave-minishop.git \ - bash <(curl -fsSL https://raw.githubusercontent.com/3252a8/remnawave-minishop/main/scripts/migrate_to_minishop.sh) - -# Без интерактива -ASSUME_YES=1 \ - bash <(curl -fsSL https://raw.githubusercontent.com/3252a8/remnawave-minishop/main/scripts/migrate_to_minishop.sh) -``` - -Что делает скрипт: - -1. **Останавливает текущий стек**: ищет известные контейнеры старой схемы - (`remnawave-tg-shop`, `…-db`, `…-caddy`), переходного периода - (`remnawave-minishop`, `…-db`, `…-caddy`) и новой схемы - (`…-backend`, `…-worker`, `…-frontend`, `…-migrate`, `…-postgres`, `…-redis`) - и останавливает их, если запущены. Безопасно при повторном запуске. -2. **Переключает `origin`**, если задана переменная `NEW_ORIGIN_URL`, иначе - оставляет как есть. -3. **Подтягивает целевую ветку** (`git fetch` + `git switch` + `git pull --ff-only`). - Прерывается, если в рабочем дереве есть незакоммиченные изменения. -4. **Правит `POSTGRES_HOST` в `.env`** (только для bare-metal сценариев — в - compose это значение перебивает `environment:` блок). -5. **Подготавливает новый стек в режиме `--no-start`**, чтобы Compose сам - создал тома `db-data`, `redis-data`, `shop-data` и не ругался на уже - существующий volume. -6. **Переносит том БД** `remnawave-tg-shop-db-data` → `remnawave-minishop-db-data` - (и Caddy-тома, если применимо) через одноразовый `alpine`-контейнер. Если - новый том уже непустой — копирование пропускается. Новые volumes - `redis-data` и `shop-data` остаются пустыми (их и не должно быть в старом - стеке). -7. **Стартует новый стек** (`docker compose up -d --remove-orphans` плюс - `--build` для локальной сборки). `migrate` отработает первым, накатит - на перенесённый том все недостающие миграции (от alembic-схемы v2.7.0 до - текущей) и завершится. Затем стартуют `backend`, `worker`, `frontend`. - -Скрипт идемпотентен: повторный запуск ничего не сломает, просто пропустит уже -выполненные шаги. - -После того как убедитесь, что бот работает и данные на месте, удалите старые -тома: - -```bash -docker volume rm remnawave-tg-shop-db-data -docker volume rm remnawave-tg-shop-caddy-data remnawave-tg-shop-caddy-config 2>/dev/null || true -``` - -## Ручной способ - -1. **Остановите старый стек и обновите код:** - - ```bash - docker compose down - git fetch origin - git checkout main - git pull --ff-only origin main - ``` - -2. **(Только для bare-metal без compose)** обновите `.env`, если в нём ещё - жёстко прописан старый контейнер БД: - - ```bash - sed -i.bak 's/^POSTGRES_HOST=remnawave-tg-shop-db$/POSTGRES_HOST=remnawave-minishop-db/' .env - ``` - - Под `docker compose up` это не нужно: compose сам выставляет - `POSTGRES_HOST: postgres` (имя сервиса) в `environment:` и `.env`-значение - не используется. - -3. **Проверьте `.env`** на наличие переменных, которые исчезли или - переименовались — см. раздел - [Переменные окружения](#переменные-окружения-которые-могли-исчезнуть-или-переехать) - выше. Главное: `WEBHOOK_SECRET_TOKEN` (бывший `TELEGRAM_WEBHOOK_SECRET`), - обязательный `WEBHOOK_BASE_URL` и наличие `REDIS_URL` (по умолчанию задано - в compose). - -4. **Подготовьте новый стек без запуска**, чтобы Compose создал новые volumes - (`db-data`, `redis-data`, `shop-data`) и контейнеры: - - ```bash - # Локальная сборка - docker compose up --no-start --build - - # Или готовый Caddy-вариант из GHCR-образов - cp .env deploy/examples/caddy/.env - nano deploy/examples/caddy/.env - docker compose \ - --env-file deploy/examples/caddy/.env \ - -f deploy/examples/caddy/docker-compose.yml \ - up --no-start - - # Другие готовые варианты: - # deploy/examples/nginx/docker-compose.yml - # deploy/examples/newt/docker-compose.yml - # deploy/examples/no-proxy/docker-compose.yml - ``` - -5. **Перенесите том БД в новое имя:** - - ```bash - docker run --rm \ - -v remnawave-tg-shop-db-data:/from:ro \ - -v remnawave-minishop-db-data:/to \ - alpine sh -c "cd /from && cp -a . /to" - ``` - - `remnawave-minishop-redis-data` и `remnawave-minishop-shop-data` — новые, - переносить нечего. Они инициализируются на лету: Redis пуст, а `data/` - наполняется при первом обращении к настройкам Web App / каталогу тарифов. - -6. **(Только для Caddy)** перенесите тома Caddy с TLS-сертификатами и - состоянием ACME: - - ```bash - for v in caddy-data caddy-config; do - docker run --rm \ - -v "remnawave-tg-shop-$v":/from:ro \ - -v "remnawave-minishop-$v":/to \ - alpine sh -c "cd /from && cp -a . /to" - done - ``` - -7. **Запустите новый стек:** - - ```bash - docker compose up -d - # или - docker compose \ - --env-file deploy/examples/caddy/.env \ - -f deploy/examples/caddy/docker-compose.yml \ - up -d - ``` - - Сервис `migrate` запустится первым, обнаружит перенесённый том, - применит недостающие схемные миграции (`Base.metadata.create_all` + - последовательные миграции `0001..00NN` из `backend/db/migrator.py`) и - выйдет с кодом 0. Только после этого стартуют `backend` и `worker`. - -8. **Проверьте состояние:** - - ```bash - docker compose ps - docker compose logs -f backend worker frontend - docker compose logs migrate # должен закончиться "Migrator: migration 00NN applied successfully" - ``` - -9. **(Опционально) удалите старые тома**, когда убедитесь, что новый стек - стабилен: - - ```bash - docker volume rm remnawave-tg-shop-db-data - docker volume rm remnawave-tg-shop-caddy-data remnawave-tg-shop-caddy-config 2>/dev/null || true - ``` - -## Внешний reverse-proxy - -В v2.7.0 был один upstream — `remnawave-tg-shop:8000`. В v3.4+ функциональность -разнесена по портам и сервисам: - -| Назначение | DNS-имя сервиса | Порт | +| Назначение | Service | Port | | --- | --- | --- | -| Telegram / платежные / вебхуки панели | `backend` | `8080` | -| Health-чек | `backend` | `8080` (`/healthz`) | -| Web App API (`/api/*`, `/auth/*`, ассеты тем и логотипов) | `backend` | `8081` (доступен только из Docker-сети) | -| Статический фронт Web App | `frontend` | `80` (внутри `frontend` уже проксирует `/api/*` и `/auth/*` на `backend:8081`) | +| Telegram, платежные и panel webhooks | `backend` | `8080` | +| Health-check | `backend` | `8080` (`/healthz`) | +| Web App API и auth | `backend` | `8081` внутри Docker-сети | +| Статический Web App frontend | `frontend` | `80` | -Минимальная замена для внешнего Nginx, который раньше слал всё на один -upstream: +Минимальная схема для внешнего Nginx: ```nginx upstream remnawave_backend_webhooks { server backend:8080; } @@ -351,33 +120,32 @@ upstream remnawave_frontend { server frontend:80; } server { server_name app.domain.com; listen 443 ssl; - http2 on; - # ssl_certificate / ssl_certificate_key — без изменений location /webhook/ { proxy_pass http://remnawave_backend_webhooks; } location /healthz { proxy_pass http://remnawave_backend_webhooks; } - location / { proxy_pass http://remnawave_frontend; } + location / { proxy_pass http://remnawave_frontend; } } ``` -Полные примеры (Caddy, Nginx, Newt/Pangolin и запуск без обратного прокси) — в -[docs/getting-started/deployment.md](../getting-started/deployment.md) и [docs/features/web-app.md](../features/web-app.md). Если раньше прокси указывал на -`remnawave-tg-shop:8000` напрямую, после миграции нужно либо переключиться на -`backend:8080` / `frontend:80`, либо использовать готовый Caddy/Nginx/Newt -пример, который уже знает правильную маршрутизацию. +Готовые Caddy, Nginx, Pangolin/Newt и no-proxy профили уже содержат нужную +маршрутизацию. -## Если что-то пошло не так +## Проверка -`migrate` упал → читайте `docker compose logs migrate`. Том БД остался -не тронут, можно откатиться, переключив compose-файл обратно на старый -коммит и подняв старый стек на старом томе `remnawave-tg-shop-db-data` -(пока вы его не удалили). +После переноса: -`backend` не стартует → чаще всего `WEBHOOK_BASE_URL` пуст, либо -`WEBHOOK_SECRET_TOKEN` отличается от того, что Telegram ждёт. Поставьте -свежий секрет в `.env` и перезапустите — Telegram переустановит webhook -автоматически. +```bash +docker compose ps +docker compose logs migrate +docker compose logs -f backend worker frontend +``` -Web App пуст / 502 → проверьте, что `frontend` живёт (`docker compose ps`), -а внешний прокси шлёт на `frontend:80`, а не на старый -`remnawave-tg-shop:8000`. +`migrate` должен завершиться успешно, а `backend`, `worker`, `frontend`, +`postgres` и `redis` должны быть running/healthy. + +Когда убедитесь, что новый stack работает, старые volumes можно удалить вручную: + +```bash +docker volume rm remnawave-tg-shop-db-data +docker volume rm remnawave-tg-shop-caddy-data remnawave-tg-shop-caddy-config 2>/dev/null || true +``` diff --git a/frontend/src/App.svelte b/frontend/src/App.svelte index 6c0eb53..4670111 100644 --- a/frontend/src/App.svelte +++ b/frontend/src/App.svelte @@ -74,11 +74,15 @@ const ACTIVATION_PENDING_WATCH_MAX_ATTEMPTS = 45; const ACTIVATION_RESUME_CHECK_COOLDOWN_MS = 1500; const TELEGRAM_NOTIFICATIONS_RESUME_REFRESH_COOLDOWN_MS = 1500; + const TELEGRAM_LINK_PENDING_ACTION_STORAGE_KEY = "rw_webapp_telegram_link_pending_action_v1"; + const TELEGRAM_LINK_PENDING_TTL_MS = 10 * 60 * 1000; + const TELEGRAM_LINK_ACTION_TRIAL = "trial"; + const TELEGRAM_LINK_ACTION_REFERRAL_WELCOME = "referral_welcome"; import { activationPaymentFailed, createActivationHandoff, } from "./lib/webapp/activationHandoff.js"; - import { buildGravatarUrl } from "./lib/webapp/gravatar.js"; + import { buildGravatarUrl, resolveProfileAvatarUrl } from "./lib/webapp/gravatar.js"; import { createBillingActions } from "./lib/webapp/billingActions.js"; import { invalidateWebappTariffOptionCaches } from "./lib/webapp/billingOptionCache.js"; import { runWebappBoot } from "./lib/webapp/webappBoot.js"; @@ -171,6 +175,7 @@ let telegramNotificationsBotOpenedAt = 0; let telegramNotificationsResumeRefreshBusy = false; let telegramNotificationsResumeLastCheckAt = 0; + let telegramLinkPendingActionBusy = false; let promoCode = ""; let promoBusy = false; let promoStatus = ""; @@ -358,13 +363,9 @@ } = $accountStore); $: brandTitle = CFG.title || "/minishop"; - $: brandEmoji = CFG.logoEmoji || "🫥"; - $: brandEmojiFont = CFG.logoEmojiFont || "system"; $: brand = normalizeBrand({ title: brandTitle, - logoUrl: CFG.logoUseEmoji ? "" : CFG.logoUrl, - emoji: brandEmoji, - emojiFont: brandEmojiFont, + logoUrl: CFG.logoUrl, }); $: faviconBrand = normalizeBrand({ ...brand, @@ -494,7 +495,7 @@ $: telegramProfileName = telegramName(user); $: profileEmail = user?.email || t("wa_settings_email_not_linked"); $: profileTelegramId = user?.telegram_id ? `TG ID ${user.telegram_id}` : t("wa_tg_id_not_linked"); - $: profileAvatarUrl = user?.telegram_photo_url || emailAvatarUrl || ""; + $: profileAvatarUrl = resolveProfileAvatarUrl(user, emailAvatarUrl); $: privacyPolicyUrl = String(CFG.privacyPolicyUrl || "").trim(); $: userAgreementUrl = String(CFG.userAgreementUrl || "").trim(); $: supportUrl = String(appSettings?.support_url || CFG.supportUrl || "").trim(); @@ -1187,6 +1188,161 @@ } } + function currentTelegramLinkPendingUserId() { + const currentUser = data?.user || user || {}; + const id = currentUser.user_id ?? currentUser.id; + return id == null ? "" : String(id); + } + + function isTelegramLinkPendingAction(action) { + return [TELEGRAM_LINK_ACTION_TRIAL, TELEGRAM_LINK_ACTION_REFERRAL_WELCOME].includes(action); + } + + function rememberTelegramLinkPendingAction(action) { + if (typeof window === "undefined" || !isTelegramLinkPendingAction(action)) return; + try { + window.sessionStorage.setItem( + TELEGRAM_LINK_PENDING_ACTION_STORAGE_KEY, + JSON.stringify({ + action, + userId: currentTelegramLinkPendingUserId(), + createdAt: Date.now(), + }) + ); + } catch (_error) { + void _error; + } + } + + function clearTelegramLinkPendingAction() { + if (typeof window === "undefined") return; + try { + window.sessionStorage.removeItem(TELEGRAM_LINK_PENDING_ACTION_STORAGE_KEY); + } catch (_error) { + void _error; + } + } + + function readTelegramLinkPendingAction() { + if (typeof window === "undefined") return null; + try { + const raw = window.sessionStorage.getItem(TELEGRAM_LINK_PENDING_ACTION_STORAGE_KEY); + if (!raw) return null; + const payload = JSON.parse(raw); + const action = String(payload?.action || ""); + const createdAt = Number(payload?.createdAt || 0); + const pendingUserId = String(payload?.userId || ""); + const currentUserId = currentTelegramLinkPendingUserId(); + if ( + !isTelegramLinkPendingAction(action) || + !createdAt || + Date.now() - createdAt > TELEGRAM_LINK_PENDING_TTL_MS || + (pendingUserId && currentUserId && pendingUserId !== currentUserId) + ) { + clearTelegramLinkPendingAction(); + return null; + } + return action; + } catch (_error) { + clearTelegramLinkPendingAction(); + return null; + } + } + + async function runTelegramLinkedAction(action) { + if (action === TELEGRAM_LINK_ACTION_TRIAL) { + await activateTrial(); + return true; + } + if (action === TELEGRAM_LINK_ACTION_REFERRAL_WELCOME) { + await claimReferralWelcomeBonus(); + return true; + } + return false; + } + + async function continueTelegramLinkPendingAction() { + if (telegramLinkPendingActionBusy) return false; + const currentUser = data?.user || user || {}; + if (!currentUser?.telegram_linked) return false; + const action = readTelegramLinkPendingAction(); + if (!action) return false; + telegramLinkPendingActionBusy = true; + clearTelegramLinkPendingAction(); + try { + return await runTelegramLinkedAction(action); + } finally { + telegramLinkPendingActionBusy = false; + } + } + + async function linkTelegramWithPayloadForPendingAction(payload) { + accountStore.update((s) => ({ ...s, linkTelegramBusy: true })); + try { + const response = await api("/account/telegram/link", { + method: "POST", + body: JSON.stringify(payload), + }); + if (!response?.ok) throw response; + if (response?.csrf_token) setToken("", response.csrf_token); + await loadData({ fresh: true, preserveView: true }); + const handled = await continueTelegramLinkPendingAction(); + if (!handled) { + clearTelegramLinkPendingAction(); + showToast(t("wa_settings_linked")); + } + } catch (error) { + clearTelegramLinkPendingAction(); + showToast(error?.message || t("wa_auth_telegram_not_confirmed")); + } finally { + accountStore.update((s) => ({ ...s, linkTelegramBusy: false })); + } + } + + async function linkTelegramForPendingAction(action) { + if (!isTelegramLinkPendingAction(action) || linkTelegramBusy || telegramLinkPendingActionBusy) { + return; + } + const currentUser = data?.user || user || {}; + if (currentUser?.telegram_linked) { + await runTelegramLinkedAction(action); + return; + } + + rememberTelegramLinkPendingAction(action); + if (demoAuthLogin) { + await linkTelegramWithPayloadForPendingAction({ auth_data: demoTelegramAuthPayload() }); + return; + } + + const isTelegramMiniAppAttempt = hasTelegramLaunchParams(); + if (isTelegramMiniAppAttempt) { + await telegramSdk.ensureForAction(); + } + const initData = + telegramMiniAppInitData || tg?.initData || readTelegramMiniAppInitDataFromLocation(); + if (initData) { + await linkTelegramWithPayloadForPendingAction({ init_data: initData }); + return; + } + if (!telegramOAuthClientId) { + clearTelegramLinkPendingAction(); + showToast(t("wa_auth_telegram_not_configured")); + return; + } + await accountStore.linkTelegramAccount( + () => telegramMiniAppInitData || tg?.initData || readTelegramMiniAppInitDataFromLocation() + ); + } + + function linkTelegramAndActivateTrial() { + return linkTelegramForPendingAction(TELEGRAM_LINK_ACTION_TRIAL); + } + + function linkTelegramAndClaimReferralWelcome() { + return linkTelegramForPendingAction(TELEGRAM_LINK_ACTION_REFERRAL_WELCOME); + } + function openTelegramNotificationsBot() { const link = telegramNotificationsStartLink; telegramNotificationsBotOpenedAt = Date.now(); @@ -1453,9 +1609,12 @@ getCsrfToken: () => csrfToken, }); if (mode === "app" && screen !== "admin") { - if (hasPendingActivationHandoff()) await loadData({ fresh: true }); - const shown = await maybeShowActivationSuccessDialog({ source: "boot" }); - if (!shown) startPendingActivationWatch(); + const telegramActionHandled = await continueTelegramLinkPendingAction(); + if (!telegramActionHandled) { + if (hasPendingActivationHandoff()) await loadData({ fresh: true }); + const shown = await maybeShowActivationSuccessDialog({ source: "boot" }); + if (!shown) startPendingActivationWatch(); + } } } @@ -1524,6 +1683,7 @@ selectedPlan: null, selectedTariffKey: "", paymentStep: "tariff", + renewHwidDevices: true, selectedMethod: payload.payment_methods?.[0]?.id || "", })); const currentQuery = currentSearchParams(); @@ -1904,6 +2064,55 @@ } } + function trialActivationFailureMessage(error) { + if ( + error?.error === "trial_telegram_required" || + error?.message === "telegram_required" || + error?.message === "disposable_email" + ) { + return t( + "wa_trial_telegram_required_error", + {}, + "Для активации пробного периода привяжите Telegram." + ); + } + return error?.message || t("wa_trial_activation_failed"); + } + + function referralWelcomeFailureMessage(error) { + if ( + error?.error === "referral_welcome_telegram_required" || + error?.message === "telegram_required" || + error?.message === "disposable_email" + ) { + return t( + "wa_referral_welcome_telegram_required_error", + {}, + "Для получения реферального бонуса привяжите Telegram." + ); + } + return error?.message || t("wa_referral_welcome_claim_failed"); + } + + async function claimReferralWelcomeBonus() { + try { + const response = await api("/referral/welcome-bonus/claim", { + method: "POST", + body: JSON.stringify({}), + }); + if (!response.ok) throw response; + showToast( + response.end_date_text + ? t("wa_referral_welcome_claimed_until", { date: response.end_date_text }) + : t("wa_referral_welcome_claimed") + ); + await loadData({ fresh: true }); + await maybeShowActivationSuccessDialog({ source: "referral_welcome", force: true }); + } catch (error) { + showToast(referralWelcomeFailureMessage(error)); + } + } + async function activateTrial() { if (trialBusy) return; trialBusy = true; @@ -1920,7 +2129,7 @@ await loadData({ fresh: true }); await maybeShowActivationSuccessDialog({ source: "trial", force: true }); } catch (error) { - const message = error?.message || t("wa_trial_activation_failed"); + const message = trialActivationFailureMessage(error); trialActivationError = message; showToast(message); } finally { @@ -2077,9 +2286,6 @@ ]); return [ "WEBAPP_LOGO_URL", - "WEBAPP_LOGO_USE_EMOJI", - "WEBAPP_LOGO_EMOJI", - "WEBAPP_LOGO_EMOJI_FONT", "WEBAPP_FAVICON_URL", "WEBAPP_FAVICON_USE_CUSTOM", "WEBAPP_LOGO_FAVICON_URL", @@ -2286,7 +2492,9 @@ {premiumTrafficTopupUnlocked} {regularTrafficTopupBarClickable} {regularTrafficTopupUnlocked} + {referral} {subscription} + {linkTelegramBusy} {telegramNotificationsNeedPrompt} {telegramNotificationsStartLink} {telegramNotificationsStatus} @@ -2294,6 +2502,8 @@ {trafficMode} {trialBusy} {activateTrial} + {linkTelegramAndActivateTrial} + {linkTelegramAndClaimReferralWelcome} {openTelegramNotificationsBot} openConnectLink={openInstallOrConnect} {openPaymentModal} @@ -2323,9 +2533,11 @@ {brandTitle} {subscription} {trialBusy} + {linkTelegramBusy} trialResult={trialActivationResult} trialError={trialActivationError} {activateTrial} + {linkTelegramAndActivateTrial} openInstallOrConnect={openTrialInstallOrConnect} {goHome} {t} @@ -2428,6 +2640,7 @@ bind:paymentStep={$billingStore.paymentStep} bind:selectedMethod={$billingStore.selectedMethod} bind:selectedPlan={$billingStore.selectedPlan} + bind:renewHwidDevices={$billingStore.renewHwidDevices} bind:selectedTariffKey={$billingStore.selectedTariffKey} bind:setPasswordCode={$accountStore.setPasswordCode} bind:setPasswordConfirm={$accountStore.setPasswordConfirm} diff --git a/frontend/src/PreviewBoard.svelte b/frontend/src/PreviewBoard.svelte index fd64391..f43375a 100644 --- a/frontend/src/PreviewBoard.svelte +++ b/frontend/src/PreviewBoard.svelte @@ -31,7 +31,7 @@ export let mockData = {}; const title = config.title || "/minishop"; - const logoEmoji = config.logoEmoji || "🫥"; + const logoUrl = config.logoUrl || "/webapp-default-logo.webp"; const plans = mockData.plans || []; const sub = mockData.subscription || {}; const methods = mockData.payment_methods || []; @@ -78,7 +78,7 @@
@@ -113,7 +113,7 @@
-
{logoEmoji}
+
{title}
@@ -198,7 +198,7 @@
-
{logoEmoji}
+
{title}
@@ -221,7 +221,7 @@
-
{logoEmoji}
+
{title}
@@ -252,7 +252,7 @@ diff --git a/frontend/src/admin/sections/AppearanceSection.svelte b/frontend/src/admin/sections/AppearanceSection.svelte index 21aef22..26ad2a2 100644 --- a/frontend/src/admin/sections/AppearanceSection.svelte +++ b/frontend/src/admin/sections/AppearanceSection.svelte @@ -1,16 +1,10 @@
@@ -24,7 +35,7 @@ {at("broadcast_label_audience", {}, "Аудитория")} broadcastStore.updateField({ broadcastTarget: value })} /> diff --git a/frontend/src/admin/sections/SettingsSection.svelte b/frontend/src/admin/sections/SettingsSection.svelte index 3443016..8d151fb 100644 --- a/frontend/src/admin/sections/SettingsSection.svelte +++ b/frontend/src/admin/sections/SettingsSection.svelte @@ -348,6 +348,7 @@ devices: "Устройства", subscription_guides: "Connection guides", system: "Система", + migrations: "Миграции", }; return adminText(`settings_section_${id}`, {}, map[id] || id); } diff --git a/frontend/src/admin/sections/TariffEditorModal.svelte b/frontend/src/admin/sections/TariffEditorModal.svelte index 1e1e1c9..53f9bbf 100644 --- a/frontend/src/admin/sections/TariffEditorModal.svelte +++ b/frontend/src/admin/sections/TariffEditorModal.svelte @@ -1,5 +1,5 @@ @@ -33,11 +44,11 @@ step={sliderStep} {disabled} onValueChange={handleValueChange} - {onValueCommit} + onValueCommit={handleValueCommit} {...$$restProps} > - + diff --git a/frontend/src/lib/webapp/BrandMark.svelte b/frontend/src/lib/webapp/BrandMark.svelte index cabedee..d79a3cb 100644 --- a/frontend/src/lib/webapp/BrandMark.svelte +++ b/frontend/src/lib/webapp/BrandMark.svelte @@ -1,39 +1,15 @@