diff --git a/app/config.py b/app/config.py index 1b47e755..7de6f435 100644 --- a/app/config.py +++ b/app/config.py @@ -6,7 +6,8 @@ import re import html from collections import defaultdict from datetime import time -from typing import List, Optional, Union, Dict +from typing import Dict, List, Optional, Union +from urllib.parse import urlparse from zoneinfo import ZoneInfo from pydantic_settings import BaseSettings from pydantic import field_validator, Field @@ -254,6 +255,7 @@ class Settings(BaseSettings): MULENPAY_PAYMENT_MODE: int = 4 MULENPAY_MIN_AMOUNT_KOPEKS: int = 10000 MULENPAY_MAX_AMOUNT_KOPEKS: int = 10000000 + MULENPAY_IFRAME_EXPECTED_ORIGIN: Optional[str] = None PAL24_ENABLED: bool = False PAL24_API_TOKEN: Optional[str] = None @@ -943,6 +945,20 @@ class Settings(BaseSettings): def get_mulenpay_display_name_html(self) -> str: return html.escape(self.get_mulenpay_display_name()) + def get_mulenpay_expected_origin(self) -> Optional[str]: + override = (self.MULENPAY_IFRAME_EXPECTED_ORIGIN or "").strip() + if override: + return override + + base_url = (self.MULENPAY_BASE_URL or "").strip() + if not base_url: + return None + + parsed = urlparse(base_url) + if parsed.scheme and parsed.netloc: + return f"{parsed.scheme}://{parsed.netloc}" + return None + def is_pal24_enabled(self) -> bool: return ( self.PAL24_ENABLED diff --git a/app/webapi/routes/miniapp.py b/app/webapi/routes/miniapp.py index 3a38a713..f4937461 100644 --- a/app/webapi/routes/miniapp.py +++ b/app/webapi/routes/miniapp.py @@ -10,6 +10,7 @@ from typing import Any, Awaitable, Callable, Dict, List, Optional, Tuple, Union from aiogram import Bot from fastapi import APIRouter, Depends, HTTPException, status +from pydantic import ValidationError from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload @@ -113,6 +114,8 @@ from ..schemas.miniapp import ( MiniAppLegalDocuments, MiniAppPaymentCreateRequest, MiniAppPaymentCreateResponse, + MiniAppPaymentIframeConfig, + MiniAppPaymentIntegrationType, MiniAppPaymentMethod, MiniAppPaymentMethodsRequest, MiniAppPaymentMethodsResponse, @@ -624,6 +627,18 @@ def _normalize_amount_kopeks( return normalized if normalized >= 0 else None +def _build_mulenpay_iframe_config() -> Optional[MiniAppPaymentIframeConfig]: + expected_origin = settings.get_mulenpay_expected_origin() + if not expected_origin: + return None + + try: + return MiniAppPaymentIframeConfig(expected_origin=expected_origin) + except ValidationError as error: # pragma: no cover - defensive logging + logger.error("Invalid MulenPay expected origin '%s': %s", expected_origin, error) + return None + + @router.post( "/payments/methods", response_model=MiniAppPaymentMethodsResponse, @@ -646,6 +661,7 @@ async def get_payment_methods( currency="RUB", min_amount_kopeks=stars_min_amount, amount_step_kopeks=stars_min_amount, + integration_type=MiniAppPaymentIntegrationType.REDIRECT, ) ) @@ -659,6 +675,7 @@ async def get_payment_methods( currency="RUB", min_amount_kopeks=settings.YOOKASSA_MIN_AMOUNT_KOPEKS, max_amount_kopeks=settings.YOOKASSA_MAX_AMOUNT_KOPEKS, + integration_type=MiniAppPaymentIntegrationType.REDIRECT, ) ) @@ -670,10 +687,17 @@ async def get_payment_methods( currency="RUB", min_amount_kopeks=settings.YOOKASSA_MIN_AMOUNT_KOPEKS, max_amount_kopeks=settings.YOOKASSA_MAX_AMOUNT_KOPEKS, + integration_type=MiniAppPaymentIntegrationType.REDIRECT, ) ) if settings.is_mulenpay_enabled(): + mulenpay_iframe_config = _build_mulenpay_iframe_config() + mulenpay_integration = ( + MiniAppPaymentIntegrationType.IFRAME + if mulenpay_iframe_config + else MiniAppPaymentIntegrationType.REDIRECT + ) methods.append( MiniAppPaymentMethod( id="mulenpay", @@ -683,6 +707,8 @@ async def get_payment_methods( currency="RUB", min_amount_kopeks=settings.MULENPAY_MIN_AMOUNT_KOPEKS, max_amount_kopeks=settings.MULENPAY_MAX_AMOUNT_KOPEKS, + integration_type=mulenpay_integration, + iframe_config=mulenpay_iframe_config, ) ) @@ -695,6 +721,7 @@ async def get_payment_methods( currency="RUB", min_amount_kopeks=settings.PAL24_MIN_AMOUNT_KOPEKS, max_amount_kopeks=settings.PAL24_MAX_AMOUNT_KOPEKS, + integration_type=MiniAppPaymentIntegrationType.REDIRECT, ) ) @@ -707,6 +734,7 @@ async def get_payment_methods( currency="RUB", min_amount_kopeks=settings.WATA_MIN_AMOUNT_KOPEKS, max_amount_kopeks=settings.WATA_MAX_AMOUNT_KOPEKS, + integration_type=MiniAppPaymentIntegrationType.REDIRECT, ) ) @@ -721,6 +749,7 @@ async def get_payment_methods( currency="RUB", min_amount_kopeks=min_amount_kopeks, max_amount_kopeks=max_amount_kopeks, + integration_type=MiniAppPaymentIntegrationType.REDIRECT, ) ) @@ -733,6 +762,7 @@ async def get_payment_methods( currency="RUB", min_amount_kopeks=100 * 100, max_amount_kopeks=100_000 * 100, + integration_type=MiniAppPaymentIntegrationType.REDIRECT, ) ) @@ -743,6 +773,7 @@ async def get_payment_methods( icon="💎", requires_amount=False, currency="RUB", + integration_type=MiniAppPaymentIntegrationType.REDIRECT, ) ) diff --git a/app/webapi/schemas/miniapp.py b/app/webapi/schemas/miniapp.py index 839a6f17..ac31f350 100644 --- a/app/webapi/schemas/miniapp.py +++ b/app/webapi/schemas/miniapp.py @@ -1,9 +1,11 @@ from __future__ import annotations from datetime import datetime +from enum import Enum from typing import Any, Dict, List, Optional +from urllib.parse import urlparse -from pydantic import BaseModel, Field, ConfigDict, model_validator +from pydantic import BaseModel, ConfigDict, Field, model_validator class MiniAppBranding(BaseModel): @@ -357,6 +359,30 @@ class MiniAppPaymentMethodsRequest(BaseModel): init_data: str = Field(..., alias="initData") +class MiniAppPaymentIntegrationType(str, Enum): + IFRAME = "iframe" + REDIRECT = "redirect" + + +class MiniAppPaymentIframeConfig(BaseModel): + expected_origin: str + + @model_validator(mode="after") + def _normalize_expected_origin( + cls, values: "MiniAppPaymentIframeConfig" + ) -> "MiniAppPaymentIframeConfig": + origin = (values.expected_origin or "").strip() + if not origin: + raise ValueError("expected_origin must not be empty") + + parsed = urlparse(origin) + if not parsed.scheme or not parsed.netloc: + raise ValueError("expected_origin must include scheme and host") + + values.expected_origin = f"{parsed.scheme}://{parsed.netloc}" + return values + + class MiniAppPaymentMethod(BaseModel): id: str name: Optional[str] = None @@ -366,6 +392,17 @@ class MiniAppPaymentMethod(BaseModel): min_amount_kopeks: Optional[int] = None max_amount_kopeks: Optional[int] = None amount_step_kopeks: Optional[int] = None + integration_type: MiniAppPaymentIntegrationType + iframe_config: Optional[MiniAppPaymentIframeConfig] = None + + @model_validator(mode="after") + def _ensure_iframe_config(cls, values: "MiniAppPaymentMethod") -> "MiniAppPaymentMethod": + if ( + values.integration_type == MiniAppPaymentIntegrationType.IFRAME + and values.iframe_config is None + ): + raise ValueError("iframe_config is required when integration_type is 'iframe'") + return values class MiniAppPaymentMethodsResponse(BaseModel): diff --git a/docs/miniapp-setup.md b/docs/miniapp-setup.md index 4a7bfadf..ed24f132 100644 --- a/docs/miniapp-setup.md +++ b/docs/miniapp-setup.md @@ -151,4 +151,36 @@ Caddy автоматически выпустит сертификаты чер | Mini App не открывается в Telegram | URL не соответствует HTTPS или отсутствует сертификат. | Обновите сертификаты и убедитесь, что домен доступен по HTTPS. | | Нет ссылок подписки | Не настроена интеграция с RemnaWave или у пользователя нет активной подписки. | Проверьте `REMNAWAVE_API_URL/KEY` и статус подписки пользователя. | +## 11. Формат ответа `/miniapp/payments/methods` + +Эндпоинт `POST /miniapp/payments/methods` возвращает список доступных способов оплаты. Каждый элемент массива `methods` содержит базовые поля (`id`, `title`, `description`, ограничения по сумме) и дополнительные атрибуты, управляющие интеграцией во фронтенде: + +- `integration_type` — тип интеграции, обязательное поле. Возможные значения: + - `iframe` — форму оплаты нужно отображать внутри мини-приложения в `