feat: expose integration metadata for miniapp payments
This commit is contained in:
+17
-1
@@ -6,7 +6,8 @@ import re
|
|||||||
import html
|
import html
|
||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
from datetime import time
|
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 zoneinfo import ZoneInfo
|
||||||
from pydantic_settings import BaseSettings
|
from pydantic_settings import BaseSettings
|
||||||
from pydantic import field_validator, Field
|
from pydantic import field_validator, Field
|
||||||
@@ -254,6 +255,7 @@ class Settings(BaseSettings):
|
|||||||
MULENPAY_PAYMENT_MODE: int = 4
|
MULENPAY_PAYMENT_MODE: int = 4
|
||||||
MULENPAY_MIN_AMOUNT_KOPEKS: int = 10000
|
MULENPAY_MIN_AMOUNT_KOPEKS: int = 10000
|
||||||
MULENPAY_MAX_AMOUNT_KOPEKS: int = 10000000
|
MULENPAY_MAX_AMOUNT_KOPEKS: int = 10000000
|
||||||
|
MULENPAY_IFRAME_EXPECTED_ORIGIN: Optional[str] = None
|
||||||
|
|
||||||
PAL24_ENABLED: bool = False
|
PAL24_ENABLED: bool = False
|
||||||
PAL24_API_TOKEN: Optional[str] = None
|
PAL24_API_TOKEN: Optional[str] = None
|
||||||
@@ -943,6 +945,20 @@ class Settings(BaseSettings):
|
|||||||
def get_mulenpay_display_name_html(self) -> str:
|
def get_mulenpay_display_name_html(self) -> str:
|
||||||
return html.escape(self.get_mulenpay_display_name())
|
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:
|
def is_pal24_enabled(self) -> bool:
|
||||||
return (
|
return (
|
||||||
self.PAL24_ENABLED
|
self.PAL24_ENABLED
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ from typing import Any, Awaitable, Callable, Dict, List, Optional, Tuple, Union
|
|||||||
|
|
||||||
from aiogram import Bot
|
from aiogram import Bot
|
||||||
from fastapi import APIRouter, Depends, HTTPException, status
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
|
from pydantic import ValidationError
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy.orm import selectinload
|
from sqlalchemy.orm import selectinload
|
||||||
@@ -113,6 +114,8 @@ from ..schemas.miniapp import (
|
|||||||
MiniAppLegalDocuments,
|
MiniAppLegalDocuments,
|
||||||
MiniAppPaymentCreateRequest,
|
MiniAppPaymentCreateRequest,
|
||||||
MiniAppPaymentCreateResponse,
|
MiniAppPaymentCreateResponse,
|
||||||
|
MiniAppPaymentIframeConfig,
|
||||||
|
MiniAppPaymentIntegrationType,
|
||||||
MiniAppPaymentMethod,
|
MiniAppPaymentMethod,
|
||||||
MiniAppPaymentMethodsRequest,
|
MiniAppPaymentMethodsRequest,
|
||||||
MiniAppPaymentMethodsResponse,
|
MiniAppPaymentMethodsResponse,
|
||||||
@@ -624,6 +627,18 @@ def _normalize_amount_kopeks(
|
|||||||
return normalized if normalized >= 0 else None
|
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(
|
@router.post(
|
||||||
"/payments/methods",
|
"/payments/methods",
|
||||||
response_model=MiniAppPaymentMethodsResponse,
|
response_model=MiniAppPaymentMethodsResponse,
|
||||||
@@ -646,6 +661,7 @@ async def get_payment_methods(
|
|||||||
currency="RUB",
|
currency="RUB",
|
||||||
min_amount_kopeks=stars_min_amount,
|
min_amount_kopeks=stars_min_amount,
|
||||||
amount_step_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",
|
currency="RUB",
|
||||||
min_amount_kopeks=settings.YOOKASSA_MIN_AMOUNT_KOPEKS,
|
min_amount_kopeks=settings.YOOKASSA_MIN_AMOUNT_KOPEKS,
|
||||||
max_amount_kopeks=settings.YOOKASSA_MAX_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",
|
currency="RUB",
|
||||||
min_amount_kopeks=settings.YOOKASSA_MIN_AMOUNT_KOPEKS,
|
min_amount_kopeks=settings.YOOKASSA_MIN_AMOUNT_KOPEKS,
|
||||||
max_amount_kopeks=settings.YOOKASSA_MAX_AMOUNT_KOPEKS,
|
max_amount_kopeks=settings.YOOKASSA_MAX_AMOUNT_KOPEKS,
|
||||||
|
integration_type=MiniAppPaymentIntegrationType.REDIRECT,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
if settings.is_mulenpay_enabled():
|
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(
|
methods.append(
|
||||||
MiniAppPaymentMethod(
|
MiniAppPaymentMethod(
|
||||||
id="mulenpay",
|
id="mulenpay",
|
||||||
@@ -683,6 +707,8 @@ async def get_payment_methods(
|
|||||||
currency="RUB",
|
currency="RUB",
|
||||||
min_amount_kopeks=settings.MULENPAY_MIN_AMOUNT_KOPEKS,
|
min_amount_kopeks=settings.MULENPAY_MIN_AMOUNT_KOPEKS,
|
||||||
max_amount_kopeks=settings.MULENPAY_MAX_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",
|
currency="RUB",
|
||||||
min_amount_kopeks=settings.PAL24_MIN_AMOUNT_KOPEKS,
|
min_amount_kopeks=settings.PAL24_MIN_AMOUNT_KOPEKS,
|
||||||
max_amount_kopeks=settings.PAL24_MAX_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",
|
currency="RUB",
|
||||||
min_amount_kopeks=settings.WATA_MIN_AMOUNT_KOPEKS,
|
min_amount_kopeks=settings.WATA_MIN_AMOUNT_KOPEKS,
|
||||||
max_amount_kopeks=settings.WATA_MAX_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",
|
currency="RUB",
|
||||||
min_amount_kopeks=min_amount_kopeks,
|
min_amount_kopeks=min_amount_kopeks,
|
||||||
max_amount_kopeks=max_amount_kopeks,
|
max_amount_kopeks=max_amount_kopeks,
|
||||||
|
integration_type=MiniAppPaymentIntegrationType.REDIRECT,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -733,6 +762,7 @@ async def get_payment_methods(
|
|||||||
currency="RUB",
|
currency="RUB",
|
||||||
min_amount_kopeks=100 * 100,
|
min_amount_kopeks=100 * 100,
|
||||||
max_amount_kopeks=100_000 * 100,
|
max_amount_kopeks=100_000 * 100,
|
||||||
|
integration_type=MiniAppPaymentIntegrationType.REDIRECT,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -743,6 +773,7 @@ async def get_payment_methods(
|
|||||||
icon="💎",
|
icon="💎",
|
||||||
requires_amount=False,
|
requires_amount=False,
|
||||||
currency="RUB",
|
currency="RUB",
|
||||||
|
integration_type=MiniAppPaymentIntegrationType.REDIRECT,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
from enum import Enum
|
||||||
from typing import Any, Dict, List, Optional
|
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):
|
class MiniAppBranding(BaseModel):
|
||||||
@@ -357,6 +359,30 @@ class MiniAppPaymentMethodsRequest(BaseModel):
|
|||||||
init_data: str = Field(..., alias="initData")
|
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):
|
class MiniAppPaymentMethod(BaseModel):
|
||||||
id: str
|
id: str
|
||||||
name: Optional[str] = None
|
name: Optional[str] = None
|
||||||
@@ -366,6 +392,17 @@ class MiniAppPaymentMethod(BaseModel):
|
|||||||
min_amount_kopeks: Optional[int] = None
|
min_amount_kopeks: Optional[int] = None
|
||||||
max_amount_kopeks: Optional[int] = None
|
max_amount_kopeks: Optional[int] = None
|
||||||
amount_step_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):
|
class MiniAppPaymentMethodsResponse(BaseModel):
|
||||||
|
|||||||
@@ -151,4 +151,36 @@ Caddy автоматически выпустит сертификаты чер
|
|||||||
| Mini App не открывается в Telegram | URL не соответствует HTTPS или отсутствует сертификат. | Обновите сертификаты и убедитесь, что домен доступен по HTTPS. |
|
| Mini App не открывается в Telegram | URL не соответствует HTTPS или отсутствует сертификат. | Обновите сертификаты и убедитесь, что домен доступен по HTTPS. |
|
||||||
| Нет ссылок подписки | Не настроена интеграция с RemnaWave или у пользователя нет активной подписки. | Проверьте `REMNAWAVE_API_URL/KEY` и статус подписки пользователя. |
|
| Нет ссылок подписки | Не настроена интеграция с 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.database.models import PaymentMethod
|
||||||
from app.webapi.schemas.miniapp import (
|
from app.webapi.schemas.miniapp import (
|
||||||
MiniAppPaymentCreateRequest,
|
MiniAppPaymentCreateRequest,
|
||||||
|
MiniAppPaymentIntegrationType,
|
||||||
MiniAppPaymentMethodsRequest,
|
MiniAppPaymentMethodsRequest,
|
||||||
MiniAppPaymentStatusQuery,
|
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 is not None
|
||||||
assert stars_method.min_amount_kopeks == 99999
|
assert stars_method.min_amount_kopeks == 99999
|
||||||
assert stars_method.amount_step_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")
|
@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.min_amount_kopeks == 5000
|
||||||
assert wata_method.max_amount_kopeks == 7500000
|
assert wata_method.max_amount_kopeks == 7500000
|
||||||
assert wata_method.icon == '🌊'
|
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")
|
@pytest.mark.anyio("asyncio")
|
||||||
async def test_find_recent_deposit_ignores_transactions_before_attempt():
|
async def test_find_recent_deposit_ignores_transactions_before_attempt():
|
||||||
started_at = datetime(2024, 5, 1, 12, 0, 0)
|
started_at = datetime(2024, 5, 1, 12, 0, 0)
|
||||||
|
|||||||
Reference in New Issue
Block a user