mirror of
https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot.git
synced 2026-01-20 03:40:26 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
После настройки вы сможете использовать мини-приложение в бот-меню и отправлять ссылку пользователям напрямую.
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user