diff --git a/app/services/payment_service.py b/app/services/payment_service.py index 623790eb..81784cdf 100644 --- a/app/services/payment_service.py +++ b/app/services/payment_service.py @@ -2,7 +2,7 @@ import logging import hashlib import hmac import uuid -from decimal import Decimal, InvalidOperation, ROUND_HALF_UP +from decimal import Decimal, InvalidOperation, ROUND_HALF_UP, ROUND_FLOOR from typing import Optional, Dict, Any from datetime import datetime from aiogram import Bot @@ -106,16 +106,32 @@ class PaymentService: self, amount_kopeks: int, description: str, - payload: Optional[str] = None + payload: Optional[str] = None, + *, + stars_amount: Optional[int] = None, ) -> str: - + if not self.bot or not self.stars_service: raise ValueError("Bot instance required for Stars payments") - + try: amount_rubles = Decimal(amount_kopeks) / Decimal(100) - stars_amount = TelegramStarsService.calculate_stars_from_rubles(float(amount_rubles)) - + + if stars_amount is None: + rate = Decimal(str(settings.get_stars_rate())) + if rate <= 0: + raise ValueError("Stars rate must be positive") + + normalized_stars = (amount_rubles / rate).to_integral_value( + rounding=ROUND_FLOOR + ) + stars_amount = int(normalized_stars) + if stars_amount <= 0: + stars_amount = 1 + + if stars_amount <= 0: + raise ValueError("Stars amount must be positive") + invoice_link = await self.bot.create_invoice_link( title="Пополнение баланса VPN", description=f"{description} (≈{stars_amount} ⭐)", @@ -828,6 +844,7 @@ class PaymentService: language: str, ttl_seconds: Optional[int] = None, payer_email: Optional[str] = None, + payment_method: Optional[str] = None, ) -> Optional[Dict[str, Any]]: if not self.pal24_service or not self.pal24_service.is_configured: @@ -858,6 +875,8 @@ class PaymentService: "language": language, } + normalized_payment_method = (payment_method or "SBP").upper() + try: response = await self.pal24_service.create_bill( amount_kopeks=amount_kopeks, @@ -867,7 +886,7 @@ class PaymentService: ttl_seconds=ttl_seconds, custom_payload=custom_payload, payer_email=payer_email, - payment_method="SBP", + payment_method=normalized_payment_method, ) except Pal24APIError as error: logger.error("Ошибка Pal24 API при создании счета: %s", error) @@ -962,6 +981,7 @@ class PaymentService: "sbp_url": transfer_url or primary_link, "card_url": card_url, "transfer_url": transfer_url, + "payment_method": normalized_payment_method, } logger.info( diff --git a/app/webapi/routes/miniapp.py b/app/webapi/routes/miniapp.py index b1043837..5ec70d63 100644 --- a/app/webapi/routes/miniapp.py +++ b/app/webapi/routes/miniapp.py @@ -2,9 +2,13 @@ from __future__ import annotations import logging import re -from datetime import datetime, timedelta +import math +from decimal import Decimal, InvalidOperation, ROUND_HALF_UP, ROUND_FLOOR +from datetime import datetime, timedelta, timezone +from uuid import uuid4 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 @@ -29,6 +33,8 @@ from app.database.models import ( Subscription, SubscriptionTemporaryAccess, Transaction, + TransactionType, + PaymentMethod, User, ) from app.services.faq_service import FaqService @@ -38,9 +44,12 @@ from app.services.remnawave_service import ( RemnaWaveConfigurationError, RemnaWaveService, ) +from app.services.payment_service import PaymentService from app.services.promo_offer_service import promo_offer_service from app.services.promocode_service import PromoCodeService from app.services.subscription_service import SubscriptionService +from app.services.tribute_service import TributeService +from app.utils.currency_converter import currency_converter from app.utils.subscription_utils import get_happ_cryptolink_redirect_link from app.utils.telegram_webapp import ( TelegramWebAppAuthError, @@ -61,6 +70,15 @@ from ..schemas.miniapp import ( MiniAppFaq, MiniAppFaqItem, MiniAppLegalDocuments, + MiniAppPaymentCreateRequest, + MiniAppPaymentCreateResponse, + MiniAppPaymentMethod, + MiniAppPaymentMethodsRequest, + MiniAppPaymentMethodsResponse, + MiniAppPaymentStatusQuery, + MiniAppPaymentStatusRequest, + MiniAppPaymentStatusResponse, + MiniAppPaymentStatusResult, MiniAppPromoCode, MiniAppPromoCodeActivationRequest, MiniAppPromoCodeActivationResponse, @@ -89,6 +107,198 @@ router = APIRouter() promo_code_service = PromoCodeService() +_CRYPTOBOT_MIN_USD = 1.0 +_CRYPTOBOT_MAX_USD = 1000.0 +_CRYPTOBOT_FALLBACK_RATE = 95.0 + +_DECIMAL_ONE_HUNDRED = Decimal(100) +_DECIMAL_CENT = Decimal("0.01") + +_PAYMENT_SUCCESS_STATUSES = { + "paid", + "success", + "succeeded", + "completed", + "captured", + "done", + "overpaid", +} +_PAYMENT_FAILURE_STATUSES = { + "fail", + "failed", + "canceled", + "cancelled", + "declined", + "expired", + "rejected", + "error", + "refunded", + "chargeback", +} + + +async def _get_usd_to_rub_rate() -> float: + try: + rate = await currency_converter.get_usd_to_rub_rate() + except Exception: + rate = 0.0 + if not rate or rate <= 0: + rate = _CRYPTOBOT_FALLBACK_RATE + return float(rate) + + +def _compute_cryptobot_limits(rate: float) -> Tuple[int, int]: + min_kopeks = max(1, int(math.ceil(rate * _CRYPTOBOT_MIN_USD * 100))) + max_kopeks = int(math.floor(rate * _CRYPTOBOT_MAX_USD * 100)) + if max_kopeks < min_kopeks: + max_kopeks = min_kopeks + return min_kopeks, max_kopeks + + +def _current_request_timestamp() -> str: + return datetime.utcnow().replace(microsecond=0).isoformat() + + +def _compute_stars_min_amount() -> Optional[int]: + try: + rate = Decimal(str(settings.get_stars_rate())) + except (InvalidOperation, TypeError): + return None + + if rate <= 0: + return None + + return int((rate * _DECIMAL_ONE_HUNDRED).to_integral_value(rounding=ROUND_HALF_UP)) + + +def _normalize_stars_amount(amount_kopeks: int) -> Tuple[int, int]: + try: + rate = Decimal(str(settings.get_stars_rate())) + except (InvalidOperation, TypeError): + raise ValueError("Stars rate is not configured") + + if rate <= 0: + raise ValueError("Stars rate must be positive") + + amount_rubles = Decimal(amount_kopeks) / _DECIMAL_ONE_HUNDRED + stars_amount = int((amount_rubles / rate).to_integral_value(rounding=ROUND_FLOOR)) + if stars_amount <= 0: + stars_amount = 1 + + normalized_rubles = (Decimal(stars_amount) * rate).quantize( + _DECIMAL_CENT, + rounding=ROUND_HALF_UP, + ) + normalized_amount_kopeks = int( + (normalized_rubles * _DECIMAL_ONE_HUNDRED).to_integral_value( + rounding=ROUND_HALF_UP + ) + ) + + return stars_amount, normalized_amount_kopeks + + +def _build_balance_invoice_payload(user_id: int, amount_kopeks: int) -> str: + suffix = uuid4().hex[:8] + return f"balance_{user_id}_{amount_kopeks}_{suffix}" + + +def _parse_client_timestamp(value: Optional[Union[str, int, float]]) -> Optional[datetime]: + if value is None: + return None + if isinstance(value, (int, float)): + try: + timestamp = float(value) + except (TypeError, ValueError): + return None + if timestamp > 1e12: + timestamp /= 1000.0 + try: + return datetime.fromtimestamp(timestamp, tz=timezone.utc).replace(tzinfo=None) + except (OverflowError, OSError, ValueError): + return None + if isinstance(value, str): + normalized = value.strip() + if not normalized: + return None + if normalized.isdigit(): + return _parse_client_timestamp(int(normalized)) + for suffix in ("Z", "z"): + if normalized.endswith(suffix): + normalized = normalized[:-1] + "+00:00" + break + try: + parsed = datetime.fromisoformat(normalized) + except ValueError: + return None + if parsed.tzinfo: + return parsed.astimezone(timezone.utc).replace(tzinfo=None) + return parsed + return None + + +async def _find_recent_deposit( + db: AsyncSession, + *, + user_id: int, + payment_method: PaymentMethod, + amount_kopeks: Optional[int], + started_at: Optional[datetime], + tolerance: timedelta = timedelta(minutes=5), +) -> Optional[Transaction]: + def _transaction_matches_started_at( + transaction: Transaction, + reference: Optional[datetime], + ) -> bool: + if not reference: + return True + timestamp = transaction.completed_at or transaction.created_at + if not timestamp: + return False + if timestamp.tzinfo: + timestamp = timestamp.astimezone(timezone.utc).replace(tzinfo=None) + return timestamp >= reference + + query = ( + select(Transaction) + .where( + Transaction.user_id == user_id, + Transaction.type == TransactionType.DEPOSIT.value, + Transaction.payment_method == payment_method.value, + ) + .order_by(Transaction.created_at.desc()) + .limit(1) + ) + + if amount_kopeks is not None: + query = query.where(Transaction.amount_kopeks == amount_kopeks) + if started_at: + query = query.where(Transaction.created_at >= started_at - tolerance) + + result = await db.execute(query) + transaction = result.scalar_one_or_none() + + if not transaction: + return None + + if not _transaction_matches_started_at(transaction, started_at): + return None + + return transaction + + +def _classify_status(status: Optional[str], is_paid: bool) -> str: + if is_paid: + return "paid" + normalized = (status or "").strip().lower() + if not normalized: + return "pending" + if normalized in _PAYMENT_SUCCESS_STATUSES: + return "paid" + if normalized in _PAYMENT_FAILURE_STATUSES: + return "failed" + return "pending" + def _format_gb(value: Optional[float]) -> float: if value is None: return 0.0 @@ -113,6 +323,887 @@ 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: + stars_min_amount = _compute_stars_min_amount() + methods.append( + MiniAppPaymentMethod( + id="stars", + icon="⭐", + requires_amount=True, + currency="RUB", + min_amount_kopeks=stars_min_amount, + amount_step_kopeks=stars_min_amount, + ) + ) + + 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(): + rate = await _get_usd_to_rub_rate() + min_amount_kopeks, max_amount_kopeks = _compute_cryptobot_limits(rate) + 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", + ) + ) + + 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") + + requested_amount_kopeks = amount_kopeks + try: + stars_amount, amount_kopeks = _normalize_stars_amount(amount_kopeks) + except ValueError as exc: + logger.error("Failed to normalize Stars amount: %s", exc) + raise HTTPException( + status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Failed to prepare Stars payment", + ) from exc + + bot = Bot(token=settings.BOT_TOKEN) + invoice_payload = _build_balance_invoice_payload(user.id, amount_kopeks) + 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=invoice_payload, + stars_amount=stars_amount, + ) + 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={ + "invoice_payload": invoice_payload, + "requested_at": _current_request_timestamp(), + "stars_amount": stars_amount, + "requested_amount_kopeks": requested_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"), + "requested_at": _current_request_timestamp(), + }, + ) + + 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"), + "requested_at": _current_request_timestamp(), + }, + ) + + 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") + + option = (payload.payment_option or "").strip().lower() + if option not in {"card", "sbp"}: + option = "sbp" + provider_method = "CARD" if 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=provider_method, + ) + if not result: + raise HTTPException(status.HTTP_502_BAD_GATEWAY, detail="Failed to create payment") + + preferred_urls: List[Optional[str]] = [] + if option == "sbp": + preferred_urls.append(result.get("sbp_url")) + elif option == "card": + preferred_urls.append(result.get("card_url")) + preferred_urls.extend( + [ + result.get("link_url"), + result.get("link_page_url"), + result.get("payment_url"), + result.get("transfer_url"), + ] + ) + payment_url = next((url for url in preferred_urls if url), None) + 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"), + "bill_id": result.get("bill_id"), + "order_id": result.get("order_id"), + "payment_method": result.get("payment_method") or provider_method, + "sbp_url": result.get("sbp_url"), + "card_url": result.get("card_url"), + "link_url": result.get("link_url"), + "link_page_url": result.get("link_page_url"), + "transfer_url": result.get("transfer_url"), + "selected_option": option, + "requested_at": _current_request_timestamp(), + }, + ) + + 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") + rate = await _get_usd_to_rub_rate() + min_amount_kopeks, max_amount_kopeks = _compute_cryptobot_limits(rate) + if amount_kopeks < min_amount_kopeks: + raise HTTPException( + status.HTTP_400_BAD_REQUEST, + detail=f"Amount is below minimum ({min_amount_kopeks / 100:.2f} RUB)", + ) + if amount_kopeks > max_amount_kopeks: + raise HTTPException( + status.HTTP_400_BAD_REQUEST, + detail=f"Amount exceeds maximum ({max_amount_kopeks / 100:.2f} RUB)", + ) + + try: + amount_usd = float( + (Decimal(amount_kopeks) / Decimal(100) / Decimal(str(rate))) + .quantize(Decimal("0.01"), rounding=ROUND_HALF_UP) + ) + except (InvalidOperation, ValueError): + raise HTTPException( + status.HTTP_400_BAD_REQUEST, + detail="Unable to convert amount to USD", + ) + + 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, + "requested_at": _current_request_timestamp(), + }, + ) + + 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={ + "requested_at": _current_request_timestamp(), + }, + ) + + raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="Unknown payment method") + + +@router.post( + "/payments/status", + response_model=MiniAppPaymentStatusResponse, +) +async def get_payment_statuses( + payload: MiniAppPaymentStatusRequest, + db: AsyncSession = Depends(get_db_session), +) -> MiniAppPaymentStatusResponse: + user, _ = await _resolve_user_from_init_data(db, payload.init_data) + + entries = payload.payments or [] + if not entries: + return MiniAppPaymentStatusResponse(results=[]) + + payment_service = PaymentService() + results: List[MiniAppPaymentStatusResult] = [] + + for entry in entries: + result = await _resolve_payment_status_entry( + payment_service=payment_service, + db=db, + user=user, + query=entry, + ) + if result: + results.append(result) + + return MiniAppPaymentStatusResponse(results=results) + + +async def _resolve_payment_status_entry( + *, + payment_service: PaymentService, + db: AsyncSession, + user: User, + query: MiniAppPaymentStatusQuery, +) -> MiniAppPaymentStatusResult: + method = (query.method or "").strip().lower() + if not method: + return MiniAppPaymentStatusResult( + method="", + status="unknown", + message="Payment method is required", + ) + + if method == "yookassa": + return await _resolve_yookassa_payment_status(db, user, query) + if method == "mulenpay": + return await _resolve_mulenpay_payment_status(payment_service, db, user, query) + if method == "pal24": + return await _resolve_pal24_payment_status(payment_service, db, user, query) + if method == "cryptobot": + return await _resolve_cryptobot_payment_status(db, user, query) + if method == "stars": + return await _resolve_stars_payment_status(db, user, query) + if method == "tribute": + return await _resolve_tribute_payment_status(db, user, query) + + return MiniAppPaymentStatusResult( + method=method, + status="unknown", + message="Unsupported payment method", + ) + + +async def _resolve_yookassa_payment_status( + db: AsyncSession, + user: User, + query: MiniAppPaymentStatusQuery, +) -> MiniAppPaymentStatusResult: + from app.database.crud.yookassa import ( + get_yookassa_payment_by_id, + get_yookassa_payment_by_local_id, + ) + + payment = None + if query.local_payment_id: + payment = await get_yookassa_payment_by_local_id(db, query.local_payment_id) + if not payment and query.payment_id: + payment = await get_yookassa_payment_by_id(db, query.payment_id) + + if not payment or payment.user_id != user.id: + return MiniAppPaymentStatusResult( + method="yookassa", + status="pending", + is_paid=False, + amount_kopeks=query.amount_kopeks, + message="Payment not found", + extra={ + "local_payment_id": query.local_payment_id, + "payment_id": query.payment_id, + "invoice_id": query.payment_id, + "payload": query.payload, + "started_at": query.started_at, + }, + ) + + succeeded = bool(payment.is_paid and (payment.status or "").lower() == "succeeded") + status = _classify_status(payment.status, succeeded) + completed_at = payment.captured_at or payment.updated_at or payment.created_at + + return MiniAppPaymentStatusResult( + method="yookassa", + status=status, + is_paid=status == "paid", + amount_kopeks=payment.amount_kopeks, + currency=payment.currency, + completed_at=completed_at, + transaction_id=payment.transaction_id, + external_id=payment.yookassa_payment_id, + extra={ + "status": payment.status, + "is_paid": payment.is_paid, + "local_payment_id": payment.id, + "payment_id": payment.yookassa_payment_id, + "invoice_id": payment.yookassa_payment_id, + "payload": query.payload, + "started_at": query.started_at, + }, + ) + + +async def _resolve_mulenpay_payment_status( + payment_service: PaymentService, + db: AsyncSession, + user: User, + query: MiniAppPaymentStatusQuery, +) -> MiniAppPaymentStatusResult: + if not query.local_payment_id: + return MiniAppPaymentStatusResult( + method="mulenpay", + status="pending", + is_paid=False, + amount_kopeks=query.amount_kopeks, + message="Missing payment identifier", + extra={ + "local_payment_id": query.local_payment_id, + "invoice_id": query.invoice_id, + "payment_id": query.payment_id, + "payload": query.payload, + "started_at": query.started_at, + }, + ) + + status_info = await payment_service.get_mulenpay_payment_status(db, query.local_payment_id) + payment = status_info.get("payment") if status_info else None + + if not payment or payment.user_id != user.id: + return MiniAppPaymentStatusResult( + method="mulenpay", + status="pending", + is_paid=False, + amount_kopeks=query.amount_kopeks, + message="Payment not found", + extra={ + "local_payment_id": query.local_payment_id, + "invoice_id": query.invoice_id, + "payment_id": query.payment_id, + "payload": query.payload, + "started_at": query.started_at, + }, + ) + + status_raw = status_info.get("status") or payment.status + is_paid = bool(payment.is_paid) + status = _classify_status(status_raw, is_paid) + completed_at = payment.paid_at or payment.updated_at or payment.created_at + message = None + if status == "failed": + remote_status = status_info.get("remote_status_code") or status_raw + if remote_status: + message = f"Status: {remote_status}" + + return MiniAppPaymentStatusResult( + method="mulenpay", + status=status, + is_paid=status == "paid", + amount_kopeks=payment.amount_kopeks, + currency=payment.currency, + completed_at=completed_at, + transaction_id=payment.transaction_id, + external_id=str(payment.mulen_payment_id or payment.uuid), + message=message, + extra={ + "status": payment.status, + "remote_status": status_info.get("remote_status_code"), + "local_payment_id": payment.id, + "payment_id": payment.mulen_payment_id, + "uuid": str(payment.uuid), + "payload": query.payload, + "started_at": query.started_at, + }, + ) + + +async def _resolve_pal24_payment_status( + payment_service: PaymentService, + db: AsyncSession, + user: User, + query: MiniAppPaymentStatusQuery, +) -> MiniAppPaymentStatusResult: + from app.database.crud.pal24 import get_pal24_payment_by_bill_id + + local_id = query.local_payment_id + if not local_id and query.invoice_id: + payment_by_bill = await get_pal24_payment_by_bill_id(db, query.invoice_id) + if payment_by_bill and payment_by_bill.user_id == user.id: + local_id = payment_by_bill.id + + if not local_id: + return MiniAppPaymentStatusResult( + method="pal24", + status="pending", + is_paid=False, + amount_kopeks=query.amount_kopeks, + message="Missing payment identifier", + extra={ + "local_payment_id": query.local_payment_id, + "bill_id": query.invoice_id, + "order_id": None, + "payload": query.payload, + "started_at": query.started_at, + }, + ) + + status_info = await payment_service.get_pal24_payment_status(db, local_id) + payment = status_info.get("payment") if status_info else None + + if not payment or payment.user_id != user.id: + return MiniAppPaymentStatusResult( + method="pal24", + status="pending", + is_paid=False, + amount_kopeks=query.amount_kopeks, + message="Payment not found", + extra={ + "local_payment_id": local_id, + "bill_id": query.invoice_id, + "order_id": None, + "payload": query.payload, + "started_at": query.started_at, + }, + ) + + status_raw = status_info.get("status") or payment.status + is_paid = bool(payment.is_paid) + status = _classify_status(status_raw, is_paid) + completed_at = payment.paid_at or payment.updated_at or payment.created_at + message = None + if status == "failed": + remote_status = status_info.get("remote_status") or status_raw + if remote_status: + message = f"Status: {remote_status}" + + return MiniAppPaymentStatusResult( + method="pal24", + status=status, + is_paid=status == "paid", + amount_kopeks=payment.amount_kopeks, + currency=payment.currency, + completed_at=completed_at, + transaction_id=payment.transaction_id, + external_id=payment.bill_id, + message=message, + extra={ + "status": payment.status, + "remote_status": status_info.get("remote_status"), + "local_payment_id": payment.id, + "bill_id": payment.bill_id, + "order_id": payment.order_id, + "payment_method": getattr(payment, "payment_method", None), + "payload": query.payload, + "started_at": query.started_at, + }, + ) + + +async def _resolve_cryptobot_payment_status( + db: AsyncSession, + user: User, + query: MiniAppPaymentStatusQuery, +) -> MiniAppPaymentStatusResult: + from app.database.crud.cryptobot import ( + get_cryptobot_payment_by_id, + get_cryptobot_payment_by_invoice_id, + ) + + payment = None + if query.local_payment_id: + payment = await get_cryptobot_payment_by_id(db, query.local_payment_id) + if not payment and query.invoice_id: + payment = await get_cryptobot_payment_by_invoice_id(db, query.invoice_id) + + if not payment or payment.user_id != user.id: + return MiniAppPaymentStatusResult( + method="cryptobot", + status="pending", + is_paid=False, + amount_kopeks=query.amount_kopeks, + message="Payment not found", + extra={ + "local_payment_id": query.local_payment_id, + "invoice_id": query.invoice_id, + "payment_id": query.payment_id, + "payload": query.payload, + "started_at": query.started_at, + }, + ) + + status_raw = payment.status + is_paid = (status_raw or "").lower() == "paid" + status = _classify_status(status_raw, is_paid) + completed_at = payment.paid_at or payment.updated_at or payment.created_at + + amount_kopeks = None + try: + amount_kopeks = int(Decimal(payment.amount) * Decimal(100)) + except (InvalidOperation, TypeError): + amount_kopeks = None + + return MiniAppPaymentStatusResult( + method="cryptobot", + status=status, + is_paid=status == "paid", + amount_kopeks=amount_kopeks, + currency=payment.asset, + completed_at=completed_at, + transaction_id=payment.transaction_id, + external_id=payment.invoice_id, + extra={ + "status": payment.status, + "asset": payment.asset, + "local_payment_id": payment.id, + "invoice_id": payment.invoice_id, + "payload": query.payload, + "started_at": query.started_at, + }, + ) + + +async def _resolve_stars_payment_status( + db: AsyncSession, + user: User, + query: MiniAppPaymentStatusQuery, +) -> MiniAppPaymentStatusResult: + started_at = _parse_client_timestamp(query.started_at) + transaction = await _find_recent_deposit( + db, + user_id=user.id, + payment_method=PaymentMethod.TELEGRAM_STARS, + amount_kopeks=query.amount_kopeks, + started_at=started_at, + ) + + if not transaction: + return MiniAppPaymentStatusResult( + method="stars", + status="pending", + is_paid=False, + amount_kopeks=query.amount_kopeks, + message="Waiting for confirmation", + extra={ + "payload": query.payload, + "started_at": query.started_at, + }, + ) + + return MiniAppPaymentStatusResult( + method="stars", + status="paid", + is_paid=True, + amount_kopeks=transaction.amount_kopeks, + currency="RUB", + completed_at=transaction.completed_at or transaction.created_at, + transaction_id=transaction.id, + external_id=transaction.external_id, + extra={ + "payload": query.payload, + "started_at": query.started_at, + }, + ) + + +async def _resolve_tribute_payment_status( + db: AsyncSession, + user: User, + query: MiniAppPaymentStatusQuery, +) -> MiniAppPaymentStatusResult: + started_at = _parse_client_timestamp(query.started_at) + transaction = await _find_recent_deposit( + db, + user_id=user.id, + payment_method=PaymentMethod.TRIBUTE, + amount_kopeks=query.amount_kopeks, + started_at=started_at, + ) + + if not transaction: + return MiniAppPaymentStatusResult( + method="tribute", + status="pending", + is_paid=False, + amount_kopeks=query.amount_kopeks, + message="Waiting for confirmation", + extra={ + "payload": query.payload, + "started_at": query.started_at, + }, + ) + + return MiniAppPaymentStatusResult( + method="tribute", + status="paid", + is_paid=True, + amount_kopeks=transaction.amount_kopeks, + currency="RUB", + completed_at=transaction.completed_at or transaction.created_at, + transaction_id=transaction.id, + external_id=transaction.external_id, + extra={ + "payload": query.payload, + "started_at": query.started_at, + }, + ) + + _TEMPLATE_ID_PATTERN = re.compile(r"promo_template_(?P\d+)$") _OFFER_TYPE_ICONS = { "extend_discount": "💎", diff --git a/app/webapi/schemas/miniapp.py b/app/webapi/schemas/miniapp.py index 8afed075..3f668548 100644 --- a/app/webapi/schemas/miniapp.py +++ b/app/webapi/schemas/miniapp.py @@ -253,6 +253,72 @@ 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") + payment_option: Optional[str] = Field(default=None, alias="option") + + +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 MiniAppPaymentStatusQuery(BaseModel): + method: str + local_payment_id: Optional[int] = Field(default=None, alias="localPaymentId") + invoice_id: Optional[str] = Field(default=None, alias="invoiceId") + payment_id: Optional[str] = Field(default=None, alias="paymentId") + payload: Optional[str] = None + amount_kopeks: Optional[int] = Field(default=None, alias="amountKopeks") + started_at: Optional[str] = Field(default=None, alias="startedAt") + + +class MiniAppPaymentStatusRequest(BaseModel): + init_data: str = Field(..., alias="initData") + payments: List[MiniAppPaymentStatusQuery] = Field(default_factory=list) + + +class MiniAppPaymentStatusResult(BaseModel): + method: str + status: str + is_paid: bool = False + amount_kopeks: Optional[int] = None + currency: Optional[str] = None + completed_at: Optional[datetime] = None + transaction_id: Optional[int] = None + external_id: Optional[str] = None + message: Optional[str] = None + extra: Dict[str, Any] = Field(default_factory=dict) + + +class MiniAppPaymentStatusResponse(BaseModel): + results: List[MiniAppPaymentStatusResult] = Field(default_factory=list) + + class MiniAppSubscriptionResponse(BaseModel): success: bool = True subscription_id: int diff --git a/miniapp/index.html b/miniapp/index.html index 2583a5dd..18a8635b 100644 --- a/miniapp/index.html +++ b/miniapp/index.html @@ -78,6 +78,10 @@ line-height: 1.6; } + body.modal-open { + overflow: hidden; + } + .container { max-width: 480px; margin: 0 auto; @@ -1060,6 +1064,9 @@ .balance-content { padding: 20px; text-align: center; + display: flex; + flex-direction: column; + align-items: center; } .balance-amount { @@ -1080,6 +1087,353 @@ 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-option-group { + margin-top: 16px; + } + + .payment-option-title { + font-size: 14px; + font-weight: 600; + margin-bottom: 8px; + } + + .payment-option-list { + display: grid; + gap: 8px; + } + + .payment-option-button { + display: flex; + align-items: center; + justify-content: flex-start; + gap: 12px; + padding: 12px 14px; + border: 1px solid var(--border-color); + border-radius: var(--radius); + background: var(--bg-primary); + cursor: pointer; + transition: border-color 0.2s ease, box-shadow 0.2s ease, background-color 0.2s ease; + } + + .payment-option-button:hover { + border-color: rgba(var(--primary-rgb), 0.4); + box-shadow: var(--shadow-sm); + } + + .payment-option-button.active { + border-color: var(--primary); + background: rgba(var(--primary-rgb), 0.08); + box-shadow: var(--shadow-sm); + } + + .payment-option-icon { + font-size: 20px; + } + + .payment-option-text { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 2px; + text-align: left; + } + + .payment-option-label { + font-size: 14px; + font-weight: 600; + } + + .payment-option-description { + font-size: 13px; + color: var(--text-secondary); + } + + .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; + } + + .amount-hint.warning { + color: var(--warning); + } + + .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-status { + margin-top: 16px; + padding: 14px 16px; + border-radius: var(--radius); + border: 1px solid rgba(var(--primary-rgb), 0.2); + background: rgba(var(--primary-rgb), 0.06); + display: flex; + gap: 12px; + align-items: flex-start; + } + + .payment-status.success { + border-color: rgba(16, 185, 129, 0.35); + background: rgba(16, 185, 129, 0.1); + } + + .payment-status.error { + border-color: rgba(var(--danger-rgb), 0.35); + background: rgba(var(--danger-rgb), 0.1); + } + + .payment-status-icon { + width: 24px; + height: 24px; + display: flex; + align-items: center; + justify-content: center; + font-size: 18px; + } + + .payment-status-spinner { + width: 16px; + height: 16px; + border: 2px solid rgba(var(--primary-rgb), 0.2); + border-top-color: var(--primary); + border-radius: 50%; + animation: spin 0.8s linear infinite; + } + + .payment-status-content { + flex: 1; + } + + .payment-status-title { + font-size: 14px; + font-weight: 600; + } + + .payment-status-description { + margin-top: 4px; + font-size: 13px; + color: var(--text-secondary); + line-height: 1.4; + } + + .payment-summary-amount { + font-size: 22px; + font-weight: 700; + color: var(--text-primary); + } + .promo-code-card { padding: 20px; display: flex; @@ -2568,6 +2922,24 @@ border-color: rgba(148, 163, 184, 0.25); } + :root[data-theme="dark"] .modal { + background: rgba(15, 23, 42, 0.96); + } + + :root[data-theme="dark"] .payment-method-card { + background: rgba(15, 23, 42, 0.85); + border-color: rgba(148, 163, 184, 0.28); + } + + :root[data-theme="dark"] .amount-input { + background: rgba(15, 23, 42, 0.85); + border-color: rgba(148, 163, 184, 0.28); + } + + :root[data-theme="dark"] .modal-button.secondary { + border-color: rgba(148, 163, 184, 0.35); + } + :root[data-theme="dark"] .promo-code-input-group { background: rgba(15, 23, 42, 0.85); border-color: rgba(148, 163, 184, 0.35); @@ -2799,6 +3171,26 @@
Balance
+
+ +
+
+ + + @@ -3152,6 +3544,51 @@ '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.stars.invoice_hint': 'Telegram Stars payments use whole stars. Invoice: {stars} ⭐.', + 'topup.method.stars.adjusted': 'Requested {requested}. Actual charge: {amount} ({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.option.sbp.title': 'Faster Payments (SBP)', + 'topup.method.pal24.option.sbp.description': 'Instant transfer via SBP with no extra fees', + 'topup.method.pal24.option.card.title': 'Bank card payment', + 'topup.method.pal24.option.card.description': 'Pay with a bank card via PayPalych', + '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.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.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.pending.title': 'Waiting for confirmation', + 'topup.status.pending.description': 'Complete the payment in the selected provider. We will update this window automatically.', + 'topup.status.refreshing.description': 'Updating payment status…', + 'topup.status.success.title': 'Payment received', + 'topup.status.success.description': 'Funds have been credited to your balance.', + 'topup.status.failed.title': 'Payment not confirmed', + 'topup.status.failed.description': 'We could not confirm the payment automatically. Please check later or contact support.', + 'topup.status.retry': 'Try again', + 'topup.done': 'Done', 'button.buy_subscription': 'Buy Subscription', 'card.balance.title': 'Balance', 'promo_code.title': 'Activate promo code', @@ -3334,6 +3771,51 @@ '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.stars.invoice_hint': 'Оплата проходит целыми звёздами. Счёт: {stars} ⭐.', + 'topup.method.stars.adjusted': 'Вы запросили {requested}. Итог к оплате: {amount} ({stars} ⭐).', + '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.title': 'СБП (рекомендуется)', + 'topup.method.pal24.option.sbp.description': 'Мгновенный перевод без комиссии через СБП', + 'topup.method.pal24.option.card.title': 'Банковская карта', + 'topup.method.pal24.option.card.description': 'Оплата картой через PayPalych', + '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.submit': 'Продолжить', + 'topup.cancel': 'Закрыть', + 'topup.back': 'Назад', + 'topup.open_link': 'Перейти к оплате', + 'topup.loading': 'Готовим платеж…', + 'topup.error.generic': 'Не удалось создать платеж. Попробуйте ещё раз позже.', + 'topup.error.amount': 'Введите корректную сумму в пределах лимитов.', + 'topup.error.unavailable': 'Способ оплаты временно недоступен.', + 'topup.status.pending.title': 'Ожидаем подтверждение', + 'topup.status.pending.description': 'Завершите оплату у выбранного провайдера. Это окно обновится автоматически.', + 'topup.status.refreshing.description': 'Обновляем статус платежа…', + 'topup.status.success.title': 'Платеж зачислен', + 'topup.status.success.description': 'Средства успешно поступили на ваш баланс.', + 'topup.status.failed.title': 'Платеж не подтверждён', + 'topup.status.failed.description': 'Не удалось подтвердить платеж автоматически. Проверьте позже или обратитесь в поддержку.', + 'topup.status.retry': 'Повторить попытку', + 'topup.done': 'Готово', 'button.buy_subscription': 'Купить подписку', 'card.balance.title': 'Баланс', 'promo_code.title': 'Активировать промокод', @@ -3609,6 +4091,569 @@ let preferredLanguage = 'en'; let languageLockedByUser = false; let currentErrorState = null; + let paymentMethodsCache = null; + let paymentMethodsPromise = null; + let activePaymentMethod = null; + const paymentMethodSelections = {}; + const activePaymentMonitors = new Map(); + let paymentStatusPollTimer = null; + let isPaymentStatusPolling = false; + + const PAYMENT_STATUS_INITIAL_DELAY_MS = 2000; + const PAYMENT_STATUS_POLL_INTERVAL_MS = 5000; + const PAYMENT_STATUS_TIMEOUT_MS = 180000; + + function cleanupPaymentPollersIfIdle() { + if (paymentStatusPollTimer) { + clearTimeout(paymentStatusPollTimer); + paymentStatusPollTimer = null; + } + isPaymentStatusPolling = false; + } + + function stopAllPaymentMonitors() { + activePaymentMonitors.clear(); + cleanupPaymentPollersIfIdle(); + } + + function createPaymentStatusView() { + const container = document.createElement('div'); + container.className = 'payment-status'; + + const icon = document.createElement('div'); + icon.className = 'payment-status-icon'; + + const content = document.createElement('div'); + content.className = 'payment-status-content'; + + const title = document.createElement('div'); + title.className = 'payment-status-title'; + + const description = document.createElement('div'); + description.className = 'payment-status-description'; + + content.appendChild(title); + content.appendChild(description); + container.appendChild(icon); + container.appendChild(content); + + return { element: container, icon, title, description }; + } + + function updatePaymentStatusView(view, options = {}) { + if (!view) { + return; + } + + const { state = 'pending', message = null, extraLines = [] } = options; + const normalizedState = state === 'refreshing' ? 'pending' : state; + + const fallbackTitles = { + pending: 'Waiting for confirmation', + success: 'Payment received', + failed: 'Payment not confirmed', + }; + + const fallbackDescriptions = { + pending: 'Complete the payment in the selected provider. We will update this window automatically.', + refreshing: 'Updating payment status…', + success: 'Funds have been credited to your balance.', + failed: 'We could not confirm the payment automatically. Please check later or contact support.', + }; + + view.element.classList.remove('success', 'error'); + view.icon.innerHTML = ''; + + if (state === 'success') { + view.element.classList.add('success'); + view.icon.textContent = '✅'; + } else if (state === 'failed') { + view.element.classList.add('error'); + view.icon.textContent = '⚠️'; + } else { + const spinner = document.createElement('div'); + spinner.className = 'payment-status-spinner'; + view.icon.appendChild(spinner); + } + + const titleKey = `topup.status.${normalizedState}.title`; + const translatedTitle = t(titleKey); + view.title.textContent = translatedTitle && translatedTitle !== titleKey + ? translatedTitle + : (fallbackTitles[normalizedState] || 'Payment status'); + + let descriptionKey = `topup.status.${state}.description`; + if (state === 'refreshing') { + descriptionKey = 'topup.status.refreshing.description'; + } + const translatedDescription = t(descriptionKey); + const defaultDescription = translatedDescription && translatedDescription !== descriptionKey + ? translatedDescription + : (fallbackDescriptions[state] || fallbackDescriptions[normalizedState] || ''); + + const lines = []; + if (message) { + lines.push(message); + } + if (Array.isArray(extraLines)) { + extraLines.filter(Boolean).forEach(line => lines.push(line)); + } + if (!message && defaultDescription) { + lines.push(defaultDescription); + } else if (message && defaultDescription && defaultDescription !== message) { + lines.push(defaultDescription); + } + + view.description.textContent = lines.filter(Boolean).join(' '); + } + + function applyPaymentSubtitle(monitor, state) { + if (!monitor || !monitor.method) { + return; + } + + if (state === 'success') { + setTopupModalSubtitle('topup.status.success.title', 'Payment received'); + return; + } + + if (state === 'failed') { + setTopupModalSubtitle('topup.status.failed.title', 'Payment not confirmed'); + return; + } + + if (monitor.method.id === 'pal24') { + const option = (monitor.option || 'sbp').toLowerCase(); + const optionKey = option === 'card' ? 'card' : 'sbp'; + const fallback = optionKey === 'card' + ? 'Bank card payment' + : 'Faster Payments (SBP)'; + setTopupModalSubtitle(`topup.method.pal24.option.${optionKey}.title`, fallback); + return; + } + + setTopupModalSubtitle(`topup.method.${monitor.method.id}.title`, monitor.method.id); + } + + function buildPaymentStatusQuery(methodId, amountKopeks, extra = {}) { + if (!methodId) { + return null; + } + + const query = { method: methodId }; + const identifiers = {}; + + if (extra.local_payment_id !== undefined && extra.local_payment_id !== null) { + query.localPaymentId = extra.local_payment_id; + identifiers.localPaymentId = extra.local_payment_id; + } + + const invoiceIdentifier = extra.invoice_id || extra.bill_id || extra.order_id || extra.invoiceId; + if (invoiceIdentifier) { + query.invoiceId = invoiceIdentifier; + identifiers.invoiceId = invoiceIdentifier; + } + + if (extra.payment_id) { + query.paymentId = extra.payment_id; + identifiers.paymentId = extra.payment_id; + } + + const payloadValue = extra.payload || extra.invoice_payload; + if (payloadValue) { + query.payload = payloadValue; + identifiers.payload = payloadValue; + } + + if (Number.isFinite(amountKopeks)) { + query.amountKopeks = amountKopeks; + identifiers.amountKopeks = amountKopeks; + } + + const startedAtRaw = extra.started_at || extra.requested_at || extra.startedAt; + const startedAt = startedAtRaw ? String(startedAtRaw) : new Date().toISOString(); + query.startedAt = startedAt; + identifiers.startedAt = startedAt; + + if (extra.external_id) { + identifiers.externalId = extra.external_id; + } + + return { query, identifiers, startedAt }; + } + + function applyPaymentFooterState(monitor, state) { + if (!monitor || !monitor.method) { + return; + } + + const method = monitor.method; + const fallbackRawInput = monitor.rawInput ?? ( + Number.isFinite(monitor.amountKopeks) + ? (monitor.amountKopeks / 100).toString() + : '' + ); + + const goBack = () => { + stopAllPaymentMonitors(); + if (method.requires_amount) { + renderTopupAmountForm(method, { + rawInput: fallbackRawInput, + selectedOption: monitor.option || paymentMethodSelections[method.id], + }); + } else { + renderTopupMethodsView(); + } + }; + + const buttons = []; + if (state === 'pending') { + buttons.push({ + id: 'topupBackButton', + labelKey: 'topup.back', + fallbackLabel: 'Back', + variant: 'secondary', + onClick: goBack, + }); + buttons.push({ + id: 'topupOpenLinkButton', + labelKey: 'topup.open_link', + fallbackLabel: 'Open payment page', + variant: 'primary', + onClick: () => openExternalLink(monitor.paymentUrl || monitor.originalUrl), + }); + } else if (state === 'success') { + buttons.push({ + id: 'topupBackButton', + labelKey: 'topup.back', + fallbackLabel: 'Back', + variant: 'secondary', + onClick: () => { + stopAllPaymentMonitors(); + renderTopupMethodsView(); + }, + }); + buttons.push({ + id: 'topupCloseButton', + labelKey: 'topup.cancel', + fallbackLabel: 'Close', + variant: 'primary', + onClick: () => { + stopAllPaymentMonitors(); + closeTopupModal(); + }, + }); + } else if (state === 'failed') { + buttons.push({ + id: 'topupBackButton', + labelKey: 'topup.back', + fallbackLabel: 'Back', + variant: 'secondary', + onClick: goBack, + }); + buttons.push({ + id: 'topupRetryButton', + labelKey: 'topup.status.retry', + fallbackLabel: 'Try again', + variant: 'primary', + onClick: () => { + stopAllPaymentMonitors(); + startPaymentForMethod(method, monitor.amountKopeks, { + rawInput: monitor.rawInput ?? fallbackRawInput, + providerOption: monitor.option || monitor.extra?.selected_option, + }); + }, + }); + } + + setTopupFooter(buttons); + monitor.backButton = document.getElementById('topupBackButton'); + monitor.openButton = document.getElementById('topupOpenLinkButton'); + monitor.retryButton = document.getElementById('topupRetryButton'); + monitor.closeButton = document.getElementById('topupCloseButton'); + + if (state === 'success' && monitor.openButton) { + monitor.openButton.disabled = true; + } + } + + function startPaymentStatusMonitor(context) { + if (!context || !context.method) { + return null; + } + + const { query, identifiers } = buildPaymentStatusQuery( + context.method.id, + Number.isFinite(context.amountKopeks) ? context.amountKopeks : null, + context.extra || {}, + ) || {}; + + const statusView = context.statusView || createPaymentStatusView(); + updatePaymentStatusView(statusView, { state: 'pending' }); + + const monitorId = `monitor_${Date.now()}_${Math.random().toString(16).slice(2)}`; + const monitor = { + id: monitorId, + method: context.method, + methodId: context.method.id, + statusView, + amountKopeks: Number.isFinite(context.amountKopeks) ? context.amountKopeks : null, + option: (context.option || context.extra?.selected_option || null) || null, + paymentUrl: context.paymentUrl || context.originalUrl || null, + originalUrl: context.originalUrl || context.paymentUrl || null, + rawInput: context.rawInput || null, + extra: context.extra || {}, + query: query || { method: context.method.id }, + identifiers: identifiers || {}, + state: 'pending', + createdAt: Date.now(), + timeoutMs: PAYMENT_STATUS_TIMEOUT_MS, + refreshed: false, + pollingDisabled: !(tg.initData || '').length, + }; + + activePaymentMonitors.set(monitorId, monitor); + applyPaymentSubtitle(monitor, 'pending'); + applyPaymentFooterState(monitor, 'pending'); + + if (!monitor.pollingDisabled) { + schedulePaymentStatusPoll(true); + } + + return monitor; + } + + function schedulePaymentStatusPoll(immediate = false) { + const hasPollableMonitor = Array.from(activePaymentMonitors.values()).some( + monitor => monitor && !monitor.pollingDisabled && monitor.state !== 'success' && monitor.state !== 'failed' + ); + + if (!hasPollableMonitor) { + cleanupPaymentPollersIfIdle(); + return; + } + + if (paymentStatusPollTimer) { + return; + } + + const delay = immediate ? PAYMENT_STATUS_INITIAL_DELAY_MS : PAYMENT_STATUS_POLL_INTERVAL_MS; + paymentStatusPollTimer = setTimeout(() => { + paymentStatusPollTimer = null; + pollPaymentStatuses(); + }, Math.max(0, delay)); + } + + async function pollPaymentStatuses() { + if (isPaymentStatusPolling) { + return; + } + + const initData = tg.initData || ''; + if (!initData) { + cleanupPaymentPollersIfIdle(); + return; + } + + const now = Date.now(); + const pendingMonitors = []; + + activePaymentMonitors.forEach(monitor => { + if (!monitor || monitor.pollingDisabled || monitor.state === 'success' || monitor.state === 'failed') { + return; + } + + if (monitor.createdAt + monitor.timeoutMs < now) { + finalizePaymentMonitor(monitor, { state: 'failed' }); + return; + } + + pendingMonitors.push(monitor); + }); + + if (!pendingMonitors.length) { + cleanupPaymentPollersIfIdle(); + return; + } + + isPaymentStatusPolling = true; + pendingMonitors.forEach(monitor => { + monitor.state = 'refreshing'; + updatePaymentStatusView(monitor.statusView, { state: 'refreshing' }); + }); + + try { + const payload = { + initData, + payments: pendingMonitors.map(monitor => ({ ...monitor.query })), + }; + + 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 || !Array.isArray(data?.results)) { + throw new Error('Failed to fetch payment statuses'); + } + + handlePaymentStatusResults(data.results); + } catch (error) { + console.warn('Payment status polling failed:', error); + pendingMonitors.forEach(monitor => { + if (monitor.state === 'refreshing') { + monitor.state = 'pending'; + updatePaymentStatusView(monitor.statusView, { state: 'pending' }); + } + }); + } finally { + isPaymentStatusPolling = false; + schedulePaymentStatusPoll(false); + } + } + + function normalizeIdentifier(value) { + if (value === undefined || value === null) { + return null; + } + return String(value); + } + + function findMonitorForResult(result) { + if (!result || !result.method) { + return null; + } + + const methodId = String(result.method).toLowerCase(); + const monitors = Array.from(activePaymentMonitors.values()).filter( + monitor => monitor && monitor.methodId === methodId + ); + + if (!monitors.length) { + return null; + } + + const extra = result.extra || {}; + const localId = normalizeIdentifier(extra.local_payment_id); + const invoiceId = normalizeIdentifier(extra.invoice_id || extra.bill_id); + const paymentId = normalizeIdentifier(extra.payment_id); + const payload = normalizeIdentifier(extra.payload); + const externalId = normalizeIdentifier(result.external_id); + const amount = Number.isFinite(result.amount_kopeks) ? Number(result.amount_kopeks) : null; + + let fallback = null; + for (const monitor of monitors) { + const ids = monitor.identifiers || {}; + + if (localId && normalizeIdentifier(ids.localPaymentId) === localId) { + return monitor; + } + if (invoiceId && normalizeIdentifier(ids.invoiceId) === invoiceId) { + return monitor; + } + if (paymentId && normalizeIdentifier(ids.paymentId) === paymentId) { + return monitor; + } + if (payload && normalizeIdentifier(ids.payload) === payload) { + return monitor; + } + if (externalId && normalizeIdentifier(ids.externalId) === externalId) { + return monitor; + } + if (amount !== null && monitor.amountKopeks === amount) { + fallback = monitor; + } + } + + return fallback || monitors[0] || null; + } + + function handlePaymentStatusResults(results) { + if (!Array.isArray(results)) { + return; + } + + results.forEach(result => { + const monitor = findMonitorForResult(result); + if (!monitor) { + return; + } + + const extra = result.extra || {}; + if (extra.local_payment_id && !monitor.query.localPaymentId) { + monitor.query.localPaymentId = extra.local_payment_id; + monitor.identifiers.localPaymentId = extra.local_payment_id; + } + if ((extra.invoice_id || extra.bill_id) && !monitor.query.invoiceId) { + const invoiceId = extra.invoice_id || extra.bill_id; + monitor.query.invoiceId = invoiceId; + monitor.identifiers.invoiceId = invoiceId; + } + if (extra.payment_id && !monitor.query.paymentId) { + monitor.query.paymentId = extra.payment_id; + monitor.identifiers.paymentId = extra.payment_id; + } + if (extra.payload && !monitor.query.payload) { + monitor.query.payload = extra.payload; + monitor.identifiers.payload = extra.payload; + } + if (extra.external_id) { + monitor.identifiers.externalId = extra.external_id; + } + + const status = String(result.status || '').toLowerCase(); + const message = typeof result.message === 'string' ? result.message : null; + + if (status === 'paid' || result.is_paid) { + finalizePaymentMonitor(monitor, { state: 'success', result, message }); + return; + } + + if (status === 'failed') { + const failureMessage = message || (extra.remote_status ? `Status: ${extra.remote_status}` : null); + finalizePaymentMonitor(monitor, { state: 'failed', result, message: failureMessage }); + return; + } + + monitor.state = 'pending'; + updatePaymentStatusView(monitor.statusView, { state: 'pending', message }); + }); + } + + function finalizePaymentMonitor(monitor, options = {}) { + if (!monitor) { + return; + } + + const { state, message } = options; + + if (state === 'success') { + monitor.state = 'success'; + updatePaymentStatusView(monitor.statusView, { state: 'success', message }); + applyPaymentSubtitle(monitor, 'success'); + applyPaymentFooterState(monitor, 'success'); + + if (!monitor.refreshed) { + monitor.refreshed = true; + refreshSubscriptionData({ silent: true }).catch(error => { + console.warn('Failed to refresh subscription data:', error); + }); + } + } else if (state === 'failed') { + monitor.state = 'failed'; + updatePaymentStatusView(monitor.statusView, { state: 'failed', message }); + applyPaymentSubtitle(monitor, 'failed'); + applyPaymentFooterState(monitor, 'failed'); + } + + activePaymentMonitors.delete(monitor.id); + + if (!activePaymentMonitors.size) { + cleanupPaymentPollersIfIdle(); + } + } function resolveLanguage(lang) { if (!lang) { @@ -5269,6 +6314,755 @@ 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; + stopAllPaymentMonitors(); + 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; + stopAllPaymentMonitors(); + 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, { selectedOption: paymentMethodSelections[method.id] }); + } else { + startPaymentForMethod(method); + } + } + + function renderTopupAmountForm(method, options = {}) { + activePaymentMethod = method; + stopAllPaymentMonitors(); + 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); + } + + if (method.id === 'pal24') { + const optionsConfig = [ + { + id: 'sbp', + icon: '🏦', + titleKey: 'topup.method.pal24.option.sbp.title', + descriptionKey: 'topup.method.pal24.option.sbp.description', + fallbackTitle: 'Faster Payments (SBP)', + fallbackDescription: 'Instant SBP transfer with no fees.', + }, + { + id: 'card', + icon: '💳', + titleKey: 'topup.method.pal24.option.card.title', + descriptionKey: 'topup.method.pal24.option.card.description', + fallbackTitle: 'Bank card', + fallbackDescription: 'Pay with a bank card via PayPalych.', + }, + ]; + + const selectedDefault = options.selectedOption + || paymentMethodSelections[method.id] + || 'sbp'; + let currentOption = optionsConfig.some(option => option.id === selectedDefault) + ? selectedDefault + : 'sbp'; + paymentMethodSelections[method.id] = currentOption; + form.dataset.paymentOption = currentOption; + + const optionGroup = document.createElement('div'); + optionGroup.className = 'payment-option-group'; + + const optionTitle = document.createElement('div'); + optionTitle.className = 'payment-option-title'; + const titleKey = 'topup.method.pal24.title'; + const titleValue = t(titleKey); + optionTitle.textContent = titleValue === titleKey ? 'Choose payment type' : titleValue; + optionGroup.appendChild(optionTitle); + + const optionList = document.createElement('div'); + optionList.className = 'payment-option-list'; + + optionsConfig.forEach(config => { + const button = document.createElement('button'); + button.type = 'button'; + button.className = 'payment-option-button'; + button.dataset.optionId = config.id; + if (config.id === currentOption) { + button.classList.add('active'); + } + + const icon = document.createElement('div'); + icon.className = 'payment-option-icon'; + icon.textContent = config.icon; + + const text = document.createElement('div'); + text.className = 'payment-option-text'; + + const label = document.createElement('div'); + label.className = 'payment-option-label'; + const labelValue = t(config.titleKey); + label.textContent = labelValue === config.titleKey ? config.fallbackTitle : labelValue; + + const description = document.createElement('div'); + description.className = 'payment-option-description'; + const descriptionValue = t(config.descriptionKey); + const finalDescription = descriptionValue === config.descriptionKey + ? config.fallbackDescription + : descriptionValue; + description.textContent = finalDescription; + + text.appendChild(label); + if (finalDescription) { + text.appendChild(description); + } + + button.appendChild(icon); + button.appendChild(text); + + button.addEventListener('click', () => { + currentOption = config.id; + paymentMethodSelections[method.id] = currentOption; + form.dataset.paymentOption = currentOption; + optionList.querySelectorAll('.payment-option-button').forEach(btn => { + btn.classList.toggle('active', btn.dataset.optionId === currentOption); + }); + }); + + optionList.appendChild(button); + }); + + optionGroup.appendChild(optionList); + form.appendChild(optionGroup); + } else { + delete form.dataset.paymentOption; + } + + form.addEventListener('submit', event => { + event.preventDefault(); + submitTopupAmount(method); + }); + + body.appendChild(form); + + if (options.rawInput) { + input.value = options.rawInput; + } + + 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(); + const form = document.getElementById('topupAmountForm'); + const providerOption = form?.dataset?.paymentOption || paymentMethodSelections[method.id] || null; + await startPaymentForMethod(method, amountKopeks, { + rawInput: rawValue, + providerOption, + }); + } + + 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) { + renderTopupAmountForm(method, { + rawInput: options.rawInput, + selectedOption: options.providerOption || paymentMethodSelections[method?.id], + }); + 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; + } + if (options.providerOption) { + payload.option = options.providerOption; + } + + 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'); + } + + renderTopupPaymentLink( + method, + paymentUrl, + data?.amount_kopeks ?? amountKopeks, + data?.extra || {}, + { + rawInput: options.rawInput, + providerOption: options.providerOption, + }, + ); + } catch (error) { + console.error('Failed to create payment:', error); + if (method?.requires_amount) { + renderTopupAmountForm(method, { + rawInput: options.rawInput, + selectedOption: options.providerOption || paymentMethodSelections[method?.id], + }); + 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 renderTopupPaymentLink(method, paymentUrl, amountKopeks, extra = {}, options = {}) { + stopAllPaymentMonitors(); + clearTopupError(); + setTopupModalTitle('topup.title', 'Top up balance'); + + const { body } = getTopupElements(); + if (!body) { + return; + } + + const normalizedAmount = Number.isFinite(amountKopeks) ? Number(amountKopeks) : null; + const monitorExtra = { ...extra }; + + let option = null; + if (method.id === 'pal24') { + option = (options.providerOption || monitorExtra.selected_option || paymentMethodSelections[method.id] || 'sbp').toLowerCase(); + if (!['card', 'sbp'].includes(option)) { + option = 'sbp'; + } + paymentMethodSelections[method.id] = option; + monitorExtra.selected_option = option; + } + + const titleKey = method.id === 'pal24' && option + ? `topup.method.pal24.option.${option}.title` + : `topup.method.${method.id}.title`; + const titleFallback = method.id === 'pal24' + ? (option === 'card' ? 'Bank card payment' : 'Faster Payments (SBP)') + : method.id; + setTopupModalSubtitle(titleKey, titleFallback); + + body.innerHTML = ''; + const summary = document.createElement('div'); + summary.className = 'payment-summary'; + + const titleValue = t(titleKey); + const title = document.createElement('div'); + title.className = 'payment-method-label'; + title.textContent = titleValue && titleValue !== titleKey ? titleValue : titleFallback; + summary.appendChild(title); + + if (normalizedAmount && normalizedAmount > 0) { + const amount = document.createElement('div'); + amount.className = 'payment-summary-amount'; + const currency = (method.currency || userData?.balance_currency || 'RUB').toUpperCase(); + amount.textContent = formatCurrency(normalizedAmount / 100, currency); + summary.appendChild(amount); + + if (method.id === 'stars') { + const starsAmount = Number.isFinite(monitorExtra?.stars_amount) + ? Number(monitorExtra.stars_amount) + : null; + const requestedAmount = Number.isFinite(monitorExtra?.requested_amount_kopeks) + ? Number(monitorExtra.requested_amount_kopeks) + : null; + + if (starsAmount && starsAmount > 0) { + const template = t('topup.method.stars.invoice_hint'); + const replacement = template && template !== 'topup.method.stars.invoice_hint' + ? template.replace('{stars}', String(starsAmount)) + : `${starsAmount} ⭐`; + const hint = document.createElement('div'); + hint.className = 'amount-hint'; + hint.textContent = replacement; + summary.appendChild(hint); + } + + if ( + requestedAmount && + requestedAmount > 0 && + requestedAmount !== normalizedAmount + ) { + const template = t('topup.method.stars.adjusted'); + const requestedLabel = formatCurrency(requestedAmount / 100, currency); + const normalizedLabel = formatCurrency(normalizedAmount / 100, currency); + let replacement = template; + if (replacement && replacement !== 'topup.method.stars.adjusted') { + replacement = replacement + .replace('{requested}', requestedLabel) + .replace('{amount}', normalizedLabel) + .replace('{stars}', String(starsAmount ?? '')); + } else { + replacement = `Requested ${requestedLabel}, invoice ${normalizedLabel}`; + } + const hint = document.createElement('div'); + hint.className = 'amount-hint warning'; + hint.textContent = replacement; + summary.appendChild(hint); + } + } + } + + if (Number.isFinite(monitorExtra?.amount_usd) && monitorExtra.amount_usd > 0) { + const usdAmount = document.createElement('div'); + usdAmount.className = 'amount-hint'; + usdAmount.textContent = `≈ ${formatCurrency(monitorExtra.amount_usd, 'USD')}`; + summary.appendChild(usdAmount); + } + + const descriptionKey = method.id === 'pal24' && option + ? `topup.method.pal24.option.${option}.description` + : `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); + + let effectiveUrl = paymentUrl; + if (method.id === 'pal24') { + const urls = []; + if (option === 'sbp') { + urls.push(monitorExtra.sbp_url, monitorExtra.transfer_url); + } + if (option === 'card') { + urls.push(monitorExtra.card_url); + } + urls.push( + monitorExtra.link_url, + monitorExtra.link_page_url, + monitorExtra.transfer_url, + paymentUrl, + ); + effectiveUrl = urls.find(url => typeof url === 'string' && url.length) || paymentUrl; + } + + const statusView = createPaymentStatusView(); + body.appendChild(statusView.element); + updatePaymentStatusView(statusView, { state: 'pending' }); + + startPaymentStatusMonitor({ + method, + amountKopeks: normalizedAmount, + extra: monitorExtra, + statusView, + paymentUrl: effectiveUrl, + originalUrl: paymentUrl, + rawInput: options.rawInput || null, + option, + }); + } + function updateReferralToggleState() { const list = document.getElementById('referralList'); const empty = document.getElementById('referralListEmpty'); @@ -6758,6 +8552,31 @@ openExternalLink(link); }); + const topupButton = document.getElementById('topupBalanceBtn'); + if (topupButton) { + topupButton.addEventListener('click', () => { + openTopupModal(); + }); + } + + const topupModal = document.getElementById('topupModal'); + if (topupModal) { + topupModal.addEventListener('click', event => { + if (event.target === topupModal) { + closeTopupModal(); + } + }); + } + + window.addEventListener('keydown', event => { + if (event.key === 'Escape') { + const { backdrop } = getTopupElements(); + if (backdrop && !backdrop.classList.contains('hidden')) { + closeTopupModal(); + } + } + }); + document.getElementById('copyBtn')?.addEventListener('click', async () => { const subscriptionUrl = getCurrentSubscriptionUrl(); if (!subscriptionUrl || !navigator.clipboard) { diff --git a/tests/test_miniapp_payments.py b/tests/test_miniapp_payments.py new file mode 100644 index 00000000..8b55bc3a --- /dev/null +++ b/tests/test_miniapp_payments.py @@ -0,0 +1,353 @@ +import os +import sys +import types +from datetime import datetime, timedelta +from pathlib import Path + +import pytest + +ROOT_DIR = Path(__file__).resolve().parents[1] +if str(ROOT_DIR) not in sys.path: + sys.path.insert(0, str(ROOT_DIR)) + +BACKUP_DIR = ROOT_DIR / 'data' / 'backups' +BACKUP_DIR.mkdir(parents=True, exist_ok=True) + +os.environ.setdefault('BOT_TOKEN', 'test-token') + +from app.config import settings +from app.webapi.routes import miniapp +from app.database.models import PaymentMethod +from app.webapi.schemas.miniapp import ( + MiniAppPaymentCreateRequest, + MiniAppPaymentMethodsRequest, + MiniAppPaymentStatusQuery, +) + + +@pytest.fixture +def anyio_backend(): + return 'asyncio' + + +def test_compute_cryptobot_limits_scale_with_rate(): + low_rate_min, low_rate_max = miniapp._compute_cryptobot_limits(70.0) + high_rate_min, high_rate_max = miniapp._compute_cryptobot_limits(120.0) + + assert low_rate_min == 7000 + assert low_rate_max == 7000000 + assert high_rate_min == 12000 + assert high_rate_max == 12000000 + assert high_rate_min > low_rate_min + assert high_rate_max > low_rate_max + + +@pytest.mark.anyio("asyncio") +async def test_create_payment_link_pal24_uses_selected_option(monkeypatch): + monkeypatch.setattr(settings, 'PAL24_ENABLED', True, raising=False) + monkeypatch.setattr(settings, 'PAL24_API_TOKEN', 'token', raising=False) + monkeypatch.setattr(settings, 'PAL24_SHOP_ID', 'shop', raising=False) + monkeypatch.setattr(settings, 'PAL24_MIN_AMOUNT_KOPEKS', 1000, raising=False) + monkeypatch.setattr(settings, 'PAL24_MAX_AMOUNT_KOPEKS', 5000000, raising=False) + + captured_calls = [] + + class DummyPaymentService: + def __init__(self, *args, **kwargs): + pass + + async def create_pal24_payment(self, db, **kwargs): + captured_calls.append({'db': db, **kwargs}) + return { + 'local_payment_id': 101, + 'bill_id': 'BILL42', + 'order_id': 'ORD42', + 'payment_method': kwargs.get('payment_method'), + 'sbp_url': 'https://sbp', + 'card_url': 'https://card', + 'link_url': 'https://link', + } + + async def fake_resolve_user(db, init_data): + return types.SimpleNamespace(id=123, language='ru'), {} + + monkeypatch.setattr(miniapp, 'PaymentService', lambda *args, **kwargs: DummyPaymentService()) + monkeypatch.setattr(miniapp, '_resolve_user_from_init_data', fake_resolve_user) + + payload = MiniAppPaymentCreateRequest( + initData='test', + method='pal24', + amountKopeks=15000, + option='card', + ) + + response = await miniapp.create_payment_link(payload, db=types.SimpleNamespace()) + + assert response.payment_url == 'https://card' + assert response.extra['selected_option'] == 'card' + assert response.extra['payment_method'] == 'CARD' + assert captured_calls and captured_calls[0]['payment_method'] == 'CARD' + + +@pytest.mark.anyio("asyncio") +async def test_resolve_yookassa_status_includes_identifiers(monkeypatch): + payment = types.SimpleNamespace( + id=55, + user_id=1, + amount_kopeks=15000, + currency='RUB', + status='pending', + is_paid=False, + captured_at=None, + updated_at=None, + created_at=datetime.utcnow(), + transaction_id=42, + yookassa_payment_id='yk_1', + ) + + async def fake_get_by_local_id(db, local_id): + return payment if local_id == 55 else None + + async def fake_get_by_id(db, payment_id): + return None + + stub_module = types.SimpleNamespace( + get_yookassa_payment_by_local_id=fake_get_by_local_id, + get_yookassa_payment_by_id=fake_get_by_id, + ) + monkeypatch.setitem(sys.modules, 'app.database.crud.yookassa', stub_module) + + user = types.SimpleNamespace(id=1) + query = MiniAppPaymentStatusQuery( + method='yookassa', + localPaymentId=55, + paymentId='yk_1', + amountKopeks=15000, + startedAt='2024-01-01T00:00:00Z', + payload='payload123', + ) + + result = await miniapp._resolve_yookassa_payment_status(db=None, user=user, query=query) + + assert result.extra['local_payment_id'] == 55 + assert result.extra['payment_id'] == 'yk_1' + assert result.extra['invoice_id'] == 'yk_1' + assert result.extra['payload'] == 'payload123' + assert result.extra['started_at'] == '2024-01-01T00:00:00Z' + + +@pytest.mark.anyio("asyncio") +async def test_resolve_pal24_status_includes_identifiers(monkeypatch): + async def fake_get_pal24_payment_by_bill_id(db, bill_id): + return None + + stub_module = types.SimpleNamespace( + get_pal24_payment_by_bill_id=fake_get_pal24_payment_by_bill_id, + ) + monkeypatch.setitem(sys.modules, 'app.database.crud.pal24', stub_module) + + paid_at = datetime.utcnow() + + payment = types.SimpleNamespace( + id=321, + user_id=1, + amount_kopeks=25000, + currency='RUB', + is_paid=True, + status='PAID', + paid_at=paid_at, + updated_at=paid_at, + created_at=paid_at - timedelta(minutes=1), + transaction_id=777, + bill_id='BILL99', + order_id='ORD99', + payment_method='SBP', + ) + + class StubPal24Service: + async def get_pal24_payment_status(self, db, local_id): + assert local_id == 321 + return { + 'payment': payment, + 'status': 'PAID', + 'remote_status': 'PAID', + } + + user = types.SimpleNamespace(id=1) + query = MiniAppPaymentStatusQuery( + method='pal24', + localPaymentId=321, + amountKopeks=25000, + startedAt='2024-01-01T00:00:00Z', + payload='pal24_payload', + ) + + result = await miniapp._resolve_pal24_payment_status( + StubPal24Service(), + db=None, + user=user, + query=query, + ) + + assert result.status == 'paid' + assert result.extra['local_payment_id'] == 321 + assert result.extra['bill_id'] == 'BILL99' + assert result.extra['order_id'] == 'ORD99' + assert result.extra['payment_method'] == 'SBP' + assert result.extra['payload'] == 'pal24_payload' + assert result.extra['started_at'] == '2024-01-01T00:00:00Z' + assert result.extra['remote_status'] == 'PAID' + + +@pytest.mark.anyio("asyncio") +async def test_create_payment_link_stars_normalizes_amount(monkeypatch): + monkeypatch.setattr(settings, 'TELEGRAM_STARS_ENABLED', True, raising=False) + monkeypatch.setattr(settings, 'TELEGRAM_STARS_RATE_RUB', 1000.0, raising=False) + monkeypatch.setattr(settings, 'BOT_TOKEN', 'test-token', raising=False) + + captured = {} + + class DummyPaymentService: + def __init__(self, bot): + captured['bot'] = bot + + async def create_stars_invoice( + self, + amount_kopeks, + description, + payload, + *, + stars_amount=None, + ): + captured['amount_kopeks'] = amount_kopeks + captured['description'] = description + captured['payload'] = payload + captured['stars_amount'] = stars_amount + return 'https://invoice.example' + + class DummySession: + def __init__(self): + self.closed = False + + async def close(self): + self.closed = True + captured['session_closed'] = True + + class DummyBot: + def __init__(self, token): + captured['bot_token'] = token + self.session = DummySession() + + async def fake_resolve_user(db, init_data): + return types.SimpleNamespace(id=7, language='ru'), {} + + monkeypatch.setattr(miniapp, 'PaymentService', lambda bot: DummyPaymentService(bot)) + monkeypatch.setattr(miniapp, 'Bot', DummyBot) + monkeypatch.setattr(miniapp, '_resolve_user_from_init_data', fake_resolve_user) + + payload = MiniAppPaymentCreateRequest( + initData='data', + method='stars', + amountKopeks=101000, + ) + + response = await miniapp.create_payment_link(payload, db=types.SimpleNamespace()) + + assert response.payment_url == 'https://invoice.example' + assert response.amount_kopeks == 100000 + assert response.extra['stars_amount'] == 1 + assert response.extra['requested_amount_kopeks'] == 101000 + assert captured['amount_kopeks'] == 100000 + assert captured['stars_amount'] == 1 + assert captured['bot_token'] == 'test-token' + assert captured.get('session_closed') is True + + +@pytest.mark.anyio("asyncio") +async def test_get_payment_methods_exposes_stars_min_amount(monkeypatch): + monkeypatch.setattr(settings, 'TELEGRAM_STARS_ENABLED', True, raising=False) + monkeypatch.setattr(settings, 'TELEGRAM_STARS_RATE_RUB', 999.99, raising=False) + + async def fake_resolve_user(db, init_data): + return types.SimpleNamespace(id=1, language='ru'), {} + + monkeypatch.setattr(miniapp, '_resolve_user_from_init_data', fake_resolve_user) + + payload = MiniAppPaymentMethodsRequest(initData='abc') + + response = await miniapp.get_payment_methods(payload, db=types.SimpleNamespace()) + + stars_method = next((method for method in response.methods if method.id == 'stars'), None) + assert stars_method is not None + assert stars_method.min_amount_kopeks == 99999 + assert stars_method.amount_step_kopeks == 99999 +@pytest.mark.anyio("asyncio") +async def test_find_recent_deposit_ignores_transactions_before_attempt(): + started_at = datetime(2024, 5, 1, 12, 0, 0) + + transaction = types.SimpleNamespace( + id=10, + amount_kopeks=1000, + completed_at=None, + created_at=started_at - timedelta(minutes=1), + ) + + class DummyResult: + def __init__(self, value): + self._value = value + + def scalar_one_or_none(self): + return self._value + + class DummySession: + def __init__(self, value): + self._value = value + + async def execute(self, query): # noqa: ARG002 + return DummyResult(self._value) + + result = await miniapp._find_recent_deposit( + DummySession(transaction), + user_id=1, + payment_method=PaymentMethod.TELEGRAM_STARS, + amount_kopeks=1000, + started_at=started_at, + ) + + assert result is None + + +@pytest.mark.anyio("asyncio") +async def test_find_recent_deposit_accepts_recent_transactions(): + started_at = datetime(2024, 5, 1, 12, 0, 0) + + transaction = types.SimpleNamespace( + id=11, + amount_kopeks=1000, + completed_at=started_at + timedelta(seconds=5), + created_at=started_at + timedelta(seconds=5), + ) + + class DummyResult: + def __init__(self, value): + self._value = value + + def scalar_one_or_none(self): + return self._value + + class DummySession: + def __init__(self, value): + self._value = value + + async def execute(self, query): # noqa: ARG002 + return DummyResult(self._value) + + result = await miniapp._find_recent_deposit( + DummySession(transaction), + user_id=1, + payment_method=PaymentMethod.TELEGRAM_STARS, + amount_kopeks=1000, + started_at=started_at, + ) + + assert result is transaction