From bdeff1dbad719ad70698b4b00043a7dc023acc21 Mon Sep 17 00:00:00 2001 From: Egor Date: Fri, 10 Oct 2025 03:36:48 +0300 Subject: [PATCH] Revert "Improve miniapp payment polling for Stars" --- app/webapi/routes/miniapp.py | 652 ------------ app/webapi/schemas/miniapp.py | 53 - miniapp/index.html | 1861 --------------------------------- 3 files changed, 2566 deletions(-) diff --git a/app/webapi/routes/miniapp.py b/app/webapi/routes/miniapp.py index fad374cb..b1043837 100644 --- a/app/webapi/routes/miniapp.py +++ b/app/webapi/routes/miniapp.py @@ -2,11 +2,9 @@ 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 @@ -25,23 +23,6 @@ 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, @@ -57,12 +38,9 @@ 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, @@ -83,13 +61,6 @@ from ..schemas.miniapp import ( MiniAppFaq, MiniAppFaqItem, MiniAppLegalDocuments, - MiniAppPaymentCreateRequest, - MiniAppPaymentCreateResponse, - MiniAppPaymentMethod, - MiniAppPaymentMethodsRequest, - MiniAppPaymentMethodsResponse, - MiniAppPaymentStatusRequest, - MiniAppPaymentStatusResponse, MiniAppPromoCode, MiniAppPromoCodeActivationRequest, MiniAppPromoCodeActivationResponse, @@ -142,629 +113,6 @@ 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 563150d6..8afed075 100644 --- a/app/webapi/schemas/miniapp.py +++ b/app/webapi/schemas/miniapp.py @@ -253,59 +253,6 @@ 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 f0f80e0b..2583a5dd 100644 --- a/miniapp/index.html +++ b/miniapp/index.html @@ -78,10 +78,6 @@ line-height: 1.6; } - body.modal-open { - overflow: hidden; - } - .container { max-width: 480px; margin: 0 auto; @@ -1064,9 +1060,6 @@ .balance-content { padding: 20px; text-align: center; - display: flex; - flex-direction: column; - align-items: center; } .balance-amount { @@ -1087,287 +1080,6 @@ 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; @@ -2789,26 +2501,6 @@ 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; } @@ -2876,24 +2568,6 @@ 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); @@ -3125,26 +2799,6 @@
Balance
-
- -
-
- - - @@ -3451,7 +3105,6 @@ applyTheme(tg.colorScheme); } }); - tg.onEvent('invoiceClosed', handleInvoiceClosedEvent); } if (window.matchMedia) { @@ -3499,56 +3152,6 @@ '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', @@ -3731,56 +3334,6 @@ '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': 'Активировать промокод', @@ -4056,19 +3609,6 @@ 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) { @@ -5729,1382 +5269,6 @@ 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'); @@ -8594,31 +6758,6 @@ 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) {