Merge pull request #1866 from Fr1ngg/bedolaga/extend-api-for-payment-method-integration-types

[FEAT] Add integration metadata for miniapp payment methods
This commit is contained in:
Egor
2025-11-11 08:15:06 +03:00
committed by GitHub
5 changed files with 147 additions and 2 deletions

View File

@@ -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

View File

@@ -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,
)
)

View File

@@ -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):

View File

@@ -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` — форму оплаты нужно отображать внутри мини-приложения в `<iframe>`.
- `redirect` — необходимо выполнить переход на внешнюю страницу.
- `iframe_config` — объект с настройками, присутствует только при `integration_type = "iframe"`.
- `expected_origin` — origin страницы платёжного провайдера. Значение используется при проверке `event.origin` для сообщений, полученных через `postMessage` от платежного iframe.
Пример фрагмента ответа:
```json
{
"methods": [
{
"id": "mulenpay",
"title": "Банковская карта",
"integration_type": "iframe",
"iframe_config": {
"expected_origin": "https://checkout.example"
}
},
{
"id": "cryptobot",
"title": "CryptoBot",
"integration_type": "redirect"
}
]
}
```
После настройки вы сможете использовать мини-приложение в бот-меню и отправлять ссылку пользователям напрямую.

View File

@@ -21,6 +21,7 @@ from app.webapi.routes import miniapp
from app.database.models import PaymentMethod
from app.webapi.schemas.miniapp import (
MiniAppPaymentCreateRequest,
MiniAppPaymentIntegrationType,
MiniAppPaymentMethodsRequest,
MiniAppPaymentStatusQuery,
)
@@ -491,6 +492,8 @@ async def test_get_payment_methods_exposes_stars_min_amount(monkeypatch):
assert stars_method is not None
assert stars_method.min_amount_kopeks == 99999
assert stars_method.amount_step_kopeks == 99999
assert stars_method.integration_type == MiniAppPaymentIntegrationType.REDIRECT
assert stars_method.iframe_config is None
@pytest.mark.anyio("asyncio")
@@ -515,6 +518,32 @@ async def test_get_payment_methods_includes_wata(monkeypatch):
assert wata_method.min_amount_kopeks == 5000
assert wata_method.max_amount_kopeks == 7500000
assert wata_method.icon == '🌊'
assert wata_method.integration_type == MiniAppPaymentIntegrationType.REDIRECT
assert wata_method.iframe_config is None
@pytest.mark.anyio("asyncio")
async def test_get_payment_methods_marks_mulenpay_iframe(monkeypatch):
monkeypatch.setattr(settings, 'MULENPAY_ENABLED', True, raising=False)
monkeypatch.setattr(settings, 'MULENPAY_API_KEY', 'api-key', raising=False)
monkeypatch.setattr(settings, 'MULENPAY_SECRET_KEY', 'secret', raising=False)
monkeypatch.setattr(settings, 'MULENPAY_SHOP_ID', 99, raising=False)
monkeypatch.setattr(settings, 'MULENPAY_BASE_URL', 'https://checkout.example/api', raising=False)
monkeypatch.setattr(settings, 'MULENPAY_IFRAME_EXPECTED_ORIGIN', None, 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())
mulenpay_method = next((method for method in response.methods if method.id == 'mulenpay'), None)
assert mulenpay_method is not None
assert mulenpay_method.integration_type == MiniAppPaymentIntegrationType.IFRAME
assert mulenpay_method.iframe_config is not None
assert str(mulenpay_method.iframe_config.expected_origin) == 'https://checkout.example'
@pytest.mark.anyio("asyncio")
async def test_find_recent_deposit_ignores_transactions_before_attempt():
started_at = datetime(2024, 5, 1, 12, 0, 0)