diff --git a/app/webapi/routes/miniapp.py b/app/webapi/routes/miniapp.py index b1043837..fad374cb 100644 --- a/app/webapi/routes/miniapp.py +++ b/app/webapi/routes/miniapp.py @@ -2,9 +2,11 @@ from __future__ import annotations import logging import re +from decimal import Decimal, InvalidOperation, ROUND_HALF_UP from datetime import datetime, timedelta from typing import Any, Dict, List, Optional, Tuple, Union +from aiogram import Bot from fastapi import APIRouter, Depends, HTTPException, status from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession @@ -23,6 +25,23 @@ from app.database.crud.promo_offer_template import get_promo_offer_template_by_i from app.database.crud.server_squad import get_server_squad_by_uuid from app.database.crud.transaction import get_user_total_spent_kopeks from app.database.crud.user import get_user_by_telegram_id +from app.database.crud.cryptobot import ( + get_cryptobot_payment_by_id, + get_cryptobot_payment_by_invoice_id, +) +from app.database.crud.mulenpay import ( + get_mulenpay_payment_by_local_id, + get_mulenpay_payment_by_mulen_id, + get_mulenpay_payment_by_uuid, +) +from app.database.crud.pal24 import ( + get_pal24_payment_by_bill_id, + get_pal24_payment_by_id, +) +from app.database.crud.yookassa import ( + get_yookassa_payment_by_id, + get_yookassa_payment_by_local_id, +) from app.database.models import ( PromoGroup, PromoOfferTemplate, @@ -38,9 +57,12 @@ from app.services.remnawave_service import ( RemnaWaveConfigurationError, RemnaWaveService, ) +from app.services.payment_service import PaymentService from app.services.promo_offer_service import promo_offer_service from app.services.promocode_service import PromoCodeService from app.services.subscription_service import SubscriptionService +from app.services.tribute_service import TributeService +from app.utils.currency_converter import currency_converter from app.utils.subscription_utils import get_happ_cryptolink_redirect_link from app.utils.telegram_webapp import ( TelegramWebAppAuthError, @@ -61,6 +83,13 @@ from ..schemas.miniapp import ( MiniAppFaq, MiniAppFaqItem, MiniAppLegalDocuments, + MiniAppPaymentCreateRequest, + MiniAppPaymentCreateResponse, + MiniAppPaymentMethod, + MiniAppPaymentMethodsRequest, + MiniAppPaymentMethodsResponse, + MiniAppPaymentStatusRequest, + MiniAppPaymentStatusResponse, MiniAppPromoCode, MiniAppPromoCodeActivationRequest, MiniAppPromoCodeActivationResponse, @@ -113,6 +142,629 @@ def _format_limit_label(limit: Optional[int]) -> str: return f"{limit} GB" +async def _resolve_user_from_init_data( + db: AsyncSession, + init_data: str, +) -> Tuple[User, Dict[str, Any]]: + if not init_data: + raise HTTPException( + status.HTTP_401_UNAUTHORIZED, + detail="Missing initData", + ) + + try: + webapp_data = parse_webapp_init_data(init_data, settings.BOT_TOKEN) + except TelegramWebAppAuthError as error: + raise HTTPException( + status.HTTP_401_UNAUTHORIZED, + detail=str(error), + ) from error + + telegram_user = webapp_data.get("user") + if not isinstance(telegram_user, dict) or "id" not in telegram_user: + raise HTTPException( + status.HTTP_400_BAD_REQUEST, + detail="Invalid Telegram user payload", + ) + + try: + telegram_id = int(telegram_user["id"]) + except (TypeError, ValueError): + raise HTTPException( + status.HTTP_400_BAD_REQUEST, + detail="Invalid Telegram user identifier", + ) from None + + user = await get_user_by_telegram_id(db, telegram_id) + if not user: + raise HTTPException( + status.HTTP_404_NOT_FOUND, + detail="User not found", + ) + + return user, webapp_data + + +def _normalize_amount_kopeks( + amount_rubles: Optional[float], + amount_kopeks: Optional[int], +) -> Optional[int]: + if amount_kopeks is not None: + try: + normalized = int(amount_kopeks) + except (TypeError, ValueError): + return None + return normalized if normalized >= 0 else None + + if amount_rubles is None: + return None + + try: + decimal_amount = Decimal(str(amount_rubles)).quantize( + Decimal("0.01"), rounding=ROUND_HALF_UP + ) + except (InvalidOperation, ValueError): + return None + + normalized = int((decimal_amount * 100).to_integral_value(rounding=ROUND_HALF_UP)) + return normalized if normalized >= 0 else None + + +async def _get_cryptobot_limits() -> Tuple[int, int, float]: + try: + rate_value = await currency_converter.get_usd_to_rub_rate() + except Exception: + rate_value = 0.0 + + if not rate_value or rate_value <= 0: + rate_value = 95.0 + + rate_decimal = Decimal(str(rate_value)) + min_usd = Decimal("1") + max_usd = Decimal("1000") + + min_rubles = (min_usd * rate_decimal).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP) + max_rubles = (max_usd * rate_decimal).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP) + + min_kopeks = int((min_rubles * 100).to_integral_value(rounding=ROUND_HALF_UP)) + max_kopeks = int((max_rubles * 100).to_integral_value(rounding=ROUND_HALF_UP)) + + if max_kopeks < min_kopeks: + max_kopeks = min_kopeks + + return max(min_kopeks, 1), max_kopeks, float(rate_decimal) + + +@router.post( + "/payments/methods", + response_model=MiniAppPaymentMethodsResponse, +) +async def get_payment_methods( + payload: MiniAppPaymentMethodsRequest, + db: AsyncSession = Depends(get_db_session), +) -> MiniAppPaymentMethodsResponse: + _, _ = await _resolve_user_from_init_data(db, payload.init_data) + + methods: List[MiniAppPaymentMethod] = [] + + if settings.TELEGRAM_STARS_ENABLED: + methods.append( + MiniAppPaymentMethod( + id="stars", + icon="⭐", + requires_amount=True, + currency="RUB", + ) + ) + + if settings.is_yookassa_enabled(): + methods.append( + MiniAppPaymentMethod( + id="yookassa", + icon="💳", + requires_amount=True, + currency="RUB", + min_amount_kopeks=settings.YOOKASSA_MIN_AMOUNT_KOPEKS, + max_amount_kopeks=settings.YOOKASSA_MAX_AMOUNT_KOPEKS, + ) + ) + + if settings.is_mulenpay_enabled(): + methods.append( + MiniAppPaymentMethod( + id="mulenpay", + icon="💳", + requires_amount=True, + currency="RUB", + min_amount_kopeks=settings.MULENPAY_MIN_AMOUNT_KOPEKS, + max_amount_kopeks=settings.MULENPAY_MAX_AMOUNT_KOPEKS, + ) + ) + + if settings.is_pal24_enabled(): + methods.append( + MiniAppPaymentMethod( + id="pal24", + icon="🏦", + requires_amount=True, + currency="RUB", + min_amount_kopeks=settings.PAL24_MIN_AMOUNT_KOPEKS, + max_amount_kopeks=settings.PAL24_MAX_AMOUNT_KOPEKS, + ) + ) + + if settings.is_cryptobot_enabled(): + min_cryptobot_amount, max_cryptobot_amount, _ = await _get_cryptobot_limits() + methods.append( + MiniAppPaymentMethod( + id="cryptobot", + icon="🪙", + requires_amount=True, + currency="RUB", + min_amount_kopeks=min_cryptobot_amount, + max_amount_kopeks=max_cryptobot_amount, + ) + ) + + if settings.TRIBUTE_ENABLED: + methods.append( + MiniAppPaymentMethod( + id="tribute", + icon="💎", + requires_amount=False, + currency="RUB", + ) + ) + + order_map = { + "stars": 1, + "yookassa": 2, + "mulenpay": 3, + "pal24": 4, + "cryptobot": 5, + "tribute": 6, + } + methods.sort(key=lambda item: order_map.get(item.id, 99)) + + return MiniAppPaymentMethodsResponse(methods=methods) + + +@router.post( + "/payments/create", + response_model=MiniAppPaymentCreateResponse, +) +async def create_payment_link( + payload: MiniAppPaymentCreateRequest, + db: AsyncSession = Depends(get_db_session), +) -> MiniAppPaymentCreateResponse: + user, _ = await _resolve_user_from_init_data(db, payload.init_data) + + method = (payload.method or "").strip().lower() + if not method: + raise HTTPException( + status.HTTP_400_BAD_REQUEST, + detail="Payment method is required", + ) + + amount_kopeks = _normalize_amount_kopeks( + payload.amount_rubles, + payload.amount_kopeks, + ) + + if method == "stars": + if not settings.TELEGRAM_STARS_ENABLED: + raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="Payment method is unavailable") + if amount_kopeks is None or amount_kopeks <= 0: + raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="Amount must be positive") + if not settings.BOT_TOKEN: + raise HTTPException(status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Bot token is not configured") + + bot = Bot(token=settings.BOT_TOKEN) + try: + payment_service = PaymentService(bot) + invoice_link = await payment_service.create_stars_invoice( + amount_kopeks=amount_kopeks, + description=settings.get_balance_payment_description(amount_kopeks), + payload=f"balance_{user.id}_{amount_kopeks}", + ) + finally: + await bot.session.close() + + if not invoice_link: + raise HTTPException(status.HTTP_502_BAD_GATEWAY, detail="Failed to create invoice") + + return MiniAppPaymentCreateResponse( + method=method, + payment_url=invoice_link, + amount_kopeks=amount_kopeks, + extra={ + "tracking": { + "method": method, + "amount_kopeks": amount_kopeks, + } + }, + ) + + if method == "yookassa": + if not settings.is_yookassa_enabled(): + raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="Payment method is unavailable") + if amount_kopeks is None or amount_kopeks <= 0: + raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="Amount must be positive") + if amount_kopeks < settings.YOOKASSA_MIN_AMOUNT_KOPEKS: + raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="Amount is below minimum") + if amount_kopeks > settings.YOOKASSA_MAX_AMOUNT_KOPEKS: + raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="Amount exceeds maximum") + + payment_service = PaymentService() + result = await payment_service.create_yookassa_payment( + db=db, + user_id=user.id, + amount_kopeks=amount_kopeks, + description=settings.get_balance_payment_description(amount_kopeks), + ) + if not result or not result.get("confirmation_url"): + raise HTTPException(status.HTTP_502_BAD_GATEWAY, detail="Failed to create payment") + + return MiniAppPaymentCreateResponse( + method=method, + payment_url=result["confirmation_url"], + amount_kopeks=amount_kopeks, + extra={ + "local_payment_id": result.get("local_payment_id"), + "payment_id": result.get("yookassa_payment_id"), + "status": result.get("status"), + "tracking": { + "method": method, + "local_payment_id": result.get("local_payment_id"), + "payment_id": result.get("yookassa_payment_id"), + }, + }, + ) + + if method == "mulenpay": + if not settings.is_mulenpay_enabled(): + raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="Payment method is unavailable") + if amount_kopeks is None or amount_kopeks <= 0: + raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="Amount must be positive") + if amount_kopeks < settings.MULENPAY_MIN_AMOUNT_KOPEKS: + raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="Amount is below minimum") + if amount_kopeks > settings.MULENPAY_MAX_AMOUNT_KOPEKS: + raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="Amount exceeds maximum") + + payment_service = PaymentService() + result = await payment_service.create_mulenpay_payment( + db=db, + user_id=user.id, + amount_kopeks=amount_kopeks, + description=settings.get_balance_payment_description(amount_kopeks), + language=user.language, + ) + if not result or not result.get("payment_url"): + raise HTTPException(status.HTTP_502_BAD_GATEWAY, detail="Failed to create payment") + + return MiniAppPaymentCreateResponse( + method=method, + payment_url=result["payment_url"], + amount_kopeks=amount_kopeks, + extra={ + "local_payment_id": result.get("local_payment_id"), + "payment_id": result.get("mulen_payment_id"), + "uuid": result.get("uuid"), + "tracking": { + "method": method, + "local_payment_id": result.get("local_payment_id"), + "payment_id": result.get("mulen_payment_id"), + "uuid": result.get("uuid"), + }, + }, + ) + + if method == "pal24": + if not settings.is_pal24_enabled(): + raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="Payment method is unavailable") + if amount_kopeks is None or amount_kopeks <= 0: + raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="Amount must be positive") + if amount_kopeks < settings.PAL24_MIN_AMOUNT_KOPEKS: + raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="Amount is below minimum") + if amount_kopeks > settings.PAL24_MAX_AMOUNT_KOPEKS: + raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="Amount exceeds maximum") + + payment_service = PaymentService() + result = await payment_service.create_pal24_payment( + db=db, + user_id=user.id, + amount_kopeks=amount_kopeks, + description=settings.get_balance_payment_description(amount_kopeks), + language=user.language or settings.DEFAULT_LANGUAGE, + ) + if not result or not result.get("payment_url"): + raise HTTPException(status.HTTP_502_BAD_GATEWAY, detail="Failed to create payment") + + sbp_url = result.get("sbp_url") or result.get("transfer_url") + card_url = result.get("card_url") + link_page_url = result.get("link_page_url") + fallback_url = result.get("link_url") or result.get("payment_url") + available_links = { + "sbp": sbp_url, + "card": card_url, + "page": link_page_url, + "default": fallback_url, + } + available_links = {key: value for key, value in available_links.items() if value} + + return MiniAppPaymentCreateResponse( + method=method, + payment_url=result["payment_url"], + amount_kopeks=amount_kopeks, + extra={ + "local_payment_id": result.get("local_payment_id"), + "bill_id": result.get("bill_id"), + "sbp_url": sbp_url, + "card_url": card_url, + "link_page_url": link_page_url, + "link_url": fallback_url, + "tracking": { + "method": method, + "local_payment_id": result.get("local_payment_id"), + "bill_id": result.get("bill_id"), + }, + "links": available_links, + }, + ) + + if method == "cryptobot": + if not settings.is_cryptobot_enabled(): + raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="Payment method is unavailable") + if amount_kopeks is None or amount_kopeks <= 0: + raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="Amount must be positive") + min_amount_kopeks, max_amount_kopeks, rate = await _get_cryptobot_limits() + + if amount_kopeks < min_amount_kopeks: + raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="Amount is below minimum") + if amount_kopeks > max_amount_kopeks: + raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="Amount exceeds maximum") + + rate_decimal = Decimal(str(rate)) + amount_rubles_decimal = Decimal(amount_kopeks) / Decimal(100) + try: + amount_usd_decimal = (amount_rubles_decimal / rate_decimal).quantize( + Decimal("0.01"), rounding=ROUND_HALF_UP + ) + except (InvalidOperation, ZeroDivisionError): + raise HTTPException( + status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Failed to convert amount to USD", + ) + + if amount_usd_decimal <= 0: + raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="Amount is below minimum") + + amount_usd = float(amount_usd_decimal) + + payment_service = PaymentService() + result = await payment_service.create_cryptobot_payment( + db=db, + user_id=user.id, + amount_usd=amount_usd, + asset=settings.CRYPTOBOT_DEFAULT_ASSET, + description=settings.get_balance_payment_description(amount_kopeks), + payload=f"balance_{user.id}_{amount_kopeks}", + ) + if not result: + raise HTTPException(status.HTTP_502_BAD_GATEWAY, detail="Failed to create payment") + + payment_url = ( + result.get("bot_invoice_url") + or result.get("mini_app_invoice_url") + or result.get("web_app_invoice_url") + ) + if not payment_url: + raise HTTPException(status.HTTP_502_BAD_GATEWAY, detail="Failed to obtain payment url") + + return MiniAppPaymentCreateResponse( + method=method, + payment_url=payment_url, + amount_kopeks=amount_kopeks, + extra={ + "local_payment_id": result.get("local_payment_id"), + "invoice_id": result.get("invoice_id"), + "amount_usd": amount_usd, + "rate": rate, + "limits": { + "min_amount_kopeks": min_amount_kopeks, + "max_amount_kopeks": max_amount_kopeks, + }, + "tracking": { + "method": method, + "local_payment_id": result.get("local_payment_id"), + "invoice_id": result.get("invoice_id"), + }, + }, + ) + + if method == "tribute": + if not settings.TRIBUTE_ENABLED: + raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="Payment method is unavailable") + if not settings.BOT_TOKEN: + raise HTTPException(status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Bot token is not configured") + + bot = Bot(token=settings.BOT_TOKEN) + try: + tribute_service = TributeService(bot) + payment_url = await tribute_service.create_payment_link( + user_id=user.telegram_id, + amount_kopeks=amount_kopeks or 0, + description=settings.get_balance_payment_description(amount_kopeks or 0), + ) + finally: + await bot.session.close() + + if not payment_url: + raise HTTPException(status.HTTP_502_BAD_GATEWAY, detail="Failed to create payment") + + return MiniAppPaymentCreateResponse( + method=method, + payment_url=payment_url, + amount_kopeks=amount_kopeks, + extra={ + "tracking": { + "method": method, + "amount_kopeks": amount_kopeks, + } + }, + ) + + raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="Unknown payment method") + + +@router.post( + "/payments/status", + response_model=MiniAppPaymentStatusResponse, +) +async def get_payment_status( + payload: MiniAppPaymentStatusRequest, + db: AsyncSession = Depends(get_db_session), +) -> MiniAppPaymentStatusResponse: + user, _ = await _resolve_user_from_init_data(db, payload.init_data) + + method = (payload.method or "").strip().lower() + if not method: + raise HTTPException( + status.HTTP_400_BAD_REQUEST, + detail="Payment method is required", + ) + + if method in {"stars", "tribute"}: + return MiniAppPaymentStatusResponse(method=method, status="unsupported") + + if method == "yookassa": + payment = None + if payload.local_payment_id is not None: + payment = await get_yookassa_payment_by_local_id(db, payload.local_payment_id) + if not payment and payload.payment_id: + payment = await get_yookassa_payment_by_id(db, payload.payment_id) + + if not payment or payment.user_id != user.id: + return MiniAppPaymentStatusResponse(method=method, status="not_found") + + status_value = (payment.status or "").lower() or "unknown" + is_paid = bool(payment.is_paid) + is_pending = bool(payment.is_pending or status_value in {"waiting_for_capture"}) + + return MiniAppPaymentStatusResponse( + method=method, + status=status_value, + is_paid=is_paid, + is_pending=is_pending, + amount_kopeks=payment.amount_kopeks, + completed_at=payment.captured_at or payment.updated_at, + extra={ + "status_raw": payment.status, + "transaction_id": payment.transaction_id, + "payment_method_type": payment.payment_method_type, + "is_captured": payment.is_captured, + }, + ) + + if method == "mulenpay": + payment = None + if payload.local_payment_id is not None: + payment = await get_mulenpay_payment_by_local_id(db, payload.local_payment_id) + if not payment and payload.payment_id: + try: + mulen_payment_id = int(payload.payment_id) + except (TypeError, ValueError): + mulen_payment_id = None + if mulen_payment_id is not None: + payment = await get_mulenpay_payment_by_mulen_id(db, mulen_payment_id) + if not payment and payload.uuid: + payment = await get_mulenpay_payment_by_uuid(db, payload.uuid) + + if not payment or payment.user_id != user.id: + return MiniAppPaymentStatusResponse(method=method, status="not_found") + + status_value = (payment.status or "").lower() or "unknown" + is_paid = bool(payment.is_paid) + failure_statuses = {"failed", "canceled", "cancelled", "rejected"} + is_pending = not is_paid and status_value not in failure_statuses + + return MiniAppPaymentStatusResponse( + method=method, + status=status_value, + is_paid=is_paid, + is_pending=is_pending, + amount_kopeks=payment.amount_kopeks, + completed_at=payment.paid_at or payment.updated_at, + extra={ + "status_raw": payment.status, + "transaction_id": payment.transaction_id, + "uuid": payment.uuid, + "mulen_payment_id": payment.mulen_payment_id, + }, + ) + + if method == "pal24": + payment = None + if payload.local_payment_id is not None: + payment = await get_pal24_payment_by_id(db, payload.local_payment_id) + if not payment and payload.bill_id: + payment = await get_pal24_payment_by_bill_id(db, payload.bill_id) + + if not payment or payment.user_id != user.id: + return MiniAppPaymentStatusResponse(method=method, status="not_found") + + status_value = (payment.status or "").lower() or "unknown" + is_paid = bool(payment.is_paid) + is_pending = bool(payment.is_pending and not is_paid) + + return MiniAppPaymentStatusResponse( + method=method, + status=status_value, + is_paid=is_paid, + is_pending=is_pending, + amount_kopeks=payment.amount_kopeks, + completed_at=payment.paid_at or payment.updated_at, + extra={ + "status_raw": payment.status, + "transaction_id": payment.transaction_id, + "bill_id": payment.bill_id, + "payment_id": payment.payment_id, + "links": (payment.metadata_json or {}).get("links") if payment.metadata_json else None, + }, + ) + + if method == "cryptobot": + payment = None + if payload.local_payment_id is not None: + payment = await get_cryptobot_payment_by_id(db, payload.local_payment_id) + if not payment and payload.invoice_id: + payment = await get_cryptobot_payment_by_invoice_id(db, payload.invoice_id) + + if not payment or payment.user_id != user.id: + return MiniAppPaymentStatusResponse(method=method, status="not_found") + + status_value = (payment.status or "").lower() or "unknown" + is_paid = payment.is_paid + is_pending = payment.is_pending and not is_paid + + return MiniAppPaymentStatusResponse( + method=method, + status=status_value, + is_paid=is_paid, + is_pending=is_pending, + completed_at=payment.paid_at or payment.updated_at, + extra={ + "status_raw": payment.status, + "transaction_id": payment.transaction_id, + "invoice_id": payment.invoice_id, + "amount": payment.amount, + "asset": payment.asset, + }, + ) + + raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="Unknown payment method") + + _TEMPLATE_ID_PATTERN = re.compile(r"promo_template_(?P\d+)$") _OFFER_TYPE_ICONS = { "extend_discount": "💎", diff --git a/app/webapi/schemas/miniapp.py b/app/webapi/schemas/miniapp.py index 8afed075..563150d6 100644 --- a/app/webapi/schemas/miniapp.py +++ b/app/webapi/schemas/miniapp.py @@ -253,6 +253,59 @@ class MiniAppReferralInfo(BaseModel): referrals: Optional[MiniAppReferralList] = None +class MiniAppPaymentMethodsRequest(BaseModel): + init_data: str = Field(..., alias="initData") + + +class MiniAppPaymentMethod(BaseModel): + id: str + icon: Optional[str] = None + requires_amount: bool = False + currency: str = "RUB" + min_amount_kopeks: Optional[int] = None + max_amount_kopeks: Optional[int] = None + amount_step_kopeks: Optional[int] = None + + +class MiniAppPaymentMethodsResponse(BaseModel): + methods: List[MiniAppPaymentMethod] = Field(default_factory=list) + + +class MiniAppPaymentCreateRequest(BaseModel): + init_data: str = Field(..., alias="initData") + method: str + amount_rubles: Optional[float] = Field(default=None, alias="amountRubles") + amount_kopeks: Optional[int] = Field(default=None, alias="amountKopeks") + + +class MiniAppPaymentCreateResponse(BaseModel): + success: bool = True + method: str + payment_url: Optional[str] = None + amount_kopeks: Optional[int] = None + extra: Dict[str, Any] = Field(default_factory=dict) + + +class MiniAppPaymentStatusRequest(BaseModel): + init_data: str = Field(..., alias="initData") + method: str + local_payment_id: Optional[int] = Field(default=None, alias="localPaymentId") + payment_id: Optional[str] = Field(default=None, alias="paymentId") + invoice_id: Optional[str] = Field(default=None, alias="invoiceId") + bill_id: Optional[str] = Field(default=None, alias="billId") + uuid: Optional[str] = None + + +class MiniAppPaymentStatusResponse(BaseModel): + method: str + status: str = "unknown" + is_paid: bool = False + is_pending: bool = False + amount_kopeks: Optional[int] = None + completed_at: Optional[datetime] = None + extra: Dict[str, Any] = Field(default_factory=dict) + + class MiniAppSubscriptionResponse(BaseModel): success: bool = True subscription_id: int diff --git a/miniapp/index.html b/miniapp/index.html index 2583a5dd..f0f80e0b 100644 --- a/miniapp/index.html +++ b/miniapp/index.html @@ -78,6 +78,10 @@ line-height: 1.6; } + body.modal-open { + overflow: hidden; + } + .container { max-width: 480px; margin: 0 auto; @@ -1060,6 +1064,9 @@ .balance-content { padding: 20px; text-align: center; + display: flex; + flex-direction: column; + align-items: center; } .balance-amount { @@ -1080,6 +1087,287 @@ letter-spacing: 0.5px; } + .balance-actions { + margin-top: 16px; + display: flex; + justify-content: center; + } + + .topup-button { + padding: 10px 20px; + border-radius: var(--radius); + border: none; + background: var(--primary); + color: var(--tg-theme-button-text-color); + font-size: 15px; + font-weight: 700; + cursor: pointer; + transition: transform 0.2s ease, box-shadow 0.2s ease, background 0.3s ease; + display: inline-flex; + align-items: center; + gap: 8px; + } + + .topup-button:hover { + box-shadow: var(--shadow-sm); + transform: translateY(-1px); + } + + .topup-button:active { + transform: scale(0.98); + } + + .topup-button:disabled { + opacity: 0.6; + cursor: not-allowed; + box-shadow: none; + transform: none; + } + + .modal-backdrop { + position: fixed; + inset: 0; + background: rgba(15, 23, 42, 0.55); + display: flex; + align-items: center; + justify-content: center; + padding: 24px; + z-index: 1000; + backdrop-filter: blur(4px); + } + + .modal-backdrop.hidden { + display: none; + } + + .modal { + width: 100%; + max-width: 420px; + background: var(--bg-primary); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-lg); + padding: 24px 20px; + display: flex; + flex-direction: column; + gap: 18px; + } + + .modal-header { + text-align: center; + } + + .modal-title { + font-size: 20px; + font-weight: 800; + color: var(--text-primary); + margin-bottom: 4px; + } + + .modal-subtitle { + font-size: 14px; + color: var(--text-secondary); + } + + .payment-methods-list { + display: flex; + flex-direction: column; + gap: 12px; + } + + .payment-method-card { + border: 2px solid var(--border-color); + border-radius: var(--radius); + padding: 14px 16px; + background: var(--bg-secondary); + cursor: pointer; + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + transition: transform 0.2s ease, box-shadow 0.2s ease, border-color 0.2s ease; + } + + .payment-method-card:hover { + transform: translateY(-1px); + box-shadow: var(--shadow-sm); + border-color: var(--primary); + } + + .payment-method-card:active { + transform: scale(0.99); + } + + .payment-method-info { + display: flex; + align-items: center; + gap: 12px; + flex: 1; + } + + .payment-method-icon { + font-size: 26px; + } + + .payment-method-text { + display: flex; + flex-direction: column; + gap: 2px; + } + + .payment-method-label { + font-weight: 700; + color: var(--text-primary); + } + + .payment-method-description { + font-size: 13px; + color: var(--text-secondary); + } + + .amount-form { + display: flex; + flex-direction: column; + gap: 16px; + } + + .amount-input { + width: 100%; + padding: 12px 14px; + border-radius: var(--radius); + border: 2px solid var(--border-color); + background: var(--bg-secondary); + color: var(--text-primary); + font-size: 16px; + font-weight: 600; + transition: border-color 0.2s ease, box-shadow 0.2s ease; + } + + .amount-input:focus { + outline: none; + border-color: var(--primary); + box-shadow: var(--shadow-sm); + } + + .amount-hint { + font-size: 13px; + color: var(--text-secondary); + line-height: 1.5; + } + + .modal-actions { + display: flex; + gap: 12px; + justify-content: center; + } + + .modal-button { + flex: 1; + padding: 12px; + border-radius: var(--radius); + border: none; + font-weight: 700; + font-size: 15px; + cursor: pointer; + transition: transform 0.2s ease, box-shadow 0.2s ease, background 0.2s ease; + } + + .modal-button.primary { + background: var(--primary); + color: var(--tg-theme-button-text-color); + } + + .modal-button.secondary { + background: transparent; + color: var(--text-primary); + border: 2px solid var(--border-color); + } + + .modal-button:hover:not(:disabled) { + box-shadow: var(--shadow-sm); + transform: translateY(-1px); + } + + .modal-button:disabled { + opacity: 0.6; + cursor: not-allowed; + box-shadow: none; + transform: none; + } + + .modal-error { + font-size: 13px; + color: var(--danger); + text-align: center; + } + + .payment-summary { + display: flex; + flex-direction: column; + align-items: center; + gap: 8px; + text-align: center; + } + + .payment-summary-amount { + font-size: 22px; + font-weight: 700; + color: var(--text-primary); + } + + .payment-actions { + margin-top: 20px; + display: flex; + flex-direction: column; + gap: 12px; + width: 100%; + } + + .payment-actions .modal-button { + width: 100%; + } + + .payment-status { + margin-top: 20px; + padding: 12px 16px; + border-radius: var(--radius-lg); + font-size: 14px; + display: flex; + align-items: center; + gap: 10px; + line-height: 1.5; + background: rgba(var(--primary-rgb), 0.08); + color: var(--text-primary); + width: 100%; + } + + .payment-status .status-icon { + font-size: 18px; + } + + .payment-status .status-text { + flex: 1; + } + + .payment-status.payment-status-success { + background: rgba(16, 185, 129, 0.12); + color: var(--success); + } + + .payment-status.payment-status-error { + background: rgba(var(--danger-rgb), 0.12); + color: var(--danger); + } + + .payment-status.payment-status-warning { + background: rgba(245, 158, 11, 0.12); + color: var(--warning); + } + + .payment-status.payment-status-pending { + background: rgba(var(--primary-rgb), 0.08); + color: var(--text-primary); + } + .promo-code-card { padding: 20px; display: flex; @@ -2501,6 +2789,26 @@ color: #fecaca; } + :root[data-theme="dark"] .payment-status.payment-status-success { + background: rgba(16, 185, 129, 0.2); + color: var(--success); + } + + :root[data-theme="dark"] .payment-status.payment-status-error { + background: rgba(var(--danger-rgb), 0.25); + color: #fca5a5; + } + + :root[data-theme="dark"] .payment-status.payment-status-warning { + background: rgba(245, 158, 11, 0.25); + color: #fbbf24; + } + + :root[data-theme="dark"] .payment-status.payment-status-pending { + background: rgba(var(--primary-rgb), 0.18); + color: var(--text-primary); + } + :root[data-theme="light"] .theme-toggle .icon-moon { display: none; } @@ -2568,6 +2876,24 @@ border-color: rgba(148, 163, 184, 0.25); } + :root[data-theme="dark"] .modal { + background: rgba(15, 23, 42, 0.96); + } + + :root[data-theme="dark"] .payment-method-card { + background: rgba(15, 23, 42, 0.85); + border-color: rgba(148, 163, 184, 0.28); + } + + :root[data-theme="dark"] .amount-input { + background: rgba(15, 23, 42, 0.85); + border-color: rgba(148, 163, 184, 0.28); + } + + :root[data-theme="dark"] .modal-button.secondary { + border-color: rgba(148, 163, 184, 0.35); + } + :root[data-theme="dark"] .promo-code-input-group { background: rgba(15, 23, 42, 0.85); border-color: rgba(148, 163, 184, 0.35); @@ -2799,6 +3125,26 @@
Balance
+
+ +
+
+ + + @@ -3105,6 +3451,7 @@ applyTheme(tg.colorScheme); } }); + tg.onEvent('invoiceClosed', handleInvoiceClosedEvent); } if (window.matchMedia) { @@ -3152,6 +3499,56 @@ 'button.connect.default': 'Connect to VPN', 'button.connect.happ': 'Connect', 'button.copy': 'Copy subscription link', + 'button.topup_balance': 'Top up balance', + 'topup.title': 'Top up balance', + 'topup.subtitle': 'Choose a payment method', + 'topup.methods.subtitle': 'Select how you want to pay', + 'topup.method.stars.title': 'Telegram Stars', + 'topup.method.stars.description': 'Pay using Telegram Stars', + 'topup.method.yookassa.title': 'Bank card (YooKassa)', + 'topup.method.yookassa.description': 'Pay securely with a bank card', + 'topup.method.mulenpay.title': 'Bank card (Mulen Pay)', + 'topup.method.mulenpay.description': 'Fast payment with bank card', + 'topup.method.pal24.title': 'SBP (PayPalych)', + 'topup.method.pal24.description': 'Pay via Faster Payments System', + 'topup.method.pal24.subtitle': 'Choose how you want to pay with PayPalych', + 'topup.method.pal24.button.sbp': 'Pay via SBP', + 'topup.method.pal24.button.card': 'Pay by card', + 'topup.method.pal24.button.page': 'Open payment page', + 'topup.method.cryptobot.title': 'Cryptocurrency (CryptoBot)', + 'topup.method.cryptobot.description': 'Pay with crypto assets', + 'topup.method.tribute.title': 'Bank card (Tribute)', + 'topup.method.tribute.description': 'Redirect to Tribute payment page', + 'topup.method.tribute.subtitle': 'Complete the payment on the Tribute page', + 'topup.amount.title': 'Enter amount', + 'topup.amount.subtitle': 'Specify how much you want to top up', + 'topup.amount.placeholder': 'Amount in {currency}', + 'topup.amount.hint.range': 'Available range: {min} — {max}', + 'topup.amount.hint.single_min': 'Minimum top-up: {min}', + 'topup.amount.hint.single_max': 'Maximum top-up: {max}', + 'topup.submit': 'Continue', + 'topup.cancel': 'Close', + 'topup.back': 'Back', + 'topup.open_link': 'Open payment page', + 'topup.close': 'Close', + 'topup.again': 'Top up again', + 'topup.loading': 'Preparing payment…', + 'topup.error.generic': 'Unable to start the payment. Please try again later.', + 'topup.error.amount': 'Enter a valid amount within the limits.', + 'topup.error.unavailable': 'This payment method is temporarily unavailable.', + 'topup.status.waiting': 'Waiting for payment confirmation…', + 'topup.status.checking': 'Checking payment status…', + 'topup.status.paid': 'Payment received! Your balance has been updated.', + 'topup.status.refreshing': 'Payment confirmed! Updating your balance…', + 'topup.status.failed': 'Payment failed or was cancelled.', + 'topup.status.expired': 'Payment link expired. Please create a new one.', + 'topup.status.manual_check': 'Return to this window after paying to check the status.', + 'topup.status.not_found': 'We could not find this payment yet. Please try again shortly.', + 'topup.status.error': 'Unable to check payment status. Please try again later.', + 'topup.status.button.check': 'Check status', + 'topup.status.tribute.awaiting': 'After paying, return to this window. The balance will update automatically.', + 'topup.status.tribute.pending': 'Waiting for confirmation from Tribute…', + 'topup.status.tribute.success': 'Payment received! Your balance has been updated.', 'button.buy_subscription': 'Buy Subscription', 'card.balance.title': 'Balance', 'promo_code.title': 'Activate promo code', @@ -3334,6 +3731,56 @@ 'button.connect.default': 'Подключиться к VPN', 'button.connect.happ': 'Подключиться', 'button.copy': 'Скопировать ссылку подписки', + 'button.topup_balance': 'Пополнить баланс', + 'topup.title': 'Пополнение баланса', + 'topup.subtitle': 'Выберите способ оплаты', + 'topup.methods.subtitle': 'Выберите удобный способ оплаты', + 'topup.method.stars.title': 'Telegram Stars', + 'topup.method.stars.description': 'Оплата звёздами Telegram', + 'topup.method.yookassa.title': 'Банковская карта (YooKassa)', + 'topup.method.yookassa.description': 'Безопасная оплата банковской картой', + 'topup.method.mulenpay.title': 'Банковская карта (Mulen Pay)', + 'topup.method.mulenpay.description': 'Мгновенное списание с карты', + 'topup.method.pal24.title': 'СБП (PayPalych)', + 'topup.method.pal24.description': 'Оплата через Систему быстрых платежей', + 'topup.method.pal24.subtitle': 'Выберите способ оплаты в PayPalych', + 'topup.method.pal24.button.sbp': 'Оплатить через СБП', + 'topup.method.pal24.button.card': 'Оплатить банковской картой', + 'topup.method.pal24.button.page': 'Открыть страницу оплаты', + 'topup.method.cryptobot.title': 'Криптовалюта (CryptoBot)', + 'topup.method.cryptobot.description': 'Оплата в USDT, TON и других активах', + 'topup.method.tribute.title': 'Банковская карта (Tribute)', + 'topup.method.tribute.description': 'Переход на страницу оплаты Tribute', + 'topup.method.tribute.subtitle': 'Завершите оплату на странице Tribute', + 'topup.amount.title': 'Введите сумму', + 'topup.amount.subtitle': 'Укажите сумму пополнения', + 'topup.amount.placeholder': 'Сумма в {currency}', + 'topup.amount.hint.range': 'Доступный диапазон: {min} — {max}', + 'topup.amount.hint.single_min': 'Минимальная сумма: {min}', + 'topup.amount.hint.single_max': 'Максимальная сумма: {max}', + 'topup.submit': 'Продолжить', + 'topup.cancel': 'Закрыть', + 'topup.back': 'Назад', + 'topup.open_link': 'Перейти к оплате', + 'topup.close': 'Закрыть', + 'topup.again': 'Пополнить ещё', + 'topup.loading': 'Готовим платеж…', + 'topup.error.generic': 'Не удалось создать платеж. Попробуйте ещё раз позже.', + 'topup.error.amount': 'Введите корректную сумму в пределах лимитов.', + 'topup.error.unavailable': 'Способ оплаты временно недоступен.', + 'topup.status.waiting': 'Ожидаем подтверждение оплаты…', + 'topup.status.checking': 'Проверяем статус платежа…', + 'topup.status.paid': 'Платеж получен! Баланс обновлён.', + 'topup.status.refreshing': 'Платеж подтверждён! Обновляем баланс…', + 'topup.status.failed': 'Платеж не прошёл или был отменён.', + 'topup.status.expired': 'Ссылка на оплату истекла. Создайте новую.', + 'topup.status.manual_check': 'После оплаты вернитесь в это окно, чтобы проверить статус.', + 'topup.status.not_found': 'Платеж пока не найден. Попробуйте немного позже.', + 'topup.status.error': 'Не удалось проверить статус платежа. Попробуйте позже.', + 'topup.status.button.check': 'Проверить статус', + 'topup.status.tribute.awaiting': 'После оплаты вернитесь в это окно. Баланс обновится автоматически.', + 'topup.status.tribute.pending': 'Ожидаем подтверждение от Tribute…', + 'topup.status.tribute.success': 'Платеж получен! Баланс обновлён.', 'button.buy_subscription': 'Купить подписку', 'card.balance.title': 'Баланс', 'promo_code.title': 'Активировать промокод', @@ -3609,6 +4056,19 @@ let preferredLanguage = 'en'; let languageLockedByUser = false; let currentErrorState = null; + let paymentMethodsCache = null; + let paymentMethodsPromise = null; + + let activePaymentSession = null; + let paymentStatusTimer = null; + let paymentBalanceTimer = null; + const PAYMENT_STATUS_INTERVAL = 5000; + const PAYMENT_STATUS_INITIAL_DELAY = 4000; + const PAYMENT_STATUS_TIMEOUT = 5 * 60 * 1000; + const BALANCE_POLL_INTERVAL = 8000; + const BALANCE_POLL_INITIAL_DELAY = 10000; + const BALANCE_POLL_TIMEOUT = 5 * 60 * 1000; + let activePaymentMethod = null; function resolveLanguage(lang) { if (!lang) { @@ -5269,6 +5729,1382 @@ amountElement.textContent = formatCurrency(balanceRubles, currency); } + function getTopupElements() { + return { + backdrop: document.getElementById('topupModal'), + title: document.getElementById('topupModalTitle'), + subtitle: document.getElementById('topupModalSubtitle'), + body: document.getElementById('topupModalBody'), + error: document.getElementById('topupModalError'), + footer: document.getElementById('topupModalFooter'), + }; + } + + function setTopupModalTitle(key, fallback) { + const { title } = getTopupElements(); + if (!title) { + return; + } + const value = t(key); + title.textContent = value === key ? (fallback || key) : value; + } + + function setTopupModalSubtitle(key, fallback) { + const { subtitle } = getTopupElements(); + if (!subtitle) { + return; + } + const value = t(key); + subtitle.textContent = value === key ? (fallback || key) : value; + } + + function setTopupFooter(buttons = []) { + const { footer } = getTopupElements(); + if (!footer) { + return; + } + footer.innerHTML = ''; + buttons.forEach(config => { + const btn = document.createElement('button'); + btn.type = 'button'; + btn.className = `modal-button ${config.variant || 'primary'}`; + if (config.id) { + btn.id = config.id; + } + const label = config.labelKey ? t(config.labelKey) : config.label; + btn.textContent = label && label !== config.labelKey + ? label + : (config.fallbackLabel || config.labelKey || config.label || ''); + if (typeof config.onClick === 'function') { + btn.addEventListener('click', config.onClick); + } + if (config.disabled) { + btn.disabled = true; + } + footer.appendChild(btn); + }); + } + + function showTopupError(messageKey, fallback) { + const { error } = getTopupElements(); + if (!error) { + return; + } + const message = t(messageKey); + error.textContent = message === messageKey ? (fallback || messageKey) : message; + error.classList.remove('hidden'); + } + + function clearTopupError() { + const { error } = getTopupElements(); + if (error) { + error.classList.add('hidden'); + error.textContent = ''; + } + } + + function stopPaymentTracking() { + if (paymentStatusTimer) { + clearTimeout(paymentStatusTimer); + paymentStatusTimer = null; + } + if (paymentBalanceTimer) { + clearTimeout(paymentBalanceTimer); + paymentBalanceTimer = null; + } + } + + function resetActivePaymentSession() { + stopPaymentTracking(); + activePaymentSession = null; + } + + function extractTrackingData(method, extra = {}) { + const tracking = + typeof extra.tracking === 'object' && extra.tracking !== null + ? { ...extra.tracking } + : {}; + + if (method?.id && !tracking.method) { + tracking.method = method.id; + } + if (extra.local_payment_id !== undefined && tracking.local_payment_id === undefined) { + tracking.local_payment_id = extra.local_payment_id; + } + if (extra.payment_id && !tracking.payment_id) { + tracking.payment_id = extra.payment_id; + } + if (extra.invoice_id && !tracking.invoice_id) { + tracking.invoice_id = extra.invoice_id; + } + if (extra.bill_id && !tracking.bill_id) { + tracking.bill_id = extra.bill_id; + } + if (extra.uuid && !tracking.uuid) { + tracking.uuid = extra.uuid; + } + if (extra.amount_kopeks && !tracking.amount_kopeks) { + tracking.amount_kopeks = extra.amount_kopeks; + } + + return tracking; + } + + function shouldPollStatus(session) { + if (!session || !session.method?.id) { + return false; + } + const methodId = session.method.id; + const supported = new Set(['yookassa', 'mulenpay', 'pal24', 'cryptobot']); + if (!supported.has(methodId)) { + return false; + } + const tracking = session.tracking || {}; + return Boolean( + tracking.local_payment_id !== undefined + || tracking.payment_id + || tracking.invoice_id + || tracking.bill_id + || tracking.uuid + ); + } + + function shouldPollBalance(session) { + if (!session || !session.method?.id) { + return false; + } + if (session.method.id === 'tribute') { + return true; + } + if (session.method.id === 'stars') { + return true; + } + return shouldPollStatus(session); + } + + function preparePaymentSession(session) { + if (!session) { + return null; + } + + session.status = session.status || 'pending'; + session.createdAt = Date.now(); + session.completed = false; + + if (!session.statusMessageKey) { + if (session.method?.id === 'tribute') { + session.statusMessageKey = 'topup.status.tribute.awaiting'; + } else if (session.method?.id === 'stars') { + session.statusMessageKey = 'topup.status.waiting'; + } else { + session.statusMessageKey = 'topup.status.manual_check'; + } + } + + const initialBalance = Number.isFinite(userData?.balance_kopeks) + ? userData.balance_kopeks + : Number.isFinite(userData?.balance_rubles) + ? Math.round(userData.balance_rubles * 100) + : 0; + + session.initialBalanceKopeks = initialBalance; + session.lastKnownBalanceKopeks = initialBalance; + session.initialTransactionIds = new Set( + Array.isArray(userData?.transactions) + ? userData.transactions.map(tx => tx.id) + : [] + ); + session.statusChecks = 0; + session.balanceChecks = 0; + + return session; + } + + function createPaymentSession(method, response, requestedAmountKopeks) { + if (!method) { + return null; + } + + const extra = + response && typeof response.extra === 'object' && response.extra !== null + ? { ...response.extra } + : {}; + + const amount = Number.isFinite(response?.amount_kopeks) + ? response.amount_kopeks + : Number.isFinite(requestedAmountKopeks) + ? requestedAmountKopeks + : null; + + const session = { + method, + amountKopeks: amount, + paymentUrl: response?.payment_url || response?.paymentUrl || null, + extra, + tracking: extractTrackingData(method, extra), + status: 'pending', + statusMessageKey: undefined, + }; + + return preparePaymentSession(session); + } + + function getPaymentStatusIcon(type) { + switch (type) { + case 'success': + return '✅'; + case 'error': + case 'warning': + return '⚠️'; + default: + return '⏳'; + } + } + + function updatePaymentStatusDisplay(options = {}) { + const statusElement = document.getElementById('topupStatusMessage'); + if (!statusElement) { + return; + } + + const { type = 'pending', key, fallback } = options; + statusElement.className = `payment-status payment-status-${type}`; + + const iconElement = document.getElementById('topupStatusIcon'); + if (iconElement) { + iconElement.textContent = getPaymentStatusIcon(type); + } + + const textElement = document.getElementById('topupStatusText'); + if (textElement) { + const translation = key ? t(key) : ''; + textElement.textContent = translation && translation !== key + ? translation + : (fallback || ''); + } + } + + function supportsManualStatusCheck(session) { + if (!session || !session.method?.id) { + return false; + } + if (session.method.id === 'tribute') { + return true; + } + if (shouldPollStatus(session)) { + return true; + } + return shouldPollBalance(session); + } + + function mapPaymentStatusToKey(status) { + const normalized = (status || '').toLowerCase(); + switch (normalized) { + case 'waiting_for_capture': + case 'pending': + case 'process': + case 'processing': + case 'new': + case 'active': + return 'topup.status.waiting'; + case 'not_found': + return 'topup.status.not_found'; + case 'expired': + return 'topup.status.expired'; + case 'manual_check': + return 'topup.status.manual_check'; + default: + return null; + } + } + + function getPaymentStatusPayload(session) { + if (!session || !session.method?.id) { + return null; + } + + const payload = { + initData: tg.initData || '', + method: session.method.id, + }; + + const tracking = session.tracking || {}; + if (tracking.local_payment_id !== undefined) { + payload.localPaymentId = tracking.local_payment_id; + } + if (tracking.payment_id) { + payload.paymentId = tracking.payment_id; + } + if (tracking.invoice_id) { + payload.invoiceId = tracking.invoice_id; + } + if (tracking.bill_id) { + payload.billId = tracking.bill_id; + } + if (tracking.uuid) { + payload.uuid = tracking.uuid; + } + + return payload; + } + + function updateTopupFooterForSession(session) { + if (!session) { + setTopupFooter([ + { + labelKey: 'topup.cancel', + fallbackLabel: 'Close', + variant: 'secondary', + onClick: closeTopupModal, + }, + ]); + return; + } + + const buttons = []; + + if (session.status === 'pending') { + if (session.method?.requires_amount) { + buttons.push({ + labelKey: 'topup.back', + fallbackLabel: 'Back', + variant: 'secondary', + onClick: renderTopupMethodsView, + }); + } + + if (supportsManualStatusCheck(session)) { + buttons.push({ + id: 'topupCheckStatusBtn', + labelKey: 'topup.status.button.check', + fallbackLabel: 'Check status', + variant: 'primary', + onClick: () => { + if (!activePaymentSession || activePaymentSession.completed) { + return; + } + const targetSession = activePaymentSession; + const useBalancePolling = shouldPollBalance(targetSession) + && !shouldPollStatus(targetSession); + if (useBalancePolling) { + pollBalanceForPayment(true); + } else { + pollPaymentStatus(true); + } + }, + }); + } + + buttons.push({ + labelKey: 'topup.cancel', + fallbackLabel: 'Close', + variant: 'secondary', + onClick: closeTopupModal, + }); + } else if (session.status === 'success') { + buttons.push({ + labelKey: 'topup.again', + fallbackLabel: 'Top up again', + variant: 'secondary', + onClick: () => { + resetActivePaymentSession(); + renderTopupMethodsView(); + }, + }); + buttons.push({ + labelKey: 'topup.close', + fallbackLabel: 'Close', + variant: 'primary', + onClick: closeTopupModal, + }); + } else { + if (session.method?.requires_amount) { + buttons.push({ + labelKey: 'topup.back', + fallbackLabel: 'Back', + variant: 'secondary', + onClick: renderTopupMethodsView, + }); + } + buttons.push({ + labelKey: 'topup.cancel', + fallbackLabel: 'Close', + variant: 'secondary', + onClick: closeTopupModal, + }); + } + + setTopupFooter(buttons); + } + + function createPaymentActions(session) { + if (!session || session.status !== 'pending') { + return null; + } + + const methodId = session.method?.id; + const container = document.createElement('div'); + container.className = 'payment-actions'; + + const extra = session.extra || {}; + const links = typeof extra.links === 'object' && extra.links !== null + ? { ...extra.links } + : {}; + + const sbpLink = links.sbp || extra.sbp_url || extra.transfer_url || null; + const cardLink = links.card || extra.card_url || null; + const pageLink = links.page || links.default || extra.link_page_url || session.paymentUrl || null; + const defaultLink = session.paymentUrl || links.default || extra.link_url || null; + + const appendActionButton = ({ key, fallback, url, variant = 'primary' }) => { + if (!url) { + return; + } + const button = document.createElement('button'); + button.type = 'button'; + button.className = `modal-button ${variant}`; + const label = key ? t(key) : ''; + button.textContent = label && label !== key ? label : (fallback || key || 'Open'); + button.addEventListener('click', () => openExternalLink(url)); + container.appendChild(button); + }; + + if (methodId === 'pal24') { + if (sbpLink) { + appendActionButton({ + key: 'topup.method.pal24.button.sbp', + fallback: 'Pay via SBP', + url: sbpLink, + variant: 'primary', + }); + } + + if (cardLink && cardLink !== sbpLink) { + appendActionButton({ + key: 'topup.method.pal24.button.card', + fallback: 'Pay by card', + url: cardLink, + variant: sbpLink ? 'secondary' : 'primary', + }); + } + + if (pageLink && pageLink !== sbpLink && pageLink !== cardLink) { + appendActionButton({ + key: 'topup.method.pal24.button.page', + fallback: 'Open payment page', + url: pageLink, + variant: container.childElementCount ? 'secondary' : 'primary', + }); + } + } else { + const urlToOpen = defaultLink; + if (urlToOpen) { + appendActionButton({ + key: 'topup.open_link', + fallback: 'Open payment page', + url: urlToOpen, + variant: 'primary', + }); + } + } + + return container.childElementCount ? container : null; + } + + function renderTopupPaymentSession(session = activePaymentSession) { + if (!session) { + return; + } + + activePaymentSession = session; + clearTopupError(); + + const method = session.method || {}; + setTopupModalTitle('topup.title', 'Top up balance'); + + const subtitleKey = `topup.method.${method.id}.subtitle`; + let subtitleFallback = 'Complete the payment on the opened page'; + if (method.id === 'pal24') { + subtitleFallback = 'Choose how you want to pay'; + } else if (method.id === 'tribute') { + subtitleFallback = 'Complete the payment on the external page'; + } else if (method.id === 'stars') { + subtitleFallback = 'Complete the payment with Telegram Stars'; + } + setTopupModalSubtitle(subtitleKey, subtitleFallback); + + const { body } = getTopupElements(); + if (!body) { + return; + } + + body.innerHTML = ''; + + const summary = document.createElement('div'); + summary.className = 'payment-summary'; + + const titleKey = `topup.method.${method.id}.title`; + const titleValue = t(titleKey); + const title = document.createElement('div'); + title.className = 'payment-method-label'; + title.textContent = titleValue && titleValue !== titleKey + ? titleValue + : (method.id || 'Payment'); + summary.appendChild(title); + + if (Number.isFinite(session.amountKopeks) && session.amountKopeks > 0) { + const amount = document.createElement('div'); + amount.className = 'payment-summary-amount'; + const currency = (method.currency || userData?.balance_currency || 'RUB').toUpperCase(); + amount.textContent = formatCurrency(session.amountKopeks / 100, currency); + summary.appendChild(amount); + } + + const amountUsd = Number.isFinite(session.extra?.amount_usd) + ? session.extra.amount_usd + : null; + if (amountUsd) { + const usdHint = document.createElement('div'); + usdHint.className = 'amount-hint'; + usdHint.textContent = `≈ ${formatCurrency(amountUsd, 'USD')}`; + summary.appendChild(usdHint); + } + + const descriptionKey = `topup.method.${method.id}.description`; + const descriptionValue = t(descriptionKey); + if (descriptionValue && descriptionValue !== descriptionKey) { + const description = document.createElement('div'); + description.className = 'amount-hint'; + description.textContent = descriptionValue; + summary.appendChild(description); + } + + body.appendChild(summary); + + const actions = createPaymentActions(session); + if (actions) { + body.appendChild(actions); + } + + const statusWrapper = document.createElement('div'); + statusWrapper.id = 'topupStatusMessage'; + const statusType = session.status === 'success' + ? 'success' + : session.status === 'failed' || session.status === 'error' + ? 'error' + : session.status === 'warning' + ? 'warning' + : 'pending'; + statusWrapper.className = `payment-status payment-status-${statusType}`; + + const statusIcon = document.createElement('span'); + statusIcon.id = 'topupStatusIcon'; + statusIcon.className = 'status-icon'; + statusWrapper.appendChild(statusIcon); + + const statusText = document.createElement('span'); + statusText.id = 'topupStatusText'; + statusText.className = 'status-text'; + statusWrapper.appendChild(statusText); + + body.appendChild(statusWrapper); + + updatePaymentStatusDisplay({ + type: statusType, + key: session.statusMessageKey, + fallback: 'Waiting for payment confirmation…', + }); + + updateTopupFooterForSession(session); + } + + function beginPaymentTracking(session) { + if (!session) { + return; + } + stopPaymentTracking(); + if (session.completed) { + return; + } + + if (shouldPollStatus(session)) { + schedulePaymentStatusPoll(false); + } else if (shouldPollBalance(session)) { + scheduleBalancePoll(false); + } + } + + function schedulePaymentStatusPoll(immediate = false) { + if (!activePaymentSession || activePaymentSession.completed) { + return; + } + const delay = immediate + ? 0 + : (activePaymentSession.statusChecks ? PAYMENT_STATUS_INTERVAL : PAYMENT_STATUS_INITIAL_DELAY); + if (paymentStatusTimer) { + clearTimeout(paymentStatusTimer); + } + paymentStatusTimer = setTimeout(() => { + pollPaymentStatus(false); + }, delay); + } + + async function pollPaymentStatus(manual = false) { + if (!activePaymentSession || activePaymentSession.completed) { + return; + } + + const session = activePaymentSession; + if (!shouldPollStatus(session)) { + return; + } + + const payload = getPaymentStatusPayload(session); + if (!payload || !payload.initData) { + return; + } + + if (manual) { + const button = document.getElementById('topupCheckStatusBtn'); + if (button) { + button.disabled = true; + } + } + + updatePaymentStatusDisplay({ + type: 'pending', + key: manual ? 'topup.status.checking' : session.statusMessageKey, + fallback: 'Checking payment status…', + }); + + try { + const response = await fetch('/miniapp/payments/status', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }); + + if (!response.ok) { + throw new Error(`Status ${response.status}`); + } + + const data = await response.json(); + if (session !== activePaymentSession || session.completed) { + return; + } + + session.statusChecks = (session.statusChecks || 0) + 1; + + const status = (data?.status || '').toLowerCase(); + const isPaid = Boolean(data?.is_paid); + const isPending = Boolean(data?.is_pending); + + if (isPaid) { + await handlePaymentSuccess(session, data); + return; + } + + const failureStatuses = new Set([ + 'failed', + 'canceled', + 'cancelled', + 'declined', + 'rejected', + 'expired', + 'error', + ]); + + if (failureStatuses.has(status) && !isPending) { + handlePaymentFailure(session, status, data); + return; + } + + const mapped = mapPaymentStatusToKey(status); + if (mapped) { + session.statusMessageKey = mapped; + } + + updatePaymentStatusDisplay({ + type: 'pending', + key: session.statusMessageKey, + fallback: 'Waiting for payment confirmation…', + }); + + if (Date.now() - session.createdAt > PAYMENT_STATUS_TIMEOUT) { + session.status = 'warning'; + session.statusMessageKey = 'topup.status.manual_check'; + updatePaymentStatusDisplay({ + type: 'warning', + key: session.statusMessageKey, + fallback: 'Return to this window after paying to check the status.', + }); + updateTopupFooterForSession(session); + stopPaymentTracking(); + return; + } + + schedulePaymentStatusPoll(false); + } catch (error) { + console.error('Failed to poll payment status:', error); + if (!activePaymentSession || activePaymentSession !== session || session.completed) { + return; + } + + updatePaymentStatusDisplay({ + type: 'warning', + key: 'topup.status.error', + fallback: 'Unable to check payment status. Please try again.', + }); + + if (Date.now() - session.createdAt <= PAYMENT_STATUS_TIMEOUT) { + schedulePaymentStatusPoll(false); + } + } finally { + if (manual) { + const button = document.getElementById('topupCheckStatusBtn'); + if (button) { + button.disabled = false; + } + } + } + } + + function scheduleBalancePoll(immediate = false) { + if (!activePaymentSession || activePaymentSession.completed) { + return; + } + + const delay = immediate + ? 0 + : (activePaymentSession.balanceChecks ? BALANCE_POLL_INTERVAL : BALANCE_POLL_INITIAL_DELAY); + + if (paymentBalanceTimer) { + clearTimeout(paymentBalanceTimer); + } + + paymentBalanceTimer = setTimeout(() => { + pollBalanceForPayment(false); + }, delay); + } + + async function pollBalanceForPayment(manual = false) { + if (!activePaymentSession || activePaymentSession.completed) { + return; + } + + const session = activePaymentSession; + if (!shouldPollBalance(session)) { + return; + } + + if (manual) { + const button = document.getElementById('topupCheckStatusBtn'); + if (button) { + button.disabled = true; + } + } + + try { + session.balanceChecks = (session.balanceChecks || 0) + 1; + + const previousBalance = session.lastKnownBalanceKopeks ?? session.initialBalanceKopeks ?? 0; + const previousIds = session.initialTransactionIds || new Set(); + + await refreshSubscriptionData({ silent: true }); + + const currentBalance = Number.isFinite(userData?.balance_kopeks) + ? userData.balance_kopeks + : previousBalance; + session.lastKnownBalanceKopeks = currentBalance; + + const transactions = Array.isArray(userData?.transactions) ? userData.transactions : []; + const expectedAmount = Number.isFinite(session.amountKopeks) ? session.amountKopeks : 0; + + const newDeposit = transactions.find(tx => { + if (previousIds.has(tx.id)) { + return false; + } + if (tx.type !== 'deposit') { + return false; + } + if (session.method?.id && tx.payment_method && tx.payment_method !== session.method.id) { + return false; + } + const createdAt = tx.created_at ? new Date(tx.created_at).getTime() : 0; + return createdAt >= session.createdAt - 1000; + }); + + const balanceIncreased = currentBalance > previousBalance + && (expectedAmount <= 0 + || currentBalance - previousBalance >= Math.max(Math.floor(expectedAmount * 0.5), 1)); + + if (balanceIncreased || newDeposit) { + await handlePaymentSuccess(session, { status: 'paid', source: 'balance_poll' }); + return; + } + + session.statusMessageKey = session.method?.id === 'tribute' + ? 'topup.status.tribute.pending' + : (session.statusMessageKey || 'topup.status.waiting'); + + updatePaymentStatusDisplay({ + type: 'pending', + key: session.statusMessageKey, + fallback: 'Waiting for payment confirmation…', + }); + + if (Date.now() - session.createdAt > BALANCE_POLL_TIMEOUT) { + session.status = 'warning'; + session.statusMessageKey = 'topup.status.manual_check'; + updatePaymentStatusDisplay({ + type: 'warning', + key: session.statusMessageKey, + fallback: 'Return to this window after paying to check the status.', + }); + updateTopupFooterForSession(session); + stopPaymentTracking(); + return; + } + + session.initialTransactionIds = new Set(transactions.map(tx => tx.id)); + scheduleBalancePoll(false); + } catch (error) { + console.error('Failed to poll balance status:', error); + if (Date.now() - session.createdAt <= BALANCE_POLL_TIMEOUT) { + scheduleBalancePoll(false); + } + } finally { + if (manual) { + const button = document.getElementById('topupCheckStatusBtn'); + if (button) { + button.disabled = false; + } + } + } + } + + async function handlePaymentSuccess(session, data) { + if (!session || session.completed) { + return; + } + + session.completed = true; + session.status = 'success'; + session.statusMessageKey = 'topup.status.refreshing'; + + stopPaymentTracking(); + renderTopupPaymentSession(session); + + updatePaymentStatusDisplay({ + type: 'pending', + key: session.statusMessageKey, + fallback: 'Payment confirmed! Updating balance…', + }); + + try { + await refreshSubscriptionData({ silent: true }); + session.statusMessageKey = session.method?.id === 'tribute' + ? 'topup.status.tribute.success' + : 'topup.status.paid'; + updatePaymentStatusDisplay({ + type: 'success', + key: session.statusMessageKey, + fallback: 'Payment received! Your balance has been updated.', + }); + } catch (error) { + console.error('Failed to refresh data after payment:', error); + session.statusMessageKey = 'topup.status.paid'; + updatePaymentStatusDisplay({ + type: 'success', + key: session.statusMessageKey, + fallback: 'Payment received!', + }); + } + + updateTopupFooterForSession(session); + } + + function handlePaymentFailure(session, status, data) { + if (!session || session.completed) { + return; + } + + session.completed = true; + session.status = 'failed'; + stopPaymentTracking(); + + const normalized = (status || '').toLowerCase(); + if (normalized === 'expired') { + session.statusMessageKey = 'topup.status.expired'; + } else if (normalized === 'not_found') { + session.statusMessageKey = 'topup.status.not_found'; + } else { + session.statusMessageKey = 'topup.status.failed'; + } + + renderTopupPaymentSession(session); + updatePaymentStatusDisplay({ + type: 'error', + key: session.statusMessageKey, + fallback: 'Payment failed or was cancelled.', + }); + updateTopupFooterForSession(session); + } + + function handlePaymentCreated(method, requestedAmountKopeks, response) { + const session = createPaymentSession(method, response, requestedAmountKopeks); + if (!session) { + return; + } + + stopPaymentTracking(); + activePaymentSession = session; + renderTopupPaymentSession(session); + beginPaymentTracking(session); + } + + function handleInvoiceClosedEvent(event) { + if (!event || !activePaymentSession || activePaymentSession.method?.id !== 'stars') { + return; + } + + const status = (event.status || '').toLowerCase(); + if (status === 'paid') { + handlePaymentSuccess(activePaymentSession, { status: 'paid', source: 'invoice_closed' }); + return; + } + + if (['failed', 'canceled', 'cancelled'].includes(status)) { + handlePaymentFailure(activePaymentSession, status, event); + return; + } + + if (status === 'pending') { + activePaymentSession.statusMessageKey = 'topup.status.waiting'; + updatePaymentStatusDisplay({ + type: 'pending', + key: activePaymentSession.statusMessageKey, + fallback: 'Waiting for payment confirmation…', + }); + } + } + + async function loadPaymentMethods(force = false) { + if (!force && paymentMethodsCache) { + return paymentMethodsCache; + } + if (!force && paymentMethodsPromise) { + return paymentMethodsPromise; + } + + const initData = tg.initData || ''; + if (!initData) { + throw new Error('Missing init data'); + } + + paymentMethodsPromise = fetch('/miniapp/payments/methods', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ initData }), + }).then(async response => { + if (!response.ok) { + const payload = await response.json().catch(() => ({})); + const detail = payload?.detail; + throw new Error(typeof detail === 'string' ? detail : 'Failed to load payment methods'); + } + const data = await response.json().catch(() => ({})); + return Array.isArray(data?.methods) ? data.methods : []; + }).finally(() => { + paymentMethodsPromise = null; + }); + + try { + paymentMethodsCache = await paymentMethodsPromise; + } catch (error) { + paymentMethodsCache = null; + throw error; + } + + return paymentMethodsCache; + } + + function openTopupModal() { + const { backdrop } = getTopupElements(); + if (!backdrop) { + return; + } + backdrop.classList.remove('hidden'); + document.body.classList.add('modal-open'); + renderTopupMethodsView(); + } + + function closeTopupModal() { + const { backdrop } = getTopupElements(); + if (backdrop) { + backdrop.classList.add('hidden'); + } + document.body.classList.remove('modal-open'); + activePaymentMethod = null; + resetActivePaymentSession(); + clearTopupError(); + } + + function renderTopupLoading(messageKey = 'topup.loading') { + const { body } = getTopupElements(); + if (!body) { + return; + } + body.innerHTML = ''; + const loadingText = document.createElement('div'); + loadingText.className = 'amount-hint'; + const text = t(messageKey); + loadingText.textContent = text === messageKey ? 'Loading…' : text; + body.appendChild(loadingText); + } + + async function renderTopupMethodsView() { + resetActivePaymentSession(); + activePaymentMethod = null; + clearTopupError(); + setTopupModalTitle('topup.title', 'Top up balance'); + setTopupModalSubtitle('topup.subtitle', 'Choose a payment method'); + setTopupFooter([ + { + labelKey: 'topup.cancel', + fallbackLabel: 'Close', + variant: 'secondary', + onClick: closeTopupModal, + }, + ]); + + try { + renderTopupLoading('topup.loading'); + const methods = await loadPaymentMethods(); + const { body } = getTopupElements(); + if (!body) { + return; + } + body.innerHTML = ''; + + if (!methods.length) { + const empty = document.createElement('div'); + empty.className = 'amount-hint'; + const message = t('topup.error.unavailable'); + empty.textContent = message === 'topup.error.unavailable' + ? 'Payment methods are temporarily unavailable.' + : message; + body.appendChild(empty); + return; + } + + const list = document.createElement('div'); + list.className = 'payment-methods-list'; + + methods.forEach(method => { + const card = createPaymentMethodCard(method); + if (card) { + list.appendChild(card); + } + }); + + body.appendChild(list); + } catch (error) { + console.error('Failed to load payment methods:', error); + const { body } = getTopupElements(); + if (!body) { + return; + } + body.innerHTML = ''; + const errorMessage = document.createElement('div'); + errorMessage.className = 'amount-hint'; + const text = t('topup.error.unavailable'); + errorMessage.textContent = text === 'topup.error.unavailable' + ? 'Payment methods are temporarily unavailable.' + : text; + body.appendChild(errorMessage); + } + } + + function createPaymentMethodCard(method) { + if (!method || !method.id) { + return null; + } + const button = document.createElement('button'); + button.type = 'button'; + button.className = 'payment-method-card'; + button.dataset.methodId = method.id; + + const info = document.createElement('div'); + info.className = 'payment-method-info'; + + const icon = document.createElement('div'); + icon.className = 'payment-method-icon'; + icon.textContent = method.icon || '💳'; + + const textContainer = document.createElement('div'); + textContainer.className = 'payment-method-text'; + + const label = document.createElement('div'); + label.className = 'payment-method-label'; + const labelKey = `topup.method.${method.id}.title`; + const labelValue = t(labelKey); + label.textContent = labelValue === labelKey ? method.id : labelValue; + + const description = document.createElement('div'); + description.className = 'payment-method-description'; + const descriptionKey = `topup.method.${method.id}.description`; + const descriptionValue = t(descriptionKey); + if (descriptionValue && descriptionValue !== descriptionKey) { + description.textContent = descriptionValue; + textContainer.appendChild(description); + } + + textContainer.insertBefore(label, textContainer.firstChild); + + info.appendChild(icon); + info.appendChild(textContainer); + button.appendChild(info); + + button.addEventListener('click', () => handlePaymentMethodSelection(method)); + + return button; + } + + function handlePaymentMethodSelection(method) { + if (!method) { + return; + } + if (method.requires_amount) { + renderTopupAmountForm(method); + } else { + startPaymentForMethod(method); + } + } + + function renderTopupAmountForm(method) { + activePaymentMethod = method; + clearTopupError(); + setTopupModalTitle('topup.amount.title', 'Enter amount'); + setTopupModalSubtitle('topup.amount.subtitle', 'Specify how much you want to top up'); + + const { body } = getTopupElements(); + if (!body) { + return; + } + + body.innerHTML = ''; + const form = document.createElement('form'); + form.className = 'amount-form'; + form.id = 'topupAmountForm'; + + const currency = (method.currency || userData?.balance_currency || 'RUB').toUpperCase(); + + const input = document.createElement('input'); + input.type = 'text'; + input.id = 'topupAmountInput'; + input.className = 'amount-input'; + input.autocomplete = 'off'; + input.inputMode = 'decimal'; + input.placeholder = (t('topup.amount.placeholder') || 'Amount').replace('{currency}', currency); + + form.appendChild(input); + + const hint = document.createElement('div'); + hint.className = 'amount-hint'; + const limitsText = buildAmountHint(method, currency); + if (limitsText) { + hint.textContent = limitsText; + form.appendChild(hint); + } + + form.addEventListener('submit', event => { + event.preventDefault(); + submitTopupAmount(method); + }); + + body.appendChild(form); + + setTopupFooter([ + { + labelKey: 'topup.back', + fallbackLabel: 'Back', + variant: 'secondary', + onClick: renderTopupMethodsView, + }, + { + labelKey: 'topup.submit', + fallbackLabel: 'Continue', + variant: 'primary', + id: 'topupSubmitButton', + onClick: () => submitTopupAmount(method), + }, + ]); + + input.focus({ preventScroll: true }); + } + + function buildAmountHint(method, currency) { + const min = Number.isFinite(method?.min_amount_kopeks) + ? method.min_amount_kopeks + : null; + const max = Number.isFinite(method?.max_amount_kopeks) + ? method.max_amount_kopeks + : null; + + if (min && max) { + const template = t('topup.amount.hint.range'); + const minLabel = formatCurrency(min / 100, currency); + const maxLabel = formatCurrency(max / 100, currency); + if (template && template !== 'topup.amount.hint.range') { + return template.replace('{min}', minLabel).replace('{max}', maxLabel); + } + return `Available range: ${minLabel} — ${maxLabel}`; + } + if (min) { + const template = t('topup.amount.hint.single_min'); + const minLabel = formatCurrency(min / 100, currency); + if (template && template !== 'topup.amount.hint.single_min') { + return template.replace('{min}', minLabel); + } + return `Minimum top-up: ${minLabel}`; + } + if (max) { + const template = t('topup.amount.hint.single_max'); + const maxLabel = formatCurrency(max / 100, currency); + if (template && template !== 'topup.amount.hint.single_max') { + return template.replace('{max}', maxLabel); + } + return `Maximum top-up: ${maxLabel}`; + } + return ''; + } + + function parseAmountInput(value) { + if (typeof value !== 'string') { + return NaN; + } + const normalized = value.replace(',', '.').replace(/[^0-9.]/g, ''); + return Number.parseFloat(normalized); + } + + async function submitTopupAmount(method) { + const input = document.getElementById('topupAmountInput'); + if (!input) { + return; + } + const rawValue = input.value.trim(); + const numeric = parseAmountInput(rawValue); + if (!Number.isFinite(numeric) || numeric <= 0) { + showTopupError('topup.error.amount', 'Enter a valid amount.'); + input.focus({ preventScroll: true }); + return; + } + + const amountKopeks = Math.round(numeric * 100); + const min = Number.isFinite(method?.min_amount_kopeks) ? method.min_amount_kopeks : null; + const max = Number.isFinite(method?.max_amount_kopeks) ? method.max_amount_kopeks : null; + + if ((min && amountKopeks < min) || (max && amountKopeks > max)) { + showTopupError('topup.error.amount', 'Enter a valid amount.'); + input.focus({ preventScroll: true }); + return; + } + + clearTopupError(); + await startPaymentForMethod(method, amountKopeks, { rawInput: rawValue }); + } + + async function startPaymentForMethod(method, amountKopeks = null, options = {}) { + clearTopupError(); + resetActivePaymentSession(); + renderTopupLoading(); + + const footerButtons = []; + if (method?.requires_amount) { + footerButtons.push({ + labelKey: 'topup.back', + fallbackLabel: 'Back', + variant: 'secondary', + onClick: renderTopupMethodsView, + }); + } + footerButtons.push({ + labelKey: 'topup.cancel', + fallbackLabel: 'Close', + variant: 'secondary', + onClick: closeTopupModal, + }); + setTopupFooter(footerButtons); + + const initData = tg.initData || ''; + if (!initData) { + showTopupError('topup.error.generic', 'Unable to start payment.'); + if (method?.requires_amount) { + renderTopupAmountForm(method); + const input = document.getElementById('topupAmountInput'); + if (input && options.rawInput) { + input.value = options.rawInput; + } + } + return; + } + + const payload = { + initData, + method: method.id, + }; + if (Number.isFinite(amountKopeks) && amountKopeks > 0) { + payload.amountKopeks = amountKopeks; + } + + try { + const response = await fetch('/miniapp/payments/create', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }); + + const data = await response.json().catch(() => ({})); + + if (!response.ok) { + const detail = data?.detail; + const message = typeof detail === 'string' ? detail : null; + throw new Error(message || 'Failed to create payment'); + } + + const extra = data?.extra && typeof data.extra === 'object' ? data.extra : {}; + const paymentUrl = data?.payment_url || data?.paymentUrl || null; + const hasLinkOption = Boolean( + paymentUrl + || extra.sbp_url + || extra.card_url + || extra.link_url + || extra.link_page_url + || (extra.links && Object.keys(extra.links).length) + ); + + if (!hasLinkOption) { + throw new Error('Payment link is missing'); + } + + handlePaymentCreated(method, data?.amount_kopeks ?? amountKopeks, data); + } catch (error) { + console.error('Failed to create payment:', error); + resetActivePaymentSession(); + if (method?.requires_amount) { + renderTopupAmountForm(method); + const input = document.getElementById('topupAmountInput'); + if (input && options.rawInput) { + input.value = options.rawInput; + } + showTopupError('topup.error.generic', error?.message || 'Unable to start payment.'); + } else { + await renderTopupMethodsView(); + showTopupError('topup.error.generic', error?.message || 'Unable to start payment.'); + } + } + } + function updateReferralToggleState() { const list = document.getElementById('referralList'); const empty = document.getElementById('referralListEmpty'); @@ -6758,6 +8594,31 @@ openExternalLink(link); }); + const topupButton = document.getElementById('topupBalanceBtn'); + if (topupButton) { + topupButton.addEventListener('click', () => { + openTopupModal(); + }); + } + + const topupModal = document.getElementById('topupModal'); + if (topupModal) { + topupModal.addEventListener('click', event => { + if (event.target === topupModal) { + closeTopupModal(); + } + }); + } + + window.addEventListener('keydown', event => { + if (event.key === 'Escape') { + const { backdrop } = getTopupElements(); + if (backdrop && !backdrop.classList.contains('hidden')) { + closeTopupModal(); + } + } + }); + document.getElementById('copyBtn')?.addEventListener('click', async () => { const subscriptionUrl = getCurrentSubscriptionUrl(); if (!subscriptionUrl || !navigator.clipboard) {