diff --git a/bot/app/web/subscription_webapp.py b/bot/app/web/subscription_webapp.py
index 55d432f..73b998f 100644
--- a/bot/app/web/subscription_webapp.py
+++ b/bot/app/web/subscription_webapp.py
@@ -1,8 +1,10 @@
import json
import logging
+import re
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Dict, List, Optional
+from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
from aiohttp import ClientSession, ClientTimeout, web
from aiogram import Bot, Dispatcher
@@ -20,6 +22,8 @@ from bot.services.crypto_pay_service import CryptoPayService
from bot.services.email_auth_service import EmailAuthService, normalize_email
from bot.services.freekassa_service import FreeKassaService
from bot.services.platega_service import PlategaService
+from bot.services.promo_code_service import PromoCodeService
+from bot.services.referral_service import ReferralService
from bot.services.severpay_service import SeverPayService
from bot.services.subscription_service import SubscriptionService
from bot.services.yookassa_service import YooKassaService
@@ -65,6 +69,8 @@ def create_subscription_webapp_application(
"cryptopay_service",
"platega_service",
"severpay_service",
+ "promo_code_service",
+ "referral_service",
):
if hasattr(dp, "workflow_data") and key in dp.workflow_data: # type: ignore[attr-defined]
app[key] = dp.workflow_data[key] # type: ignore[index]
@@ -90,6 +96,7 @@ def setup_subscription_webapp_routes(app: web.Application) -> None:
app.router.add_post("/api/account/email/request", account_email_request_route)
app.router.add_post("/api/account/email/verify", account_email_verify_route)
app.router.add_post("/api/account/telegram/link", account_telegram_link_route)
+ app.router.add_post("/api/promo/apply", apply_promo_route)
app.router.add_post("/api/payments", create_payment_route)
app.router.add_get("/api/payments/{payment_id}", payment_status_route)
@@ -331,6 +338,7 @@ async def auth_token_route(request: web.Request) -> web.Response:
payload = await _read_json(request)
init_data = str(payload.get("init_data") or "")
auth_data = payload.get("auth_data")
+ referral_param = str(payload.get("referral_code") or payload.get("start_param") or "")
telegram_user = None
if init_data:
telegram_user = validate_telegram_webapp_init_data(
@@ -352,10 +360,28 @@ async def auth_token_route(request: web.Request) -> web.Response:
authenticated_user_id: Optional[int] = None
async with async_session_factory() as session:
try:
- db_user = await _ensure_user_from_telegram(session, telegram_user, settings)
+ db_user = await _ensure_user_from_telegram(
+ session,
+ telegram_user,
+ settings,
+ referral_param=referral_param,
+ )
if db_user.is_banned:
await session.rollback()
return _json_error(403, "banned", "Access denied")
+ referral_applied = await _apply_referral_to_existing_user(
+ request,
+ session,
+ db_user,
+ referral_param or telegram_user.get("start_param"),
+ )
+ if getattr(db_user, "_webapp_created", False) or referral_applied:
+ await _apply_referral_welcome_bonus_if_needed(
+ request,
+ session,
+ db_user,
+ referral_param or telegram_user.get("start_param"),
+ )
authenticated_user_id = int(db_user.user_id)
await session.commit()
except Exception as exc:
@@ -386,6 +412,7 @@ async def email_auth_verify_route(request: web.Request) -> web.Response:
payload = await _read_json(request)
email = normalize_email(str(payload.get("email") or ""))
code = str(payload.get("code") or "")
+ referral_param = str(payload.get("referral_code") or payload.get("start_param") or "")
email_service: EmailAuthService = request.app["email_auth_service"]
async_session_factory: sessionmaker = request.app["async_session_factory"]
@@ -403,16 +430,38 @@ async def email_auth_verify_route(request: web.Request) -> web.Response:
return _json_error(400, verify_result.error or "invalid_code", "Invalid code")
db_user = await user_dal.get_user_by_email(session, email)
+ created_user = False
if not db_user:
+ referred_by_id = await _resolve_referrer_id(
+ session,
+ referral_param,
+ current_user_id=None,
+ )
db_user, _ = await user_dal.create_email_user(
session,
email=email,
language_code=_normalize_language(settings.DEFAULT_LANGUAGE),
email_verified_at=datetime.now(timezone.utc),
+ referred_by_id=referred_by_id,
)
+ created_user = True
elif not db_user.email_verified_at:
db_user.email_verified_at = datetime.now(timezone.utc)
+ referral_applied = await _apply_referral_to_existing_user(
+ request,
+ session,
+ db_user,
+ referral_param,
+ )
+ if created_user or referral_applied:
+ await _apply_referral_welcome_bonus_if_needed(
+ request,
+ session,
+ db_user,
+ referral_param,
+ )
+
if db_user.is_banned:
await session.rollback()
return _json_error(403, "banned", "Access denied")
@@ -569,6 +618,50 @@ async def me_route(request: web.Request) -> web.Response:
return web.json_response({"ok": True, **data})
+async def apply_promo_route(request: web.Request) -> web.Response:
+ user_id = _require_user_id(request)
+ payload = await _read_json(request)
+ code = str(payload.get("code") or "").strip()
+ if not code:
+ return _json_error(400, "empty_code", "Promo code is empty")
+
+ settings: Settings = request.app["settings"]
+ promo_code_service: PromoCodeService = request.app.get("promo_code_service")
+ if not promo_code_service:
+ return _json_error(503, "service_unavailable", "Promo service unavailable")
+
+ 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")
+ lang = _normalize_language(db_user.language_code or settings.DEFAULT_LANGUAGE)
+ success, result = await promo_code_service.apply_promo_code(
+ session,
+ user_id,
+ code,
+ lang,
+ )
+ if not success:
+ await session.rollback()
+ return _json_error(400, "promo_apply_failed", str(result))
+ await session.commit()
+ end_date = result if isinstance(result, datetime) else None
+ return web.json_response(
+ {
+ "ok": True,
+ "end_date": end_date.isoformat() if end_date else None,
+ "end_date_text": end_date.strftime("%d.%m.%Y %H:%M") if end_date else None,
+ }
+ )
+ except Exception as exc:
+ await session.rollback()
+ logger.error("WebApp promo apply failed: %s", exc, exc_info=True)
+ return _json_error(500, "promo_apply_failed", "Promo apply failed")
+
+
async def create_payment_route(request: web.Request) -> web.Response:
user_id = _require_user_id(request)
payload = await _read_json(request)
@@ -832,10 +925,117 @@ async def _link_telegram_to_user(
return current_user
+def _normalize_referral_param(raw: Optional[str]) -> Optional[str]:
+ value = (raw or "").strip()
+ if not value:
+ return None
+
+ value_lower = value.lower()
+ if value_lower.startswith("ref_u"):
+ value = value[5:]
+ elif value_lower.startswith("ref_"):
+ value = value[4:]
+ elif 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):
+ return None
+ return value.upper()
+
+
+async def _resolve_referrer_id(
+ session: AsyncSession,
+ raw_referral_param: Optional[str],
+ *,
+ current_user_id: Optional[int],
+) -> Optional[int]:
+ normalized = _normalize_referral_param(raw_referral_param)
+ if not normalized:
+ 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)
+
+
+async def _apply_referral_to_existing_user(
+ request: web.Request,
+ session: AsyncSession,
+ user: User,
+ raw_referral_param: Optional[str],
+) -> bool:
+ if not raw_referral_param or user.referred_by_id is not None:
+ return False
+
+ referred_by_id = await _resolve_referrer_id(
+ session,
+ raw_referral_param,
+ current_user_id=int(user.user_id),
+ )
+ if not referred_by_id:
+ return False
+
+ subscription_service: SubscriptionService = request.app["subscription_service"]
+ try:
+ is_active_now = await subscription_service.has_active_subscription(
+ session,
+ int(user.user_id),
+ )
+ except Exception:
+ is_active_now = False
+ if is_active_now:
+ return False
+
+ user.referred_by_id = referred_by_id
+ await session.flush()
+ return True
+
+
+async def _apply_referral_welcome_bonus_if_needed(
+ request: web.Request,
+ session: AsyncSession,
+ user: User,
+ raw_referral_param: Optional[str],
+) -> Optional[datetime]:
+ if not raw_referral_param or not user.referred_by_id:
+ return None
+
+ settings: Settings = request.app["settings"]
+ referral_welcome_days = max(
+ 0,
+ int(getattr(settings, "REFERRAL_WELCOME_BONUS_DAYS", 0) or 0),
+ )
+ if referral_welcome_days <= 0:
+ return None
+
+ subscription_service: SubscriptionService = request.app["subscription_service"]
+ try:
+ if await subscription_service.has_active_subscription(session, int(user.user_id)):
+ return None
+ except Exception:
+ pass
+
+ return await subscription_service.extend_active_subscription_days(
+ session,
+ int(user.user_id),
+ referral_welcome_days,
+ reason="referral_welcome_bonus",
+ )
+
+
async def _ensure_user_from_telegram(
session: AsyncSession,
telegram_user: Dict[str, Any],
settings: Settings,
+ *,
+ referral_param: Optional[str] = None,
) -> User:
user_id = int(telegram_user["id"])
language_code = telegram_user.get("language_code") or settings.DEFAULT_LANGUAGE
@@ -854,14 +1054,21 @@ async def _ensure_user_from_telegram(
if not db_user:
db_user = await user_dal.get_user_by_id(session, user_id)
if not db_user:
- db_user, _ = await user_dal.create_user(
+ referred_by_id = await _resolve_referrer_id(
+ session,
+ referral_param or telegram_user.get("start_param"),
+ current_user_id=user_id,
+ )
+ db_user, created = await user_dal.create_user(
session,
{
"user_id": user_id,
**update_data,
+ "referred_by_id": referred_by_id,
"registration_date": datetime.now(timezone.utc),
},
)
+ setattr(db_user, "_webapp_created", bool(created))
return db_user
changed = {
@@ -890,6 +1097,25 @@ async def _build_user_payload(request: web.Request, user_id: int) -> Dict[str, A
active = await subscription_service.get_active_subscription_details(
session, user_id
)
+ referral_code = await user_dal.ensure_referral_code(session, db_user)
+ referral_service: Optional[ReferralService] = request.app.get("referral_service")
+ bot_username = request.app.get("bot_username") or ""
+ referral_link = None
+ if referral_service and bot_username:
+ referral_link = await referral_service.generate_referral_link(
+ session,
+ bot_username,
+ user_id,
+ )
+ webapp_referral_link = _build_webapp_referral_link(
+ request.app["settings"].SUBSCRIPTION_MINI_APP_URL,
+ referral_code,
+ )
+ referral_stats = (
+ await referral_service.get_referral_stats(session, user_id)
+ if referral_service
+ else {"invited_count": 0, "purchased_count": 0}
+ )
local_sub = await subscription_dal.get_active_subscription_by_user_id(
session,
user_id,
@@ -913,6 +1139,14 @@ async def _build_user_payload(request: web.Request, user_id: int) -> Dict[str, A
"language_code": lang,
},
"subscription": _serialize_subscription(active, local_sub, lang),
+ "referral": {
+ "code": referral_code,
+ "bot_link": referral_link,
+ "webapp_link": webapp_referral_link,
+ "invited_count": referral_stats.get("invited_count", 0),
+ "purchased_count": referral_stats.get("purchased_count", 0),
+ "bonus_details": _serialize_referral_bonus_details(settings, lang),
+ },
"plans": _serialize_plans(settings, lang),
"payment_methods": _serialize_payment_methods(settings, request.app),
"settings": {
@@ -923,6 +1157,47 @@ async def _build_user_payload(request: web.Request, user_id: int) -> Dict[str, A
}
+def _serialize_referral_bonus_details(settings: Settings, lang: str) -> List[Dict[str, Any]]:
+ if getattr(settings, "traffic_sale_mode", False):
+ return []
+
+ details: List[Dict[str, Any]] = []
+ for months, _price in sorted(settings.subscription_options.items()):
+ inviter_days = settings.referral_bonus_inviter.get(months)
+ friend_days = settings.referral_bonus_referee.get(months)
+ if inviter_days is None and friend_days is None:
+ continue
+ details.append(
+ {
+ "months": int(months),
+ "title": _format_months_title(int(months), lang),
+ "inviter_days": int(inviter_days or 0),
+ "friend_days": int(friend_days or 0),
+ }
+ )
+ return details
+
+
+def _build_webapp_referral_link(
+ base_url: Optional[str],
+ referral_code: Optional[str],
+) -> Optional[str]:
+ if not base_url or not referral_code:
+ return None
+ parts = urlsplit(base_url)
+ query = dict(parse_qsl(parts.query, keep_blank_values=True))
+ query["ref"] = f"u{referral_code}"
+ return urlunsplit(
+ (
+ parts.scheme,
+ parts.netloc,
+ parts.path or "/",
+ urlencode(query),
+ parts.fragment,
+ )
+ )
+
+
def _serialize_subscription(
active: Optional[Dict[str, Any]],
local_sub: Optional[Any],
diff --git a/bot/app/web/templates/subscription_webapp.css b/bot/app/web/templates/subscription_webapp.css
index 12c679b..8a2124c 100644
--- a/bot/app/web/templates/subscription_webapp.css
+++ b/bot/app/web/templates/subscription_webapp.css
@@ -184,13 +184,22 @@
min-width: 0;
}
- .account-panel {
+ .account-panel,
+ .promo-panel,
+ .referral-panel {
padding: 17px;
display: grid;
gap: 14px;
min-width: 0;
}
+ .quick-actions {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 8px;
+ min-width: 0;
+ }
+
.panel-head {
display: flex;
align-items: flex-start;
@@ -229,6 +238,93 @@
border-top: 1px solid var(--border);
}
+ .promo-status {
+ min-height: 22px;
+ margin-top: 2px;
+ color: var(--text-secondary);
+ font-size: 14px;
+ line-height: 1.45;
+ overflow-wrap: anywhere;
+ }
+
+ .promo-status.error {
+ color: #fca5a5;
+ }
+
+ .promo-status.success {
+ color: #6ee7b7;
+ }
+
+ .modal-panel {
+ position: fixed;
+ left: 50%;
+ top: 50%;
+ z-index: 30;
+ width: min(calc(100vw - 28px), 560px);
+ max-height: min(86vh, 760px);
+ overflow: auto;
+ transform: translate(-50%, -50%);
+ box-shadow: 0 30px 70px rgba(0, 0, 0, 0.5);
+ }
+
+ .tools-modal-backdrop {
+ position: fixed;
+ inset: 0;
+ z-index: 25;
+ padding: 0;
+ border: 0;
+ background: rgba(3, 6, 10, 0.72);
+ backdrop-filter: blur(7px);
+ }
+
+ .referral-link-list,
+ .referral-bonuses,
+ .bonus-list {
+ display: grid;
+ gap: 8px;
+ min-width: 0;
+ }
+
+ .referral-link-row,
+ .bonus-row {
+ min-height: 58px;
+ padding: 11px 12px;
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) 48px;
+ align-items: center;
+ gap: 10px;
+ border: 1px solid var(--border);
+ border-radius: var(--radius-md);
+ background: rgba(255, 255, 255, 0.02);
+ min-width: 0;
+ }
+
+ .bonus-row {
+ grid-template-columns: minmax(0, 0.8fr) minmax(0, 1.2fr);
+ }
+
+ .bonus-row span,
+ .bonus-row strong,
+ .referral-link-value {
+ min-width: 0;
+ overflow-wrap: anywhere;
+ }
+
+ .bonus-row strong {
+ color: var(--accent);
+ font-size: 13px;
+ text-align: right;
+ }
+
+ .referral-link-value {
+ margin-top: 4px;
+ color: var(--text-primary);
+ font-family: var(--font-mono);
+ font-size: 12px;
+ font-weight: 750;
+ line-height: 1.35;
+ }
+
.link-box,
.field-stack {
display: grid;
@@ -1008,10 +1104,17 @@
}
.account-row,
- .field-row {
+ .field-row,
+ .quick-actions,
+ .referral-link-row,
+ .bonus-row {
grid-template-columns: 1fr;
}
+ .bonus-row strong {
+ text-align: left;
+ }
+
.metric-value {
text-align: left;
}
diff --git a/bot/app/web/templates/subscription_webapp.html b/bot/app/web/templates/subscription_webapp.html
index ff23fab..19e3212 100644
--- a/bot/app/web/templates/subscription_webapp.html
+++ b/bot/app/web/templates/subscription_webapp.html
@@ -110,6 +110,28 @@
+