From 2ad6b145134427a7411251d0cdab09c64660220a Mon Sep 17 00:00:00 2001 From: 3252a8 <3252a8@proton.me> Date: Thu, 4 Jun 2026 10:51:58 +0300 Subject: [PATCH] feat: add telegram guardrails for trials and referrals --- .../bot/app/web/admin_settings_manifest.py | 56 ++- backend/bot/app/web/webapp/_runtime.py | 2 +- backend/bot/app/web/webapp/auth.py | 124 +++++- backend/bot/app/web/webapp/billing.py | 8 + backend/bot/app/web/webapp/routes.py | 1 + backend/bot/app/web/webapp/serializers.py | 36 +- backend/bot/services/email_auth_service.py | 28 ++ backend/config/settings.py | 144 +++++- docs/configuration/env-vars.md | 3 + docs/features/tariffs.md | 4 +- frontend/src/App.svelte | 226 +++++++++- .../src/admin/sections/TariffsSection.svelte | 416 +++++++++++++++++- frontend/src/lib/webapp/demoDataset.js | 10 +- frontend/src/lib/webapp/mockApi.js | 202 +++++++++ frontend/src/lib/webapp/previewMock.js | 140 ++++++ .../webapp/settingsManifest.generated.json | 179 +++++--- .../src/lib/webapp/stores/billingStore.js | 3 +- frontend/src/styles/webapp.css | 12 + frontend/src/webapp/PaymentDialogs.svelte | 8 +- frontend/src/webapp/screens/HomeScreen.svelte | 152 +++++-- .../screens/TrialActivationScreen.svelte | 45 ++ locales/en.json | 37 +- locales/ru.json | 37 +- tests/test_admin_settings_manifest_i18n.py | 26 ++ tests/test_webapp_referral_welcome_bonus.py | 78 ++++ tests/test_webapp_trial_activation.py | 92 ++++ 26 files changed, 1944 insertions(+), 125 deletions(-) create mode 100644 tests/test_webapp_referral_welcome_bonus.py diff --git a/backend/bot/app/web/admin_settings_manifest.py b/backend/bot/app/web/admin_settings_manifest.py index eb58fb0..e990f05 100644 --- a/backend/bot/app/web/admin_settings_manifest.py +++ b/backend/bot/app/web/admin_settings_manifest.py @@ -407,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", @@ -417,12 +429,50 @@ 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("LEGACY_REFS", "bool", "referral", "Поддержка старых ref-ссылок"), SettingField( "MIGRATION_REMNASHOP_REFERRAL_CODE_COMPAT_ENABLED", "bool", diff --git a/backend/bot/app/web/webapp/_runtime.py b/backend/bot/app/web/webapp/_runtime.py index 2a578f1..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 diff --git a/backend/bot/app/web/webapp/auth.py b/backend/bot/app/web/webapp/auth.py index e1a7f2c..88f6de4 100644 --- a/backend/bot/app/web/webapp/auth.py +++ b/backend/bot/app/web/webapp/auth.py @@ -1012,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, @@ -1433,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, @@ -1456,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], diff --git a/backend/bot/app/web/webapp/billing.py b/backend/bot/app/web/webapp/billing.py index f91b8f5..9408c74 100644 --- a/backend/bot/app/web/webapp/billing.py +++ b/backend/bot/app/web/webapp/billing.py @@ -1,6 +1,7 @@ # 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 @@ -390,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"): diff --git a/backend/bot/app/web/webapp/routes.py b/backend/bot/app/web/webapp/routes.py index e6535b4..3cec44b 100644 --- a/backend/bot/app/web/webapp/routes.py +++ b/backend/bot/app/web/webapp/routes.py @@ -85,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 1c3294b..b292679 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,11 +69,15 @@ 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, @@ -95,6 +104,15 @@ async def _build_user_payload(request: web.Request, user_id: int) -> Dict[str, A 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) ) @@ -111,7 +129,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 @@ -137,9 +155,14 @@ 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) ), @@ -174,6 +197,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"), diff --git a/backend/bot/services/email_auth_service.py b/backend/bot/services/email_auth_service.py index 034f780..82e9ab9 100644 --- a/backend/bot/services/email_auth_service.py +++ b/backend/bot/services/email_auth_service.py @@ -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}" diff --git a/backend/config/settings.py b/backend/config/settings.py index 1a3f65b..0908818 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): @@ -282,6 +393,13 @@ 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 @@ -346,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=( @@ -449,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) @@ -633,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]: diff --git a/docs/configuration/env-vars.md b/docs/configuration/env-vars.md index 47827fe..7b5e03e 100644 --- a/docs/configuration/env-vars.md +++ b/docs/configuration/env-vars.md @@ -386,10 +386,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/tariffs.md b/docs/features/tariffs.md index dd335f8..8780c26 100644 --- a/docs/features/tariffs.md +++ b/docs/features/tariffs.md @@ -350,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/frontend/src/App.svelte b/frontend/src/App.svelte index 9457e17..4670111 100644 --- a/frontend/src/App.svelte +++ b/frontend/src/App.svelte @@ -74,6 +74,10 @@ 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, @@ -171,6 +175,7 @@ let telegramNotificationsBotOpenedAt = 0; let telegramNotificationsResumeRefreshBusy = false; let telegramNotificationsResumeLastCheckAt = 0; + let telegramLinkPendingActionBusy = false; let promoCode = ""; let promoBusy = false; let promoStatus = ""; @@ -1183,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(); @@ -1449,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(); + } } } @@ -1901,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; @@ -1917,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 { @@ -2280,7 +2492,9 @@ {premiumTrafficTopupUnlocked} {regularTrafficTopupBarClickable} {regularTrafficTopupUnlocked} + {referral} {subscription} + {linkTelegramBusy} {telegramNotificationsNeedPrompt} {telegramNotificationsStartLink} {telegramNotificationsStatus} @@ -2288,6 +2502,8 @@ {trafficMode} {trialBusy} {activateTrial} + {linkTelegramAndActivateTrial} + {linkTelegramAndClaimReferralWelcome} {openTelegramNotificationsBot} openConnectLink={openInstallOrConnect} {openPaymentModal} @@ -2317,9 +2533,11 @@ {brandTitle} {subscription} {trialBusy} + {linkTelegramBusy} trialResult={trialActivationResult} trialError={trialActivationError} {activateTrial} + {linkTelegramAndActivateTrial} openInstallOrConnect={openTrialInstallOrConnect} {goHome} {t} diff --git a/frontend/src/admin/sections/TariffsSection.svelte b/frontend/src/admin/sections/TariffsSection.svelte index 0eee329..03dc217 100644 --- a/frontend/src/admin/sections/TariffsSection.svelte +++ b/frontend/src/admin/sections/TariffsSection.svelte @@ -1,5 +1,5 @@