From afa65e0fbde91079ff5fdef5d146cea5635386dc Mon Sep 17 00:00:00 2001 From: Egor Date: Fri, 10 Oct 2025 03:31:06 +0300 Subject: [PATCH] Revert "Align miniapp payment limits and status UX" --- app/services/payment_service.py | 3 +- app/webapi/routes/miniapp.py | 736 +------------- app/webapi/schemas/miniapp.py | 63 -- miniapp/index.html | 1616 ------------------------------- 4 files changed, 2 insertions(+), 2416 deletions(-) diff --git a/app/services/payment_service.py b/app/services/payment_service.py index 660274db..623790eb 100644 --- a/app/services/payment_service.py +++ b/app/services/payment_service.py @@ -828,7 +828,6 @@ class PaymentService: language: str, ttl_seconds: Optional[int] = None, payer_email: Optional[str] = None, - payment_method: str = "SBP", ) -> Optional[Dict[str, Any]]: if not self.pal24_service or not self.pal24_service.is_configured: @@ -868,7 +867,7 @@ class PaymentService: ttl_seconds=ttl_seconds, custom_payload=custom_payload, payer_email=payer_email, - payment_method=payment_method, + payment_method="SBP", ) except Pal24APIError as error: logger.error("Ошибка Pal24 API при создании счета: %s", error) diff --git a/app/webapi/routes/miniapp.py b/app/webapi/routes/miniapp.py index db2317e6..b1043837 100644 --- a/app/webapi/routes/miniapp.py +++ b/app/webapi/routes/miniapp.py @@ -2,13 +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 and_, select +from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload @@ -31,8 +29,6 @@ from app.database.models import ( Subscription, SubscriptionTemporaryAccess, Transaction, - TransactionType, - PaymentMethod, User, ) from app.services.faq_service import FaqService @@ -42,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, @@ -68,14 +61,6 @@ from ..schemas.miniapp import ( MiniAppFaq, MiniAppFaqItem, MiniAppLegalDocuments, - MiniAppPaymentCreateRequest, - MiniAppPaymentCreateResponse, - MiniAppPaymentMethod, - MiniAppPaymentOption, - MiniAppPaymentMethodsRequest, - MiniAppPaymentMethodsResponse, - MiniAppPaymentStatusRequest, - MiniAppPaymentStatusResponse, MiniAppPromoCode, MiniAppPromoCodeActivationRequest, MiniAppPromoCodeActivationResponse, @@ -104,20 +89,6 @@ router = APIRouter() promo_code_service = PromoCodeService() -_CRYPTOBOT_STATUS_LABELS = { - "paid": "paid", - "active": "waiting_payment", - "created": "waiting_payment", - "expired": "expired", - "failed": "failed", - "cancelled": "cancelled", - "canceled": "cancelled", -} - -_CRYPTOBOT_FAILED_STATUSES = {"failed", "expired", "cancelled", "canceled"} -_CRYPTOBOT_FINAL_STATUSES = _CRYPTOBOT_FAILED_STATUSES | {"paid"} - - def _format_gb(value: Optional[float]) -> float: if value is None: return 0.0 @@ -142,711 +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 - - -@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(): - cryptobot_rate: Optional[Decimal] = None - try: - rate_value = await currency_converter.get_usd_to_rub_rate() - if rate_value: - cryptobot_rate = Decimal(str(rate_value)) - except Exception: - cryptobot_rate = None - - if not cryptobot_rate or cryptobot_rate <= 0: - cryptobot_rate = Decimal("95") - - min_rubles = (Decimal("1") * cryptobot_rate).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP) - max_rubles = (Decimal("1000") * cryptobot_rate).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP) - - min_amount_kopeks = int((min_rubles * 100).to_integral_value(rounding=ROUND_HALF_UP)) - max_amount_kopeks = int((max_rubles * 100).to_integral_value(rounding=ROUND_HALF_UP)) - - methods.append( - MiniAppPaymentMethod( - id="cryptobot", - icon="🪙", - requires_amount=True, - currency="RUB", - min_amount_kopeks=min_amount_kopeks, - max_amount_kopeks=max_amount_kopeks, - ) - ) - - if settings.TRIBUTE_ENABLED: - methods.append( - MiniAppPaymentMethod( - id="tribute", - icon="💎", - requires_amount=False, - currency="RUB", - ) - ) - - if settings.is_pal24_enabled(): - for method in methods: - if method.id == "pal24": - method.options = [ - MiniAppPaymentOption(id="sbp", icon="🏦", label="SBP"), - MiniAppPaymentOption(id="card", icon="💳", label="Card"), - ] - break - - 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, - ) - payment_option = (payload.payment_option or "").strip().lower() or None - - 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, - ) - - 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"), - }, - ) - - 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"), - }, - ) - - 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") - - selected_option = payment_option or "sbp" - if selected_option not in {"sbp", "card"}: - raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="Unsupported payment option") - - pal24_payment_method = "CARD" if selected_option == "card" else "SBP" - - 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, - payment_method=pal24_payment_method, - ) - if not result or not result.get("payment_url"): - raise HTTPException(status.HTTP_502_BAD_GATEWAY, detail="Failed to create payment") - - links = { - "sbp": result.get("sbp_url") or result.get("transfer_url"), - "card": result.get("card_url"), - "page": result.get("link_page_url"), - } - - preferred_url = None - if selected_option == "card": - preferred_url = links.get("card") or links.get("page") or links.get("sbp") - else: - preferred_url = links.get("sbp") or links.get("page") or links.get("card") - - if not preferred_url: - raise HTTPException(status.HTTP_502_BAD_GATEWAY, detail="Failed to obtain payment url") - - normalized_links = {key: value for key, value in links.items() if value} - - return MiniAppPaymentCreateResponse( - method=method, - payment_url=preferred_url, - amount_kopeks=amount_kopeks, - extra={ - "local_payment_id": result.get("local_payment_id"), - "bill_id": result.get("bill_id"), - "links": normalized_links, - "selected_option": selected_option, - }, - ) - - 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") - - try: - rate_value = await currency_converter.get_usd_to_rub_rate() - rate_decimal = Decimal(str(rate_value)) if rate_value else None - except Exception: - rate_decimal = None - - if not rate_decimal or rate_decimal <= 0: - rate_decimal = Decimal("95") - - min_rubles = (Decimal("1") * rate_decimal).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP) - max_rubles = (Decimal("1000") * rate_decimal).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP) - min_amount_kopeks = int((min_rubles * 100).to_integral_value(rounding=ROUND_HALF_UP)) - max_amount_kopeks = int((max_rubles * 100).to_integral_value(rounding=ROUND_HALF_UP)) - - 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") - - amount_rubles_decimal = Decimal(amount_kopeks) / Decimal(100) - amount_usd_decimal = (amount_rubles_decimal / rate_decimal).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP) - amount_usd = float(amount_usd_decimal) - rate = float(rate_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, - "min_amount_kopeks": min_amount_kopeks, - "max_amount_kopeks": max_amount_kopeks, - }, - ) - - 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={ - "payment_option": payment_option, - } if payment_option else {}, - ) - - 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", - ) - - status_code = "pending" - status_label: Optional[str] = None - is_paid = False - is_failed = False - is_final = False - updated_at: Optional[datetime] = None - details: Dict[str, Any] = {} - - payment_service = PaymentService() - - if method == "yookassa": - if payload.local_payment_id is None: - raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="Missing payment identifier") - - from app.database.crud.yookassa import get_yookassa_payment_by_local_id - - payment = await get_yookassa_payment_by_local_id(db, payload.local_payment_id) - if not payment: - return MiniAppPaymentStatusResponse( - method=method, - status="not_found", - status_label="not_found", - is_failed=True, - is_final=True, - details={"local_payment_id": payload.local_payment_id}, - ) - - status_code = payment.status or "pending" - status_label = status_code - is_paid = bool(payment.is_succeeded) - is_failed = bool(payment.is_failed) - is_final = is_paid or is_failed or status_code in {"canceled", "failed"} - updated_at = payment.updated_at or payment.created_at - details = { - "local_payment_id": payment.id, - "payment_id": payment.yookassa_payment_id, - "status": payment.status, - } - - elif method == "mulenpay": - if payload.local_payment_id is None: - raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="Missing payment identifier") - - status_info = await payment_service.get_mulenpay_payment_status(db, payload.local_payment_id) - if not status_info: - return MiniAppPaymentStatusResponse( - method=method, - status="not_found", - status_label="not_found", - is_failed=True, - is_final=True, - details={"local_payment_id": payload.local_payment_id}, - ) - - payment = status_info["payment"] - status_code = (payment.status or "unknown").lower() - status_label = payment.status - is_paid = bool(payment.is_paid) - is_failed = status_code in {"canceled", "error"} - is_final = is_paid or is_failed - updated_at = payment.updated_at or payment.created_at - details = { - "local_payment_id": payment.id, - "mulen_payment_id": payment.mulen_payment_id, - "status": payment.status, - "remote_status": status_info.get("remote_status"), - } - if payment.payment_url and not is_paid: - details["payment_url"] = payment.payment_url - - elif method == "pal24": - if payload.local_payment_id is None: - raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="Missing payment identifier") - - status_info = await payment_service.get_pal24_payment_status(db, payload.local_payment_id) - if not status_info: - return MiniAppPaymentStatusResponse( - method=method, - status="not_found", - status_label="not_found", - is_failed=True, - is_final=True, - details={"local_payment_id": payload.local_payment_id}, - ) - - payment = status_info["payment"] - status_code = (payment.status or "UNKNOWN").lower() - status_label = payment.status - is_paid = bool(payment.is_paid) - is_failed = payment.status in {"FAIL", "UNDERPAID"} - is_final = is_paid or is_failed or payment.status in {"OVERPAID"} - updated_at = payment.updated_at or payment.created_at - - metadata = payment.metadata_json if isinstance(payment.metadata_json, dict) else {} - links_meta = metadata.get("links") if isinstance(metadata, dict) else {} - - details = { - "local_payment_id": payment.id, - "bill_id": payment.bill_id, - "status": payment.status, - "links": links_meta, - "payment_id": payment.payment_id, - } - - elif method == "cryptobot": - from app.database.crud.cryptobot import ( - get_cryptobot_payment_by_id, - get_cryptobot_payment_by_invoice_id, - ) - - payment = None - if payload.invoice_id: - payment = await get_cryptobot_payment_by_invoice_id(db, str(payload.invoice_id)) - if not payment and payload.local_payment_id is not None: - payment = await get_cryptobot_payment_by_id(db, payload.local_payment_id) - - if not payment: - return MiniAppPaymentStatusResponse( - method=method, - status="not_found", - status_label="not_found", - is_failed=True, - is_final=True, - details={ - "local_payment_id": payload.local_payment_id, - "invoice_id": payload.invoice_id, - }, - ) - - raw_status = payment.status or "unknown" - status_code = raw_status.lower() - status_label = _CRYPTOBOT_STATUS_LABELS.get(status_code, status_code) - is_paid = bool(payment.is_paid) or status_code == "paid" - is_failed = status_code in _CRYPTOBOT_FAILED_STATUSES - is_final = is_paid or status_code in _CRYPTOBOT_FINAL_STATUSES - updated_at = payment.updated_at or payment.created_at - details = { - "local_payment_id": payment.id, - "invoice_id": payment.invoice_id, - "status": raw_status, - "amount": payment.amount, - "asset": payment.asset, - } - if payment.paid_at: - details["paid_at"] = payment.paid_at - if payment.transaction_id: - details["transaction_id"] = payment.transaction_id - - elif method in {"tribute", "stars"}: - started_at = payload.started_at - if isinstance(started_at, datetime): - started = started_at.replace(tzinfo=None) - elif isinstance(started_at, str) and started_at: - try: - started = datetime.fromisoformat(started_at.replace("Z", "+00:00")).replace(tzinfo=None) - except ValueError: - started = None - else: - started = None - - if not started: - started = datetime.utcnow() - timedelta(minutes=30) - - tolerance = timedelta(minutes=5) - amount_filter = payload.amount_kopeks or 0 - - method_key = ( - PaymentMethod.TELEGRAM_STARS.value - if method == "stars" - else PaymentMethod.TRIBUTE.value - ) - - query = ( - select(Transaction) - .where( - and_( - Transaction.user_id == user.id, - Transaction.payment_method == method_key, - Transaction.type == TransactionType.DEPOSIT.value, - Transaction.is_completed.is_(True), - Transaction.created_at >= started - tolerance, - ) - ) - .order_by(Transaction.created_at.desc()) - .limit(5) - ) - - result = await db.execute(query) - transactions = list(result.scalars()) - - matched_transaction = None - for transaction in transactions: - if amount_filter and transaction.amount_kopeks < amount_filter: - continue - matched_transaction = transaction - break - - if matched_transaction: - status_code = "paid" - status_label = "paid" - is_paid = True - is_final = True - updated_at = matched_transaction.updated_at or matched_transaction.created_at - details = { - "transaction_id": matched_transaction.id, - "amount_kopeks": matched_transaction.amount_kopeks, - } - else: - status_code = "pending" - status_label = "pending" - is_paid = False - is_failed = False - is_final = False - updated_at = datetime.utcnow() - details = { - "checked_transactions": [ - transaction.id for transaction in transactions - ] - } - - else: - raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="Unsupported payment method") - - return MiniAppPaymentStatusResponse( - method=method, - status=status_code, - status_label=status_label, - is_paid=is_paid, - is_failed=is_failed, - is_final=is_final, - updated_at=updated_at, - details=details, - ) - - _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 1627e3df..8afed075 100644 --- a/app/webapi/schemas/miniapp.py +++ b/app/webapi/schemas/miniapp.py @@ -253,69 +253,6 @@ class MiniAppReferralInfo(BaseModel): referrals: Optional[MiniAppReferralList] = None -class MiniAppPaymentMethodsRequest(BaseModel): - init_data: str = Field(..., alias="initData") - - -class MiniAppPaymentOption(BaseModel): - id: str - label: Optional[str] = None - icon: Optional[str] = None - - -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 - options: List[MiniAppPaymentOption] = Field(default_factory=list) - - -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") - payment_option: Optional[str] = Field(default=None, alias="paymentOption") - - -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") - provider_payment_id: Optional[str] = Field(default=None, alias="providerPaymentId") - invoice_id: Optional[str] = Field(default=None, alias="invoiceId") - amount_kopeks: Optional[int] = Field(default=None, alias="amountKopeks") - started_at: Optional[datetime] = Field(default=None, alias="startedAt") - - -class MiniAppPaymentStatusResponse(BaseModel): - success: bool = True - method: str - status: str - status_label: Optional[str] = None - is_paid: bool = False - is_failed: bool = False - is_final: bool = False - updated_at: Optional[datetime] = None - details: 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 5c131c2f..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,329 +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-options-list { - display: flex; - flex-direction: column; - gap: 12px; - margin-top: 12px; - } - - .payment-options-list.inline { - flex-direction: row; - flex-wrap: wrap; - } - - .payment-option-button { - 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; - color: var(--text-primary); - font-size: 15px; - font-weight: 600; - transition: transform 0.2s ease, box-shadow 0.2s ease, border-color 0.2s ease; - } - - .payment-option-button:hover, - .payment-option-button:focus { - transform: translateY(-1px); - box-shadow: var(--shadow-sm); - border-color: var(--primary); - } - - .payment-option-button:active, - .payment-option-button.selected { - transform: scale(0.99); - border-color: var(--primary); - background: rgba(var(--primary-rgb), 0.08); - } - - .payment-option-label { - display: flex; - align-items: center; - gap: 12px; - } - - .payment-option-icon { - font-size: 20px; - } - - .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; - } - - .topup-status { - display: flex; - align-items: center; - gap: 12px; - padding: 12px 14px; - border-radius: var(--radius); - border: 2px solid rgba(var(--primary-rgb), 0.15); - background: rgba(var(--primary-rgb), 0.06); - color: var(--text-primary); - width: 100%; - margin-top: 4px; - } - - .topup-status.pending { - border-color: rgba(var(--primary-rgb), 0.25); - background: rgba(var(--primary-rgb), 0.08); - } - - .topup-status.refreshing { - border-color: rgba(59, 130, 246, 0.35); - background: rgba(59, 130, 246, 0.12); - } - - .topup-status.success { - border-color: rgba(16, 185, 129, 0.35); - background: rgba(16, 185, 129, 0.12); - } - - .topup-status.failed { - border-color: rgba(var(--danger-rgb), 0.35); - background: rgba(var(--danger-rgb), 0.12); - } - - .topup-status-icon { - font-size: 20px; - } - - .topup-status-text { - font-size: 14px; - font-weight: 600; - line-height: 1.4; - text-align: left; - } - - .payment-summary-amount { - font-size: 22px; - font-weight: 700; - color: var(--text-primary); - } - .promo-code-card { padding: 20px; display: flex; @@ -2898,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); @@ -3147,26 +2799,6 @@
Balance
-
- -
-
- - - @@ -3473,9 +3105,6 @@ applyTheme(tg.colorScheme); } }); - tg.onEvent('invoiceClosed', event => { - handleInvoiceClosed(event); - }); } if (window.matchMedia) { @@ -3523,51 +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.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.pal24.option.sbp': 'SBP transfer', - 'topup.method.pal24.option.card': 'Bank card', - 'topup.method.pal24.option.page': 'Universal link', - '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.option.title': 'Choose payment option', - 'topup.option.subtitle': 'Select how you want to pay', - 'topup.option.selected': 'Selected option: {option}', - 'topup.submit': 'Continue', - 'topup.cancel': 'Close', - 'topup.back': 'Back', - 'topup.close': 'Close', - 'topup.topup_again': 'Top up again', - 'topup.change_option': 'Change option', - 'topup.check_status': 'Check status', - 'topup.open_link': 'Open payment page', - 'topup.loading': 'Preparing payment…', - 'topup.status.pending': 'Waiting for payment confirmation…', - 'topup.status.checking': 'Checking payment status…', - 'topup.status.refreshing': 'Updating balance…', - 'topup.status.success': 'Payment confirmed! Balance will update shortly.', - 'topup.status.failed': 'Payment was not confirmed yet. Complete the payment or try again.', - '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.', 'button.buy_subscription': 'Buy Subscription', 'card.balance.title': 'Balance', 'promo_code.title': 'Activate promo code', @@ -3750,51 +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.option.sbp': 'СБП перевод', - 'topup.method.pal24.option.card': 'Банковская карта', - 'topup.method.pal24.option.page': 'Универсальная ссылка', - 'topup.method.cryptobot.title': 'Криптовалюта (CryptoBot)', - 'topup.method.cryptobot.description': 'Оплата в USDT, TON и других активах', - 'topup.method.tribute.title': 'Банковская карта (Tribute)', - 'topup.method.tribute.description': 'Переход на страницу оплаты 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.option.title': 'Выберите вариант оплаты', - 'topup.option.subtitle': 'Укажите, как хотите оплатить', - 'topup.option.selected': 'Выбрано: {option}', - 'topup.submit': 'Продолжить', - 'topup.cancel': 'Закрыть', - 'topup.back': 'Назад', - 'topup.close': 'Готово', - 'topup.topup_again': 'Пополнить ещё', - 'topup.change_option': 'Изменить способ', - 'topup.check_status': 'Проверить статус', - 'topup.open_link': 'Перейти к оплате', - 'topup.loading': 'Готовим платеж…', - 'topup.status.pending': 'Ожидаем подтверждение оплаты…', - 'topup.status.checking': 'Проверяем статус платежа…', - 'topup.status.refreshing': 'Обновляем данные…', - 'topup.status.success': 'Платёж подтверждён! Баланс скоро обновится.', - 'topup.status.failed': 'Платёж пока не подтверждён. Завершите оплату или попробуйте снова.', - 'topup.error.generic': 'Не удалось создать платеж. Попробуйте ещё раз позже.', - 'topup.error.amount': 'Введите корректную сумму в пределах лимитов.', - 'topup.error.unavailable': 'Способ оплаты временно недоступен.', 'button.buy_subscription': 'Купить подписку', 'card.balance.title': 'Баланс', 'promo_code.title': 'Активировать промокод', @@ -4070,13 +3609,6 @@ let preferredLanguage = 'en'; let languageLockedByUser = false; let currentErrorState = null; - let paymentMethodsCache = null; - let paymentMethodsPromise = null; - let activePaymentMethod = null; - let activeTopupContext = null; - let activePaymentTracker = null; - const PAYMENT_STATUS_BASE_DELAY = 4000; - const PAYMENT_STATUS_MAX_DELAY = 30000; function resolveLanguage(lang) { if (!lang) { @@ -5737,1129 +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 = ''; - } - } - - 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; - activeTopupContext = null; - stopPaymentStatusTracking(); - 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() { - activePaymentMethod = null; - activeTopupContext = null; - stopPaymentStatusTracking(); - 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; - activeTopupContext = { methodId: method?.id || null }; - 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 renderTopupOptionPicker(method, amountKopeks, context = {}) { - activePaymentMethod = method; - activeTopupContext = { - methodId: method?.id || null, - amountKopeks: amountKopeks, - rawInput: context.rawInput || '', - }; - - const optionsList = Array.isArray(method?.options) ? method.options : []; - if (!optionsList.length) { - startPaymentForMethod(method, amountKopeks, { rawInput: context.rawInput }); - return; - } - - clearTopupError(); - setTopupModalTitle('topup.option.title', 'Choose payment option'); - setTopupModalSubtitle('topup.option.subtitle', 'Select how you want to pay'); - - const { body } = getTopupElements(); - if (!body) { - return; - } - - body.innerHTML = ''; - const list = document.createElement('div'); - list.className = 'payment-options-list'; - - optionsList.forEach(option => { - if (!option || !option.id) { - return; - } - - const button = document.createElement('button'); - button.type = 'button'; - button.className = 'payment-option-button'; - button.dataset.optionId = option.id; - - const label = document.createElement('div'); - label.className = 'payment-option-label'; - - if (option.icon) { - const icon = document.createElement('span'); - icon.className = 'payment-option-icon'; - icon.textContent = option.icon; - label.appendChild(icon); - } - - const text = document.createElement('span'); - const labelKey = `topup.method.${method.id}.option.${option.id}`; - const translated = t(labelKey); - text.textContent = translated === labelKey - ? (option.label || option.id) - : translated; - label.appendChild(text); - - button.appendChild(label); - button.addEventListener('click', () => { - startPaymentForMethod(method, amountKopeks, { - paymentOption: option.id, - rawInput: context.rawInput, - }); - }); - - list.appendChild(button); - }); - - body.appendChild(list); - - setTopupFooter([ - { - labelKey: 'topup.back', - fallbackLabel: 'Back', - variant: 'secondary', - onClick: () => renderTopupAmountForm(method), - }, - { - labelKey: 'topup.cancel', - fallbackLabel: 'Close', - variant: 'secondary', - onClick: closeTopupModal, - }, - ]); - } - - 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(); - activeTopupContext = { - methodId: method?.id || null, - amountKopeks, - rawInput: rawValue, - }; - - if (Array.isArray(method?.options) && method.options.length) { - renderTopupOptionPicker(method, amountKopeks, { rawInput: rawValue }); - return; - } - - await startPaymentForMethod(method, amountKopeks, { rawInput: rawValue }); - } - - async function startPaymentForMethod(method, amountKopeks = null, options = {}) { - clearTopupError(); - 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) { - const contextAmount = Number.isFinite(amountKopeks) ? amountKopeks : activeTopupContext?.amountKopeks; - const contextRaw = options.rawInput || activeTopupContext?.rawInput || ''; - if (Array.isArray(method?.options) && method.options.length && Number.isFinite(contextAmount)) { - renderTopupOptionPicker(method, contextAmount, { rawInput: contextRaw }); - } else { - renderTopupAmountForm(method); - const input = document.getElementById('topupAmountInput'); - if (input && contextRaw) { - input.value = contextRaw; - } - } - } - return; - } - - const payload = { - initData, - method: method.id, - }; - if (Number.isFinite(amountKopeks) && amountKopeks > 0) { - payload.amountKopeks = amountKopeks; - } - if (options.paymentOption) { - payload.paymentOption = options.paymentOption; - } - - const context = { - rawInput: options.rawInput || activeTopupContext?.rawInput || '', - amountKopeks: Number.isFinite(amountKopeks) ? amountKopeks : activeTopupContext?.amountKopeks || null, - paymentOption: options.paymentOption || activeTopupContext?.paymentOption || null, - }; - activeTopupContext = { - methodId: method?.id || null, - amountKopeks: context.amountKopeks, - rawInput: context.rawInput, - paymentOption: context.paymentOption, - }; - - 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 paymentUrl = data?.payment_url; - if (!paymentUrl) { - throw new Error('Payment link is missing'); - } - - const extra = data?.extra || {}; - if (extra?.selected_option && !context.paymentOption) { - context.paymentOption = extra.selected_option; - activeTopupContext.paymentOption = extra.selected_option; - } - - renderTopupPaymentLink( - method, - paymentUrl, - data?.amount_kopeks ?? amountKopeks, - extra, - context, - ); - } catch (error) { - console.error('Failed to create payment:', error); - if (method?.requires_amount) { - const contextAmount = Number.isFinite(amountKopeks) ? amountKopeks : activeTopupContext?.amountKopeks; - const contextRaw = options.rawInput || activeTopupContext?.rawInput || ''; - if (Array.isArray(method?.options) && method.options.length && Number.isFinite(contextAmount)) { - renderTopupOptionPicker(method, contextAmount, { rawInput: contextRaw }); - } else { - renderTopupAmountForm(method); - const input = document.getElementById('topupAmountInput'); - if (input && contextRaw) { - input.value = contextRaw; - } - } - showTopupError('topup.error.generic', error?.message || 'Unable to start payment.'); - } else { - await renderTopupMethodsView(); - showTopupError('topup.error.generic', error?.message || 'Unable to start payment.'); - } - } - } - - function renderTopupPaymentLink(method, paymentUrl, amountKopeks, extra = {}, context = {}) { - clearTopupError(); - setTopupModalTitle('topup.title', 'Top up balance'); - setTopupModalSubtitle(`topup.method.${method.id}.title`, method.id); - - 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 === titleKey ? method.id : titleValue; - summary.appendChild(title); - - if (Number.isFinite(amountKopeks) && amountKopeks > 0) { - const amount = document.createElement('div'); - amount.className = 'payment-summary-amount'; - const currency = (method.currency || userData?.balance_currency || 'RUB').toUpperCase(); - amount.textContent = formatCurrency(amountKopeks / 100, currency); - summary.appendChild(amount); - } - - if (Number.isFinite(extra?.amount_usd) && extra.amount_usd > 0) { - const usdAmount = document.createElement('div'); - usdAmount.className = 'amount-hint'; - usdAmount.textContent = `≈ ${formatCurrency(extra.amount_usd, 'USD')}`; - summary.appendChild(usdAmount); - } - - const selectedOption = context?.paymentOption || extra?.selected_option || null; - if (selectedOption) { - const selectedLabel = document.createElement('div'); - selectedLabel.className = 'amount-hint'; - const selectedTemplate = t('topup.option.selected'); - const optionKey = `topup.method.${method.id}.option.${selectedOption}`; - const optionLabelValue = t(optionKey); - const optionLabel = optionLabelValue === optionKey ? selectedOption : optionLabelValue; - if (selectedTemplate && selectedTemplate !== 'topup.option.selected') { - selectedLabel.textContent = selectedTemplate.replace('{option}', optionLabel); - } else { - selectedLabel.textContent = `Selected option: ${optionLabel}`; - } - summary.appendChild(selectedLabel); - } - - 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); - - if (method.id === 'pal24') { - const links = extra?.links && typeof extra.links === 'object' ? extra.links : {}; - const linkEntries = Object.entries(links).filter(([, url]) => typeof url === 'string' && url); - if (linkEntries.length) { - const variants = document.createElement('div'); - variants.className = 'payment-options-list inline'; - - linkEntries.forEach(([key, url]) => { - const button = document.createElement('button'); - button.type = 'button'; - button.className = 'payment-option-button'; - if (selectedOption && selectedOption === key) { - button.classList.add('selected'); - } - - const label = document.createElement('div'); - label.className = 'payment-option-label'; - - const icon = document.createElement('span'); - icon.className = 'payment-option-icon'; - icon.textContent = key === 'card' ? '💳' : key === 'sbp' ? '🏦' : '🔗'; - label.appendChild(icon); - - const keyPath = `topup.method.${method.id}.option.${key}`; - const optionValue = t(keyPath); - const text = document.createElement('span'); - text.textContent = optionValue === keyPath ? key : optionValue; - label.appendChild(text); - - button.appendChild(label); - button.addEventListener('click', () => openExternalLink(url)); - variants.appendChild(button); - }); - - body.appendChild(variants); - } - } - - const status = document.createElement('div'); - status.className = 'topup-status pending'; - status.id = 'topupStatusContainer'; - - const statusIcon = document.createElement('div'); - statusIcon.className = 'topup-status-icon'; - statusIcon.textContent = '⏳'; - status.appendChild(statusIcon); - - const statusText = document.createElement('div'); - statusText.className = 'topup-status-text'; - statusText.id = 'topupStatusText'; - const pendingMessage = t('topup.status.pending'); - statusText.textContent = pendingMessage === 'topup.status.pending' - ? 'Waiting for payment confirmation…' - : pendingMessage; - status.appendChild(statusText); - - body.appendChild(status); - - const footerButtons = [ - { - labelKey: 'topup.back', - fallbackLabel: 'Back', - variant: 'secondary', - onClick: renderTopupMethodsView, - }, - ]; - - if (Array.isArray(method?.options) && method.options.length && Number.isFinite(amountKopeks) && amountKopeks > 0) { - footerButtons.push({ - labelKey: 'topup.change_option', - fallbackLabel: 'Change option', - variant: 'secondary', - onClick: () => renderTopupOptionPicker(method, amountKopeks, { rawInput: context?.rawInput || '' }), - }); - } - - footerButtons.push({ - labelKey: 'topup.check_status', - fallbackLabel: 'Check status', - variant: 'secondary', - id: 'topupCheckStatusButton', - onClick: triggerManualStatusCheck, - }); - - footerButtons.push({ - labelKey: 'topup.open_link', - fallbackLabel: 'Open payment page', - variant: 'primary', - onClick: () => openExternalLink(paymentUrl), - }); - - setTopupFooter(footerButtons); - - const trackerExtra = { ...(extra || {}), payment_url: paymentUrl }; - startPaymentStatusTracking( - method, - Number.isFinite(amountKopeks) ? amountKopeks : null, - trackerExtra, - context, - ); - } - - function stopPaymentStatusTracking() { - if (activePaymentTracker?.timer) { - window.clearTimeout(activePaymentTracker.timer); - } - activePaymentTracker = null; - } - - function startPaymentStatusTracking(method, amountKopeks, extra = {}, context = {}) { - stopPaymentStatusTracking(); - if (!method || !method.id) { - return; - } - - if (!tg.initData) { - return; - } - - const tracker = { - method, - methodId: method.id, - amountKopeks: Number.isFinite(amountKopeks) ? amountKopeks : null, - extra: extra || {}, - context: context || {}, - startedAt: Date.now(), - attempts: 0, - inFlight: false, - timer: null, - isFinal: false, - }; - - if (!tracker.context.startedAtIso) { - tracker.context.startedAtIso = new Date(tracker.startedAt).toISOString(); - } - - if (method.id === 'stars' && tracker.extra && !tracker.extra.invoice_slug) { - const slug = extractInvoiceSlug(tracker.extra.payment_url || ''); - if (slug) { - tracker.extra.invoice_slug = slug; - } - } - - activePaymentTracker = tracker; - updateTopupStatusView('pending'); - schedulePaymentStatusCheck(tracker, true); - } - - function schedulePaymentStatusCheck(tracker, immediate = false) { - if (!tracker || tracker !== activePaymentTracker) { - return; - } - if (tracker.timer) { - window.clearTimeout(tracker.timer); - } - const delay = immediate ? 0 : Math.min( - PAYMENT_STATUS_MAX_DELAY, - PAYMENT_STATUS_BASE_DELAY * Math.pow(1.5, Math.max(0, tracker.attempts - 1)) - ); - tracker.timer = window.setTimeout(() => { - checkPaymentStatus(tracker); - }, delay); - } - - async function checkPaymentStatus(tracker) { - if (!tracker || tracker !== activePaymentTracker) { - return; - } - if (tracker.inFlight) { - return; - } - - tracker.inFlight = true; - tracker.attempts += 1; - setTopupCheckButtonDisabled(true); - - if (tracker.attempts > 1) { - updateTopupStatusView('checking'); - } - - try { - const initData = tg.initData || ''; - if (!initData) { - throw new Error('Missing init data'); - } - - const payload = { - initData, - method: tracker.methodId, - startedAt: tracker.context.startedAtIso || new Date(tracker.startedAt).toISOString(), - }; - - if (Number.isFinite(tracker.amountKopeks) && tracker.amountKopeks > 0) { - payload.amountKopeks = tracker.amountKopeks; - } - if (tracker.extra && Object.prototype.hasOwnProperty.call(tracker.extra, 'local_payment_id')) { - payload.localPaymentId = tracker.extra.local_payment_id; - } - if (tracker.extra && Object.prototype.hasOwnProperty.call(tracker.extra, 'payment_id')) { - payload.providerPaymentId = tracker.extra.payment_id; - } - if (tracker.extra && Object.prototype.hasOwnProperty.call(tracker.extra, 'invoice_id')) { - payload.invoiceId = tracker.extra.invoice_id; - } - - const response = await fetch('/miniapp/payments/status', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(payload), - }); - - const data = await response.json().catch(() => ({})); - if (!response.ok) { - const detail = data?.detail; - throw new Error(typeof detail === 'string' ? detail : 'Failed to check payment status'); - } - - handlePaymentStatusResponse(tracker, data); - } catch (error) { - console.error('Payment status check failed:', error); - updateTopupStatusView('failed', error?.message || 'Unable to check status.'); - tracker.isFinal = true; - } finally { - tracker.inFlight = false; - if (tracker === activePaymentTracker && !tracker.isFinal) { - setTopupCheckButtonDisabled(false); - } - if (tracker === activePaymentTracker && !tracker.isFinal) { - schedulePaymentStatusCheck(tracker); - } - } - } - - function handlePaymentStatusResponse(tracker, data) { - if (!tracker || tracker !== activePaymentTracker) { - return; - } - - const status = String(data?.status || '').toLowerCase() || 'pending'; - const isPaid = Boolean(data?.is_paid); - const isFailed = Boolean(data?.is_failed); - const isFinal = Boolean(data?.is_final) - || isPaid - || isFailed - || ['failed', 'expired', 'not_found'].includes(status); - - tracker.lastStatus = status; - tracker.lastResponse = data; - tracker.isFinal = isFinal; - - if (isPaid || status === 'paid') { - updateTopupStatusView('success'); - stopPaymentStatusTracking(); - handlePaymentSuccess(tracker); - return; - } - - if (isFailed || ['failed', 'expired', 'not_found'].includes(status)) { - updateTopupStatusView('failed'); - stopPaymentStatusTracking(); - return; - } - - updateTopupStatusView('pending'); - } - - async function handlePaymentSuccess(tracker) { - updateTopupStatusView('refreshing'); - try { - await refreshSubscriptionData({ silent: true }); - } catch (error) { - console.error('Failed to refresh data after payment:', error); - } - updateTopupStatusView('success'); - - setTopupFooter([ - { - labelKey: 'topup.close', - fallbackLabel: 'Close', - variant: 'primary', - onClick: closeTopupModal, - }, - { - labelKey: 'topup.topup_again', - fallbackLabel: 'Top up again', - variant: 'secondary', - onClick: renderTopupMethodsView, - }, - ]); - } - - function setTopupCheckButtonDisabled(disabled) { - const checkButton = document.getElementById('topupCheckStatusButton'); - if (checkButton) { - checkButton.disabled = Boolean(disabled); - } - } - - function resolveTopupStatusLabel(label) { - if (typeof label !== 'string' || !label) { - return ''; - } - - const key = `topup.status_label.${label}`; - const translated = t(key); - if (translated && translated !== key) { - return translated; - } - - const normalized = label.replace(/_/g, ' ').trim(); - if (!normalized) { - return ''; - } - - return normalized.charAt(0).toUpperCase() + normalized.slice(1); - } - - function updateTopupStatusView(status, fallbackMessage = '') { - const container = document.getElementById('topupStatusContainer'); - const text = document.getElementById('topupStatusText'); - if (!container || !text) { - return; - } - - container.classList.remove('pending', 'refreshing', 'success', 'failed'); - - let icon = '⏳'; - let messageKey = 'topup.status.pending'; - - switch (status) { - case 'success': - container.classList.add('success'); - icon = '✅'; - messageKey = 'topup.status.success'; - break; - case 'failed': - container.classList.add('failed'); - icon = '❌'; - messageKey = 'topup.status.failed'; - break; - case 'refreshing': - container.classList.add('refreshing'); - icon = '🔄'; - messageKey = 'topup.status.refreshing'; - break; - case 'checking': - container.classList.add('pending'); - icon = '🔍'; - messageKey = 'topup.status.checking'; - break; - default: - container.classList.add('pending'); - icon = '⏳'; - messageKey = 'topup.status.pending'; - break; - } - - let message = ''; - const translated = t(messageKey); - if (translated && translated !== messageKey) { - message = translated; - } else if (fallbackMessage) { - message = fallbackMessage; - } else { - switch (status) { - case 'success': - message = 'Payment confirmed!'; - break; - case 'failed': - message = 'Payment was not confirmed.'; - break; - case 'refreshing': - message = 'Updating balance…'; - break; - case 'checking': - message = 'Checking payment status…'; - break; - default: - message = 'Waiting for payment confirmation…'; - break; - } - } - - if (status === 'pending') { - const extraLabel = resolveTopupStatusLabel(activePaymentTracker?.lastResponse?.status_label); - if (extraLabel) { - message = message ? `${message} · ${extraLabel}` : extraLabel; - } - } - - text.textContent = message; - - const iconElement = container.querySelector('.topup-status-icon'); - if (iconElement) { - iconElement.textContent = icon; - } - - const shouldDisable = status === 'refreshing' || status === 'success' || status === 'checking'; - const inFlight = Boolean(activePaymentTracker?.inFlight); - setTopupCheckButtonDisabled(shouldDisable || inFlight); - } - - function triggerManualStatusCheck() { - if (!activePaymentTracker) { - return; - } - updateTopupStatusView('checking'); - schedulePaymentStatusCheck(activePaymentTracker, true); - } - - function handleInvoiceClosed(event) { - if (!event || !activePaymentTracker || activePaymentTracker.methodId !== 'stars') { - return; - } - - const slug = event?.slug || event?.invoiceSlug; - if (!slug) { - return; - } - - const expectedSlug = activePaymentTracker.extra?.invoice_slug - || extractInvoiceSlug(activePaymentTracker.extra?.payment_url || ''); - if (!expectedSlug || expectedSlug !== slug) { - return; - } - - const status = String(event?.status || '').toLowerCase(); - if (status === 'paid') { - updateTopupStatusView('success'); - stopPaymentStatusTracking(); - handlePaymentSuccess(activePaymentTracker); - } else if (status === 'failed' || status === 'cancelled') { - updateTopupStatusView('failed'); - stopPaymentStatusTracking(); - } - } - - function extractInvoiceSlug(url) { - if (typeof url !== 'string') { - return null; - } - const match = url.match(/invoice\/([a-z0-9_-]+)/i); - return match ? match[1] : null; - } - function updateReferralToggleState() { const list = document.getElementById('referralList'); const empty = document.getElementById('referralListEmpty'); @@ -8349,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) {