Files
remnawave-minishop/bot/app/web/subscription_webapp.py
T

1799 lines
65 KiB
Python

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
from aiogram.types import LabeledPrice
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import sessionmaker
from bot.app.web.webapp_auth import (
create_webapp_session_token,
validate_telegram_login_widget_data,
validate_telegram_webapp_init_data,
verify_webapp_session_token,
)
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
from bot.utils.text_sanitizer import sanitize_display_name, sanitize_username
from config.settings import Settings
from db.dal import payment_dal, subscription_dal, user_dal
from db.dal.user_dal import UserMergeConflictError
from db.models import Payment, User
logger = logging.getLogger(__name__)
TEMPLATE_PATH = Path(__file__).resolve().parent / "templates" / "subscription_webapp.html"
ASSET_DIR = TEMPLATE_PATH.parent
TELEGRAM_WEB_APP_SDK_URL = "https://telegram.org/js/telegram-web-app.js"
TELEGRAM_WEB_APP_SDK_PATH = ASSET_DIR / "telegram-web-app.js"
TELEGRAM_WIDGET_SDK_URL = "https://telegram.org/js/telegram-widget.js?23"
TELEGRAM_WIDGET_SDK_PATH = ASSET_DIR / "telegram-widget.js"
_UNPATCHED_WIDGET_ORIGIN_SNIPPET = """ if (origin == 'https://telegram.org') {\n origin = default_origin;\n } else if (origin == 'https://telegram-js.azureedge.net' || origin == 'https://tg.dev') {\n origin = dev_origin;\n }\n"""
_PATCHED_WIDGET_ORIGIN_SNIPPET = """ if (origin == 'https://telegram.org') {\n origin = default_origin;\n } else if (origin == 'https://telegram-js.azureedge.net' || origin == 'https://tg.dev') {\n origin = dev_origin;\n } else {\n origin = default_origin;\n }\n"""
WEBAPP_CONFIG_PLACEHOLDER = "<!-- WEBAPP_CONFIG_SCRIPT -->"
DEV_MOCK_START_MARKER = "<!-- WEBAPP_DEV_MOCK_START -->"
DEV_MOCK_END_MARKER = "<!-- WEBAPP_DEV_MOCK_END -->"
def create_subscription_webapp_application(
dp: Dispatcher,
bot: Bot,
settings: Settings,
async_session_factory: sessionmaker,
) -> web.Application:
app = web.Application()
app["bot"] = bot
app["dp"] = dp
app["settings"] = settings
app["async_session_factory"] = async_session_factory
app["i18n"] = dp.get("i18n_instance")
app["email_auth_service"] = EmailAuthService(settings)
for key in (
"subscription_service",
"yookassa_service",
"freekassa_service",
"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]
if hasattr(dp, "workflow_data") and "bot_username" in dp.workflow_data: # type: ignore[attr-defined]
app["bot_username"] = dp.workflow_data["bot_username"] # type: ignore[index]
setup_subscription_webapp_routes(app)
return app
def setup_subscription_webapp_routes(app: web.Application) -> None:
app.router.add_get("/", index_route)
app.router.add_get("/health", health_route)
app.router.add_get("/telegram-web-app.js", telegram_web_app_asset_route)
app.router.add_get("/telegram-widget.js", telegram_widget_asset_route)
app.router.add_get("/subscription_webapp.css", css_asset_route)
app.router.add_get("/subscription_webapp.js", js_asset_route)
app.router.add_post("/api/auth/token", auth_token_route)
app.router.add_post("/api/auth/email/request", email_auth_request_route)
app.router.add_post("/api/auth/email/verify", email_auth_verify_route)
app.router.add_get("/api/me", me_route)
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)
async def health_route(request: web.Request) -> web.Response:
return web.json_response({"ok": True})
async def css_asset_route(request: web.Request) -> web.Response:
return await _serve_template_asset(request, "subscription_webapp.css", "text/css")
async def telegram_web_app_asset_route(request: web.Request) -> web.Response:
if not TELEGRAM_WEB_APP_SDK_PATH.exists():
await refresh_telegram_web_app_sdk()
try:
response = await _serve_template_asset(
request,
"telegram-web-app.js",
"application/javascript",
)
except FileNotFoundError:
logger.exception(
"Telegram Web App SDK is unavailable at %s",
TELEGRAM_WEB_APP_SDK_PATH,
)
raise web.HTTPServiceUnavailable(text="telegram_web_app_sdk_unavailable")
response.headers["Cache-Control"] = "no-cache"
return response
async def telegram_widget_asset_route(request: web.Request) -> web.Response:
if not TELEGRAM_WIDGET_SDK_PATH.exists():
await refresh_telegram_login_widget_sdk()
try:
data = TELEGRAM_WIDGET_SDK_PATH.read_bytes()
except FileNotFoundError:
logger.exception(
"Telegram Login Widget SDK is unavailable at %s",
TELEGRAM_WIDGET_SDK_PATH,
)
raise web.HTTPServiceUnavailable(text="telegram_widget_sdk_unavailable")
data = _normalize_telegram_login_widget_sdk(data)
response = web.Response(body=data, content_type="application/javascript")
response.headers["Cache-Control"] = "no-cache"
return response
async def refresh_telegram_web_app_sdk() -> bool:
"""Best-effort refresh of the vendored Telegram Web App SDK."""
try:
timeout = ClientTimeout(total=30)
async with ClientSession(
timeout=timeout,
headers={
"User-Agent": "Mozilla/5.0",
"Accept": "application/javascript,text/javascript,*/*;q=0.8",
},
) as session:
async with session.get(TELEGRAM_WEB_APP_SDK_URL) as response:
if response.status != 200:
logger.warning(
"Telegram Web App SDK refresh returned HTTP %s; keeping the bundled copy.",
response.status,
)
return False
data = await response.read()
except Exception as exc:
logger.warning("Failed to refresh Telegram Web App SDK: %s", exc)
return False
try:
TELEGRAM_WEB_APP_SDK_PATH.parent.mkdir(parents=True, exist_ok=True)
existing_data = (
TELEGRAM_WEB_APP_SDK_PATH.read_bytes()
if TELEGRAM_WEB_APP_SDK_PATH.exists()
else None
)
if existing_data == data:
logger.info("Telegram Web App SDK is already up to date.")
return True
temp_path = TELEGRAM_WEB_APP_SDK_PATH.with_name(
f"{TELEGRAM_WEB_APP_SDK_PATH.name}.tmp"
)
temp_path.write_bytes(data)
temp_path.replace(TELEGRAM_WEB_APP_SDK_PATH)
logger.info(
"Telegram Web App SDK updated at %s (%d bytes).",
TELEGRAM_WEB_APP_SDK_PATH,
len(data),
)
return True
except Exception as exc:
logger.warning("Failed to store Telegram Web App SDK locally: %s", exc)
return False
async def refresh_telegram_login_widget_sdk() -> bool:
"""Best-effort refresh of the vendored Telegram Login Widget SDK."""
try:
timeout = ClientTimeout(total=30)
async with ClientSession(
timeout=timeout,
headers={
"User-Agent": "Mozilla/5.0",
"Accept": "application/javascript,text/javascript,*/*;q=0.8",
},
) as session:
async with session.get(TELEGRAM_WIDGET_SDK_URL) as response:
if response.status != 200:
logger.warning(
"Telegram Login Widget SDK refresh returned HTTP %s; keeping the bundled copy.",
response.status,
)
return False
data = await response.read()
except Exception as exc:
logger.warning("Failed to refresh Telegram Login Widget SDK: %s", exc)
return False
try:
data = _normalize_telegram_login_widget_sdk(data)
TELEGRAM_WIDGET_SDK_PATH.parent.mkdir(parents=True, exist_ok=True)
existing_data = (
TELEGRAM_WIDGET_SDK_PATH.read_bytes()
if TELEGRAM_WIDGET_SDK_PATH.exists()
else None
)
if existing_data == data:
logger.info("Telegram Login Widget SDK is already up to date.")
return True
temp_path = TELEGRAM_WIDGET_SDK_PATH.with_name(
f"{TELEGRAM_WIDGET_SDK_PATH.name}.tmp"
)
temp_path.write_bytes(data)
temp_path.replace(TELEGRAM_WIDGET_SDK_PATH)
logger.info(
"Telegram Login Widget SDK updated at %s (%d bytes).",
TELEGRAM_WIDGET_SDK_PATH,
len(data),
)
return True
except Exception as exc:
logger.warning("Failed to store Telegram Login Widget SDK locally: %s", exc)
return False
def _normalize_telegram_login_widget_sdk(data: bytes) -> bytes:
# Keep the vendored widget pointing to Telegram's OAuth host instead of the local origin.
text = data.decode("utf-8")
normalized = text.replace(
_UNPATCHED_WIDGET_ORIGIN_SNIPPET,
_PATCHED_WIDGET_ORIGIN_SNIPPET,
1,
)
return normalized.encode("utf-8")
async def js_asset_route(request: web.Request) -> web.Response:
return await _serve_template_asset(
request,
"subscription_webapp.js",
"application/javascript",
strip_dev_mock=True,
)
async def index_route(request: web.Request) -> web.Response:
settings: Settings = request.app["settings"]
if not settings.WEBAPP_ENABLED:
raise web.HTTPNotFound(text="webapp_disabled")
html = TEMPLATE_PATH.read_text(encoding="utf-8")
config = {
"title": settings.WEBAPP_TITLE,
"primaryColor": settings.WEBAPP_PRIMARY_COLOR,
"logoUrl": settings.WEBAPP_LOGO_URL or "",
"apiBase": "/api",
"telegramLoginBotUsername": request.app.get("bot_username") or "",
"supportUrl": settings.SUPPORT_LINK or "",
"privacyPolicyUrl": settings.PRIVACY_POLICY_URL or "",
"userAgreementUrl": settings.USER_AGREEMENT_URL or "",
"currency": settings.DEFAULT_CURRENCY_SYMBOL or "RUB",
"language": _normalize_language(settings.DEFAULT_LANGUAGE),
"emailAuthEnabled": settings.email_auth_configured,
}
html = _strip_marked_block(html, DEV_MOCK_START_MARKER, DEV_MOCK_END_MARKER)
html = html.replace(
WEBAPP_CONFIG_PLACEHOLDER,
(
"<script>window.__WEBAPP_CONFIG__="
+ json.dumps(config, ensure_ascii=False, separators=(",", ":"))
+ ";</script>"
),
)
return web.Response(text=html, content_type="text/html", charset="utf-8")
async def _serve_template_asset(
request: web.Request,
filename: str,
content_type: str,
*,
strip_dev_mock: bool = False,
) -> web.Response:
settings: Settings = request.app["settings"]
if not settings.WEBAPP_ENABLED:
raise web.HTTPNotFound(text="webapp_disabled")
path = ASSET_DIR / filename
text = path.read_text(encoding="utf-8")
if strip_dev_mock:
text = _strip_marked_block(
text,
"/* WEBAPP_DEV_MOCK_START */",
"/* WEBAPP_DEV_MOCK_END */",
)
return web.Response(text=text, content_type=content_type, charset="utf-8")
def _strip_marked_block(html: str, start_marker: str, end_marker: str) -> str:
start = html.find(start_marker)
if start == -1:
return html
end = html.find(end_marker, start)
if end == -1:
return html[:start]
return html[:start] + html[end + len(end_marker):]
async def auth_token_route(request: web.Request) -> web.Response:
settings: Settings = request.app["settings"]
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(
init_data,
settings.BOT_TOKEN,
max_age_seconds=settings.WEBAPP_AUTH_MAX_AGE_SECONDS,
)
elif auth_data is not None:
telegram_user = validate_telegram_login_widget_data(
auth_data,
settings.BOT_TOKEN,
max_age_seconds=settings.WEBAPP_AUTH_MAX_AGE_SECONDS,
)
if not telegram_user:
return _json_error(401, "invalid_auth", "Invalid Telegram auth data")
async_session_factory: sessionmaker = request.app["async_session_factory"]
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,
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:
await session.rollback()
logger.error("WebApp auth failed: %s", exc, exc_info=True)
return _json_error(500, "auth_failed", "Auth failed")
token = create_webapp_session_token(settings, int(authenticated_user_id))
return web.json_response({"ok": True, "token": token})
async def email_auth_request_route(request: web.Request) -> web.Response:
settings: Settings = request.app["settings"]
payload = await _read_json(request)
email = normalize_email(str(payload.get("email") or ""))
lang = _normalize_language(str(payload.get("language") or settings.DEFAULT_LANGUAGE))
return await _request_email_code(
request,
email=email,
purpose="login",
language_code=lang,
target_user_id=None,
)
async def email_auth_verify_route(request: web.Request) -> web.Response:
settings: Settings = request.app["settings"]
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"]
async with async_session_factory() as session:
try:
verify_result = await email_service.verify_code(
session,
email=email,
purpose="login",
code=code,
target_user_id=None,
)
if not verify_result.ok:
await session.commit()
status = 429 if verify_result.error == "rate_limited" else 400
return web.json_response(
{
"ok": False,
"error": verify_result.error or "invalid_code",
"retry_after": verify_result.retry_after,
"message": "Invalid code",
},
status=status,
)
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")
await session.commit()
except Exception as exc:
await session.rollback()
logger.error("Email WebApp auth failed: %s", exc, exc_info=True)
return _json_error(500, "auth_failed", "Auth failed")
token = create_webapp_session_token(settings, int(db_user.user_id))
return web.json_response(
{
"ok": True,
"token": token,
"user_id": int(db_user.user_id),
"telegram_id": _telegram_id_for_user(db_user),
}
)
async def account_email_request_route(request: web.Request) -> web.Response:
user_id = _require_user_id(request)
settings: Settings = request.app["settings"]
payload = await _read_json(request)
email = normalize_email(str(payload.get("email") or ""))
async_session_factory: sessionmaker = request.app["async_session_factory"]
async with async_session_factory() as session:
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")
if db_user.email == email and db_user.email_verified_at:
return web.json_response({"ok": True, "already_linked": True})
lang = _normalize_language(db_user.language_code or settings.DEFAULT_LANGUAGE)
return await _request_email_code(
request,
email=email,
purpose="link_email",
language_code=lang,
target_user_id=user_id,
)
async def account_email_verify_route(request: web.Request) -> web.Response:
user_id = _require_user_id(request)
payload = await _read_json(request)
email = normalize_email(str(payload.get("email") or ""))
code = str(payload.get("code") or "")
email_service: EmailAuthService = request.app["email_auth_service"]
settings: Settings = request.app["settings"]
async_session_factory: sessionmaker = request.app["async_session_factory"]
async with async_session_factory() as session:
try:
verify_result = await email_service.verify_code(
session,
email=email,
purpose="link_email",
code=code,
target_user_id=user_id,
)
if not verify_result.ok:
await session.commit()
status = 429 if verify_result.error == "rate_limited" else 400
return web.json_response(
{
"ok": False,
"error": verify_result.error or "invalid_code",
"retry_after": verify_result.retry_after,
"message": "Invalid code",
},
status=status,
)
current_user = await user_dal.get_user_by_id(session, user_id)
if not current_user or current_user.is_banned:
await session.rollback()
return _json_error(403, "access_denied", "Access denied")
existing_email_user = await user_dal.get_user_by_email(session, email)
if existing_email_user and existing_email_user.user_id != current_user.user_id:
current_user = await user_dal.merge_users(
session,
source_user_id=existing_email_user.user_id,
target_user_id=current_user.user_id,
)
current_user.email = email
current_user.email_verified_at = datetime.now(timezone.utc)
await _sync_panel_identity_for_user(request, current_user)
await session.commit()
except UserMergeConflictError as exc:
await session.rollback()
return _json_error(409, "account_merge_conflict", str(exc))
except Exception as exc:
await session.rollback()
logger.error("Email account link failed: %s", exc, exc_info=True)
return _json_error(500, "link_failed", "Link failed")
token = create_webapp_session_token(settings, int(current_user.user_id))
return web.json_response({"ok": True, "token": token})
async def account_telegram_link_route(request: web.Request) -> web.Response:
user_id = _require_user_id(request)
settings: Settings = request.app["settings"]
payload = await _read_json(request)
init_data = str(payload.get("init_data") or "")
auth_data = payload.get("auth_data")
telegram_user = None
if init_data:
telegram_user = validate_telegram_webapp_init_data(
init_data,
settings.BOT_TOKEN,
max_age_seconds=settings.WEBAPP_AUTH_MAX_AGE_SECONDS,
)
elif auth_data is not None:
telegram_user = validate_telegram_login_widget_data(
auth_data,
settings.BOT_TOKEN,
max_age_seconds=settings.WEBAPP_AUTH_MAX_AGE_SECONDS,
)
if not telegram_user:
return _json_error(401, "invalid_auth", "Invalid Telegram auth data")
async_session_factory: sessionmaker = request.app["async_session_factory"]
async with async_session_factory() as session:
try:
db_user = await _link_telegram_to_user(
request,
session,
current_user_id=user_id,
telegram_user=telegram_user,
settings=settings,
)
if db_user.is_banned:
await session.rollback()
return _json_error(403, "banned", "Access denied")
await session.commit()
except UserMergeConflictError as exc:
await session.rollback()
return _json_error(409, "account_merge_conflict", str(exc))
except Exception as exc:
await session.rollback()
logger.error("Telegram account link failed: %s", exc, exc_info=True)
return _json_error(500, "link_failed", "Link failed")
token = create_webapp_session_token(settings, int(db_user.user_id))
return web.json_response(
{
"ok": True,
"token": token,
"user_id": int(db_user.user_id),
"telegram_id": _telegram_id_for_user(db_user),
}
)
async def me_route(request: web.Request) -> web.Response:
user_id = _require_user_id(request)
data = await _build_user_payload(request, user_id)
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.commit()
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)
method = str(payload.get("method") or "").strip().lower()
try:
months = int(float(payload.get("months")))
except (TypeError, ValueError):
return _json_error(400, "invalid_plan", "Invalid subscription period")
settings: Settings = request.app["settings"]
price = settings.subscription_options.get(months)
stars_price = settings.stars_subscription_options.get(months)
if price is None and method != "stars":
return _json_error(400, "invalid_plan", "Subscription period is not available")
if method == "stars" and (stars_price is None or int(stars_price) <= 0):
return _json_error(400, "invalid_plan", "Stars price is not configured")
async_session_factory: sessionmaker = request.app["async_session_factory"]
async with async_session_factory() as session:
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")
lang = db_user.language_code or settings.DEFAULT_LANGUAGE
return await _create_subscription_payment(
request=request,
session=session,
user_id=user_id,
method=method,
months=months,
price=float(price or 0),
stars_price=stars_price,
lang=lang,
)
async def payment_status_route(request: web.Request) -> web.Response:
user_id = _require_user_id(request)
try:
payment_id = int(request.match_info["payment_id"])
except (TypeError, ValueError):
return _json_error(400, "invalid_payment", "Invalid payment id")
async_session_factory: sessionmaker = request.app["async_session_factory"]
async with async_session_factory() as session:
payment = await payment_dal.get_payment_by_db_id(session, payment_id)
if not payment or payment.user_id != user_id:
return _json_error(404, "not_found", "Payment not found")
return web.json_response(
{
"ok": True,
"payment_id": payment.payment_id,
"status": payment.status,
"paid": payment.status == "succeeded",
}
)
async def _read_json(request: web.Request) -> Dict[str, Any]:
try:
data = await request.json()
return data if isinstance(data, dict) else {}
except Exception:
return {}
def _json_error(status: int, code: str, message: str) -> web.Response:
return web.json_response(
{"ok": False, "error": code, "message": message},
status=status,
)
def _require_user_id(request: web.Request) -> int:
settings: Settings = request.app["settings"]
header = request.headers.get("Authorization", "")
prefix = "Bearer "
token = header[len(prefix):].strip() if header.startswith(prefix) else ""
user_id = verify_webapp_session_token(settings, token)
if not user_id:
raise web.HTTPUnauthorized(
text=json.dumps({"ok": False, "error": "unauthorized"}),
content_type="application/json",
)
return user_id
async def _request_email_code(
request: web.Request,
*,
email: str,
purpose: str,
language_code: str,
target_user_id: Optional[int],
) -> web.Response:
email_service: EmailAuthService = request.app["email_auth_service"]
async_session_factory: sessionmaker = request.app["async_session_factory"]
async with async_session_factory() as session:
try:
result = await email_service.request_code(
session,
email=email,
purpose=purpose,
language_code=language_code,
target_user_id=target_user_id,
)
if not result.ok:
await session.rollback()
status = 429 if result.error == "rate_limited" else 400
if result.error == "email_auth_not_configured":
status = 503
return web.json_response(
{
"ok": False,
"error": result.error,
"retry_after": result.retry_after,
},
status=status,
)
await session.commit()
return web.json_response({"ok": True})
except Exception as exc:
await session.rollback()
logger.error("Failed to send email verification code: %s", exc, exc_info=True)
return _json_error(502, "email_send_failed", "Failed to send email")
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)
return None
def _panel_description_for_user(user: User) -> str:
lines = [
user.email or "",
user.username or "",
user.first_name or "",
user.last_name or "",
]
return "\n".join(line for line in lines if line).strip()
async def _sync_panel_identity_for_user(request: web.Request, user: User) -> None:
if not user.panel_user_uuid:
return
subscription_service: SubscriptionService = request.app.get("subscription_service")
if not subscription_service or not subscription_service.panel_service:
return
payload: Dict[str, Any] = {
"description": _panel_description_for_user(user),
}
telegram_id = _telegram_id_for_user(user)
if telegram_id:
payload["telegramId"] = telegram_id
if user.email:
payload["email"] = user.email
try:
await subscription_service.panel_service.update_user_details_on_panel(
user.panel_user_uuid,
payload,
log_response=False,
)
except Exception as exc:
logger.warning(
"Failed to sync linked identities to panel for user %s: %s",
user.user_id,
exc,
)
def _apply_telegram_profile_to_user(
user: User,
telegram_user: Dict[str, Any],
settings: Settings,
) -> None:
language_code = telegram_user.get("language_code") or user.language_code or settings.DEFAULT_LANGUAGE
if language_code not in {"ru", "en"}:
language_code = user.language_code or settings.DEFAULT_LANGUAGE
user.telegram_id = int(telegram_user["id"])
user.username = sanitize_username(telegram_user.get("username"))
user.first_name = sanitize_display_name(telegram_user.get("first_name"))
user.last_name = sanitize_display_name(telegram_user.get("last_name"))
user.language_code = language_code
async def _link_telegram_to_user(
request: web.Request,
session: AsyncSession,
*,
current_user_id: int,
telegram_user: Dict[str, Any],
settings: Settings,
) -> User:
telegram_id = int(telegram_user["id"])
current_user = await user_dal.get_user_by_id(session, current_user_id)
if not current_user:
raise ValueError("Current user not found.")
existing_telegram_user = await user_dal.get_user_by_telegram_id(session, telegram_id)
if not existing_telegram_user:
existing_telegram_user = await user_dal.get_user_by_id(session, telegram_id)
if existing_telegram_user and existing_telegram_user.user_id != current_user.user_id:
if (
current_user.email
and existing_telegram_user.email
and current_user.email != existing_telegram_user.email
):
raise UserMergeConflictError(
"Telegram account is already linked to a different email."
)
merged_user = await user_dal.merge_users(
session,
source_user_id=current_user.user_id,
target_user_id=existing_telegram_user.user_id,
)
_apply_telegram_profile_to_user(merged_user, telegram_user, settings)
await session.flush()
await _sync_panel_identity_for_user(request, merged_user)
return merged_user
if not existing_telegram_user and int(current_user.user_id) < 0:
language_code = telegram_user.get("language_code") or current_user.language_code or settings.DEFAULT_LANGUAGE
if language_code not in {"ru", "en"}:
language_code = current_user.language_code or settings.DEFAULT_LANGUAGE
target_user, _ = await user_dal.create_user(
session,
{
"user_id": telegram_id,
"telegram_id": telegram_id,
"username": sanitize_username(telegram_user.get("username")),
"first_name": sanitize_display_name(telegram_user.get("first_name")),
"last_name": sanitize_display_name(telegram_user.get("last_name")),
"language_code": language_code,
"registration_date": current_user.registration_date or datetime.now(timezone.utc),
},
)
target_user.referral_code = None
await session.flush()
merged_user = await user_dal.merge_users(
session,
source_user_id=current_user.user_id,
target_user_id=target_user.user_id,
)
_apply_telegram_profile_to_user(merged_user, telegram_user, settings)
await session.flush()
await _sync_panel_identity_for_user(request, merged_user)
return merged_user
if current_user.telegram_id and int(current_user.telegram_id) != telegram_id:
raise UserMergeConflictError("Current account is already linked to Telegram.")
_apply_telegram_profile_to_user(current_user, telegram_user, settings)
await session.flush()
await _sync_panel_identity_for_user(request, current_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
if language_code not in {"ru", "en"}:
language_code = settings.DEFAULT_LANGUAGE
update_data = {
"telegram_id": user_id,
"username": sanitize_username(telegram_user.get("username")),
"first_name": sanitize_display_name(telegram_user.get("first_name")),
"last_name": sanitize_display_name(telegram_user.get("last_name")),
"language_code": language_code,
}
db_user = await user_dal.get_user_by_telegram_id(session, user_id)
if not db_user:
db_user = await user_dal.get_user_by_id(session, user_id)
if not db_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 = {
key: value
for key, value in update_data.items()
if getattr(db_user, key) != value
}
if changed:
db_user = await user_dal.update_user(session, db_user.user_id, changed) or db_user
return db_user
async def _build_user_payload(request: web.Request, user_id: int) -> Dict[str, Any]:
settings: Settings = request.app["settings"]
async_session_factory: sessionmaker = request.app["async_session_factory"]
subscription_service: SubscriptionService = request.app["subscription_service"]
async with async_session_factory() as session:
db_user = await user_dal.get_user_by_id(session, user_id)
if not db_user or db_user.is_banned:
raise web.HTTPForbidden(
text=json.dumps({"ok": False, "error": "access_denied"}),
content_type="application/json",
)
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,
db_user.panel_user_uuid,
) if db_user.panel_user_uuid else None
try:
await session.commit()
except Exception:
await session.rollback()
lang = _normalize_language(db_user.language_code or settings.DEFAULT_LANGUAGE)
return {
"user": {
"id": user_id,
"username": db_user.username,
"email": db_user.email,
"email_verified": bool(db_user.email_verified_at),
"telegram_id": db_user.telegram_id,
"telegram_linked": bool(_telegram_id_for_user(db_user)),
"first_name": db_user.first_name,
"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": {
"support_url": settings.SUPPORT_LINK,
"traffic_mode": bool(settings.traffic_sale_mode),
"email_auth_enabled": settings.email_auth_configured,
},
}
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],
lang: str,
) -> Dict[str, Any]:
if not active:
return {
"active": False,
"status": "INACTIVE",
"remaining_text": _format_remaining(0, lang),
"days_left": 0,
"config_link": None,
"connect_url": None,
}
end_date = active.get("end_date")
if end_date and end_date.tzinfo is None:
end_date = end_date.replace(tzinfo=timezone.utc)
seconds_left = 0
if end_date:
seconds_left = max(
0,
int((end_date - datetime.now(timezone.utc)).total_seconds()),
)
return {
"active": seconds_left > 0,
"status": active.get("status_from_panel") or "UNKNOWN",
"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 "N/A",
"days_left": seconds_left // 86400,
"remaining_text": _format_remaining(seconds_left, lang),
"config_link": active.get("config_link"),
"connect_url": active.get("connect_button_url") or active.get("config_link"),
"traffic_limit": _format_bytes(active.get("traffic_limit_bytes")),
"traffic_used": _format_bytes(active.get("traffic_used_bytes")),
"traffic_limit_bytes": _coerce_int_or_none(active.get("traffic_limit_bytes")),
"traffic_used_bytes": _coerce_int_or_none(active.get("traffic_used_bytes")),
"auto_renew_enabled": bool(getattr(local_sub, "auto_renew_enabled", False)),
"provider": getattr(local_sub, "provider", None),
}
def _serialize_plans(settings: Settings, lang: str) -> List[Dict[str, Any]]:
plans: List[Dict[str, Any]] = []
for months, price in sorted(settings.subscription_options.items()):
plan = {
"months": int(months),
"price": float(price),
"currency": settings.DEFAULT_CURRENCY_SYMBOL or "RUB",
"title": _format_months_title(int(months), lang),
}
stars_price = settings.stars_subscription_options.get(months)
if stars_price is not None and int(stars_price) > 0:
plan["stars_price"] = int(stars_price)
plans.append(plan)
return plans
def _serialize_payment_methods(
settings: Settings,
app: web.Application,
) -> List[Dict[str, Any]]:
labels = {
"severpay": "SeverPay",
"freekassa": "FreeKassa / СБП",
"platega": "Platega",
"yookassa": "Банковская карта",
"stars": "Telegram Stars",
"cryptopay": "CryptoPay",
}
methods: List[Dict[str, Any]] = []
for method in settings.payment_methods_order:
method = method.lower()
if method == "severpay" and _service_configured(app, "severpay_service"):
methods.append({"id": method, "name": labels[method]})
elif method == "freekassa" and _service_configured(app, "freekassa_service"):
methods.append({"id": method, "name": labels[method]})
elif method == "platega" and _service_configured(app, "platega_service"):
methods.append({"id": method, "name": labels[method]})
elif method == "yookassa" and _service_configured(app, "yookassa_service"):
methods.append({"id": method, "name": labels[method]})
elif method == "stars" and settings.STARS_ENABLED:
methods.append({"id": method, "name": labels[method]})
elif method == "cryptopay" and _service_configured(app, "cryptopay_service"):
methods.append({"id": method, "name": labels[method]})
return methods
def _service_configured(app: web.Application, key: str) -> bool:
service = app.get(key)
return bool(service and getattr(service, "configured", False))
async def _create_subscription_payment(
*,
request: web.Request,
session: AsyncSession,
user_id: int,
method: str,
months: int,
price: float,
stars_price: Optional[int],
lang: str,
) -> web.Response:
settings: Settings = request.app["settings"]
description = _payment_description(months, lang)
if method == "yookassa":
return await _create_yookassa_payment(
request, session, user_id, months, price, description
)
if method == "freekassa":
return await _create_freekassa_payment(
request, session, user_id, months, price, description
)
if method == "platega":
return await _create_platega_payment(
request, session, user_id, months, price, description
)
if method == "severpay":
return await _create_severpay_payment(
request, session, user_id, months, price, description
)
if method == "cryptopay":
service: CryptoPayService = request.app["cryptopay_service"]
if not service or not service.configured:
return _json_error(400, "payment_unavailable", "Payment method unavailable")
url = await service.create_invoice(
session=session,
user_id=user_id,
months=months,
amount=price,
description=description,
sale_mode="subscription",
)
if not url:
return _json_error(502, "payment_failed", "Failed to create payment")
return web.json_response(
{"ok": True, "action": "open_link", "payment_url": url, "payment_id": None}
)
if method == "stars":
if not settings.STARS_ENABLED or stars_price is None:
return _json_error(400, "payment_unavailable", "Payment method unavailable")
return await _create_stars_payment(
request, session, user_id, months, int(stars_price), description
)
return _json_error(400, "payment_unavailable", "Payment method unavailable")
async def _create_base_payment_record(
session: AsyncSession,
*,
user_id: int,
amount: float,
currency: str,
status: str,
description: str,
months: int,
provider: str,
) -> Payment:
payment = await payment_dal.create_payment_record(
session,
{
"user_id": user_id,
"amount": amount,
"currency": currency,
"status": status,
"description": description,
"subscription_duration_months": months,
"provider": provider,
},
)
await session.commit()
return payment
async def _create_yookassa_payment(
request: web.Request,
session: AsyncSession,
user_id: int,
months: int,
price: float,
description: str,
) -> web.Response:
settings: Settings = request.app["settings"]
service: YooKassaService = request.app["yookassa_service"]
if not service or not service.configured:
return _json_error(400, "payment_unavailable", "Payment method unavailable")
try:
payment = await _create_base_payment_record(
session,
user_id=user_id,
amount=price,
currency="RUB",
status="pending_yookassa",
description=description,
months=months,
provider="yookassa",
)
response = await service.create_payment(
amount=price,
currency="RUB",
description=description,
metadata={
"user_id": str(user_id),
"subscription_months": str(months),
"payment_db_id": str(payment.payment_id),
"sale_mode": "subscription",
"source": "webapp",
},
receipt_email=settings.YOOKASSA_DEFAULT_RECEIPT_EMAIL,
save_payment_method=bool(
settings.yookassa_autopayments_active
and settings.YOOKASSA_AUTOPAYMENTS_REQUIRE_CARD_BINDING
),
)
payment_url = response.get("confirmation_url") if response else None
if not payment_url:
await payment_dal.update_payment_status_by_db_id(
session, payment.payment_id, "failed_creation"
)
await session.commit()
return _json_error(502, "payment_failed", "Failed to create payment")
await payment_dal.update_payment_status_by_db_id(
session,
payment.payment_id,
response.get("status", "pending"),
yk_payment_id=response.get("id"),
)
await session.commit()
return web.json_response(
{
"ok": True,
"action": "open_link",
"payment_url": payment_url,
"payment_id": payment.payment_id,
}
)
except Exception as exc:
await session.rollback()
logger.error("YooKassa WebApp payment failed: %s", exc, exc_info=True)
return _json_error(502, "payment_failed", "Failed to create payment")
async def _create_freekassa_payment(
request: web.Request,
session: AsyncSession,
user_id: int,
months: int,
price: float,
description: str,
) -> web.Response:
settings: Settings = request.app["settings"]
service: FreeKassaService = request.app["freekassa_service"]
if not service or not service.configured or not service.payment_method_id:
return _json_error(400, "payment_unavailable", "Payment method unavailable")
try:
payment = await _create_base_payment_record(
session,
user_id=user_id,
amount=price,
currency=service.default_currency,
status="pending_freekassa",
description=description,
months=months,
provider="freekassa",
)
success, response_data = await service.create_order(
payment_db_id=payment.payment_id,
user_id=user_id,
months=months,
amount=price,
currency=service.default_currency,
payment_method_id=service.payment_method_id,
ip_address=service.server_ip,
extra_params={"us_method": service.payment_method_id},
)
payment_url = response_data.get("location") if success else None
provider_id = response_data.get("orderHash") or response_data.get("orderId")
if provider_id:
await payment_dal.update_provider_payment_and_status(
session, payment.payment_id, str(provider_id), payment.status
)
await session.commit()
if not payment_url:
await payment_dal.update_payment_status_by_db_id(
session, payment.payment_id, "failed_creation"
)
await session.commit()
return _json_error(502, "payment_failed", "Failed to create payment")
return web.json_response(
{
"ok": True,
"action": "open_link",
"payment_url": payment_url,
"payment_id": payment.payment_id,
}
)
except Exception as exc:
await session.rollback()
logger.error("FreeKassa WebApp payment failed: %s", exc, exc_info=True)
return _json_error(502, "payment_failed", "Failed to create payment")
async def _create_platega_payment(
request: web.Request,
session: AsyncSession,
user_id: int,
months: int,
price: float,
description: str,
) -> web.Response:
settings: Settings = request.app["settings"]
service: PlategaService = request.app["platega_service"]
if not service or not service.configured:
return _json_error(400, "payment_unavailable", "Payment method unavailable")
try:
payment = await _create_base_payment_record(
session,
user_id=user_id,
amount=price,
currency=settings.DEFAULT_CURRENCY_SYMBOL or "RUB",
status="pending_platega",
description=description,
months=months,
provider="platega",
)
payload = json.dumps(
{
"payment_db_id": payment.payment_id,
"user_id": user_id,
"months": months,
"sale_mode": "subscription",
"source": "webapp",
}
)
success, response_data = await service.create_transaction(
payment_db_id=payment.payment_id,
user_id=user_id,
months=months,
amount=price,
currency=settings.DEFAULT_CURRENCY_SYMBOL or "RUB",
description=description,
payload=payload,
)
payment_url = (
response_data.get("redirect")
or response_data.get("url")
or response_data.get("paymentUrl")
) if success else None
provider_id = response_data.get("transactionId") or response_data.get("id")
if provider_id:
await payment_dal.update_provider_payment_and_status(
session,
payment.payment_id,
str(provider_id),
str(response_data.get("status", payment.status)),
)
await session.commit()
if not payment_url:
await payment_dal.update_payment_status_by_db_id(
session, payment.payment_id, "failed_creation"
)
await session.commit()
return _json_error(502, "payment_failed", "Failed to create payment")
return web.json_response(
{
"ok": True,
"action": "open_link",
"payment_url": payment_url,
"payment_id": payment.payment_id,
}
)
except Exception as exc:
await session.rollback()
logger.error("Platega WebApp payment failed: %s", exc, exc_info=True)
return _json_error(502, "payment_failed", "Failed to create payment")
async def _create_severpay_payment(
request: web.Request,
session: AsyncSession,
user_id: int,
months: int,
price: float,
description: str,
) -> web.Response:
settings: Settings = request.app["settings"]
service: SeverPayService = request.app["severpay_service"]
if not service or not service.configured:
return _json_error(400, "payment_unavailable", "Payment method unavailable")
try:
payment = await _create_base_payment_record(
session,
user_id=user_id,
amount=price,
currency=settings.DEFAULT_CURRENCY_SYMBOL or "RUB",
status="pending_severpay",
description=description,
months=months,
provider="severpay",
)
success, response_data = await service.create_payment(
payment_db_id=payment.payment_id,
user_id=user_id,
months=months,
amount=price,
currency=settings.DEFAULT_CURRENCY_SYMBOL or "RUB",
description=description,
)
payment_url = (
response_data.get("url")
or response_data.get("payment_url")
or response_data.get("paymentUrl")
) if success else None
provider_id = response_data.get("id") or response_data.get("uid")
if provider_id:
await payment_dal.update_provider_payment_and_status(
session, payment.payment_id, str(provider_id), payment.status
)
await session.commit()
if not payment_url:
await payment_dal.update_payment_status_by_db_id(
session, payment.payment_id, "failed_creation"
)
await session.commit()
return _json_error(502, "payment_failed", "Failed to create payment")
return web.json_response(
{
"ok": True,
"action": "open_link",
"payment_url": payment_url,
"payment_id": payment.payment_id,
}
)
except Exception as exc:
await session.rollback()
logger.error("SeverPay WebApp payment failed: %s", exc, exc_info=True)
return _json_error(502, "payment_failed", "Failed to create payment")
async def _create_stars_payment(
request: web.Request,
session: AsyncSession,
user_id: int,
months: int,
stars_price: int,
description: str,
) -> web.Response:
bot: Bot = request.app["bot"]
try:
payment = await _create_base_payment_record(
session,
user_id=user_id,
amount=float(stars_price),
currency="XTR",
status="pending_stars",
description=description,
months=months,
provider="telegram_stars",
)
payload = f"{payment.payment_id}:{months}:subscription"
prices = [LabeledPrice(label=description, amount=stars_price)]
create_invoice_link = getattr(bot, "create_invoice_link", None)
if callable(create_invoice_link):
invoice_url = await create_invoice_link(
title=description,
description=description,
payload=payload,
provider_token="",
currency="XTR",
prices=prices,
)
return web.json_response(
{
"ok": True,
"action": "open_invoice",
"payment_url": invoice_url,
"payment_id": payment.payment_id,
}
)
await bot.send_invoice(
chat_id=user_id,
title=description,
description=description,
payload=payload,
provider_token="",
currency="XTR",
prices=prices,
)
return web.json_response(
{
"ok": True,
"action": "invoice_sent",
"payment_id": payment.payment_id,
}
)
except Exception as exc:
await session.rollback()
logger.error("Stars WebApp payment failed: %s", exc, exc_info=True)
return _json_error(502, "payment_failed", "Failed to create invoice")
def _normalize_language(lang: Optional[str]) -> str:
value = (lang or "ru").split("-")[0].lower()
return value if value in {"ru", "en"} else "ru"
def _format_remaining(seconds: int, lang: str) -> str:
if seconds <= 0:
if lang == "en":
return "Subscription inactive"
return "Подписка не активна"
days, rem = divmod(seconds, 86400)
hours, rem = divmod(rem, 3600)
minutes = rem // 60
if lang == "en":
if days > 0:
return f"{days} d. {hours} h."
if hours > 0:
return f"{hours} h. {minutes} min."
return f"{max(1, minutes)} min."
if days > 0:
return f"{days} д. {hours} ч."
if hours > 0:
return f"{hours} ч. {minutes} мин."
return f"{max(1, minutes)} мин."
def _coerce_int_or_none(value: Optional[Any]) -> Optional[int]:
if value is None:
return None
try:
return int(value)
except (TypeError, ValueError):
return None
def _format_bytes(value: Optional[Any]) -> str:
if value is None:
return "N/A"
try:
size = float(value)
except (TypeError, ValueError):
return str(value)
if size <= 0:
return "∞"
units = ["B", "KB", "MB", "GB", "TB"]
index = 0
while size >= 1024 and index < len(units) - 1:
size /= 1024
index += 1
return f"{size:.2f} {units[index]}"
def _format_months_title(months: int, lang: str) -> str:
if lang == "en":
if months == 1:
return "1 month"
return f"{months} months"
if months == 1:
return "1 месяц"
if 2 <= months <= 4:
return f"{months} месяца"
return f"{months} месяцев"
def _payment_description(months: int, lang: str) -> str:
if lang == "en":
return f"Subscription for {_format_months_title(months, lang)}"
return f"Подписка на {_format_months_title(months, lang)}"