refactor: payments providers
This commit is contained in:
@@ -2,19 +2,13 @@ from aiogram import Bot
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from bot.middlewares.i18n import JsonI18n
|
||||
from bot.services.crypto_pay_service import CryptoPayService
|
||||
from bot.services.freekassa_service import FreeKassaService
|
||||
from bot.payment_providers import ServiceFactoryContext, build_provider_services
|
||||
from bot.services.lknpd_service import LknpdService
|
||||
from bot.services.panel_api_service import PanelApiService
|
||||
from bot.services.panel_webhook_service import PanelWebhookService
|
||||
from bot.services.platega_service import PlategaService
|
||||
from bot.services.promo_code_service import PromoCodeService
|
||||
from bot.services.referral_service import ReferralService
|
||||
from bot.services.severpay_service import SeverPayService
|
||||
from bot.services.stars_service import StarsService
|
||||
from bot.services.subscription_service import SubscriptionService
|
||||
from bot.services.wata_service import WataService
|
||||
from bot.services.yookassa_service import YooKassaService
|
||||
from config.settings import Settings
|
||||
|
||||
|
||||
@@ -29,61 +23,19 @@ def build_core_services(
|
||||
subscription_service = SubscriptionService(settings, panel_service, bot, i18n)
|
||||
referral_service = ReferralService(settings, subscription_service, bot, i18n)
|
||||
promo_code_service = PromoCodeService(settings, subscription_service, bot, i18n)
|
||||
stars_service = StarsService(bot, settings, i18n, subscription_service, referral_service)
|
||||
cryptopay_service = CryptoPayService(
|
||||
settings.CRYPTOPAY_TOKEN,
|
||||
settings.CRYPTOPAY_NETWORK,
|
||||
bot,
|
||||
settings,
|
||||
i18n,
|
||||
async_session_factory,
|
||||
subscription_service,
|
||||
referral_service,
|
||||
)
|
||||
freekassa_service = FreeKassaService(
|
||||
bot=bot,
|
||||
settings=settings,
|
||||
i18n=i18n,
|
||||
async_session_factory=async_session_factory,
|
||||
subscription_service=subscription_service,
|
||||
referral_service=referral_service,
|
||||
)
|
||||
platega_service = PlategaService(
|
||||
bot=bot,
|
||||
settings=settings,
|
||||
i18n=i18n,
|
||||
async_session_factory=async_session_factory,
|
||||
subscription_service=subscription_service,
|
||||
referral_service=referral_service,
|
||||
default_return_url=bot_username_for_default_return,
|
||||
)
|
||||
severpay_service = SeverPayService(
|
||||
bot=bot,
|
||||
settings=settings,
|
||||
i18n=i18n,
|
||||
async_session_factory=async_session_factory,
|
||||
subscription_service=subscription_service,
|
||||
referral_service=referral_service,
|
||||
default_return_url=bot_username_for_default_return,
|
||||
)
|
||||
wata_service = WataService(
|
||||
bot=bot,
|
||||
settings=settings,
|
||||
i18n=i18n,
|
||||
async_session_factory=async_session_factory,
|
||||
subscription_service=subscription_service,
|
||||
referral_service=referral_service,
|
||||
default_return_url=bot_username_for_default_return,
|
||||
)
|
||||
panel_webhook_service = PanelWebhookService(
|
||||
bot, settings, i18n, async_session_factory, panel_service
|
||||
)
|
||||
yookassa_service = YooKassaService(
|
||||
shop_id=settings.YOOKASSA_SHOP_ID,
|
||||
secret_key=settings.YOOKASSA_SECRET_KEY,
|
||||
configured_return_url=settings.YOOKASSA_RETURN_URL,
|
||||
bot_username_for_default_return=bot_username_for_default_return,
|
||||
settings_obj=settings,
|
||||
payment_services = build_provider_services(
|
||||
ServiceFactoryContext(
|
||||
settings=settings,
|
||||
bot=bot,
|
||||
async_session_factory=async_session_factory,
|
||||
i18n=i18n,
|
||||
bot_username_for_default_return=bot_username_for_default_return,
|
||||
subscription_service=subscription_service,
|
||||
referral_service=referral_service,
|
||||
)
|
||||
)
|
||||
lknpd_service = LknpdService(
|
||||
settings.LKNPD_INN,
|
||||
@@ -91,25 +43,17 @@ def build_core_services(
|
||||
api_url=settings.LKNPD_API_URL,
|
||||
)
|
||||
|
||||
# Wire services that depend on each other. These attachments are critical
|
||||
# for auto-renew (subscription_service.yookassa_service) and for the panel
|
||||
# webhook handler's 24h pre-expiry renewal trigger; do NOT swallow errors —
|
||||
# silent wiring failures previously caused auto-renew to disappear.
|
||||
subscription_service.yookassa_service = yookassa_service
|
||||
# These attachments are critical for auto-renew and panel pre-expiry hooks.
|
||||
subscription_service.yookassa_service = payment_services.get("yookassa_service")
|
||||
panel_webhook_service.subscription_service = subscription_service
|
||||
|
||||
return {
|
||||
services = {
|
||||
"panel_service": panel_service,
|
||||
"subscription_service": subscription_service,
|
||||
"referral_service": referral_service,
|
||||
"promo_code_service": promo_code_service,
|
||||
"stars_service": stars_service,
|
||||
"cryptopay_service": cryptopay_service,
|
||||
"freekassa_service": freekassa_service,
|
||||
"panel_webhook_service": panel_webhook_service,
|
||||
"yookassa_service": yookassa_service,
|
||||
"lknpd_service": lknpd_service,
|
||||
"platega_service": platega_service,
|
||||
"severpay_service": severpay_service,
|
||||
"wata_service": wata_service,
|
||||
}
|
||||
services.update(payment_services)
|
||||
return services
|
||||
|
||||
@@ -15,7 +15,7 @@ from typing import Any, List, Optional, Tuple
|
||||
@dataclass(frozen=True)
|
||||
class SettingField:
|
||||
key: str
|
||||
type: str # "string" | "int" | "float" | "bool" | "text" | "url" | "color" | "secret"
|
||||
type: str # "string" | "int" | "float" | "bool" | "text" | "url" | "color" | "icon"
|
||||
section: str
|
||||
label: str
|
||||
description: str = ""
|
||||
@@ -30,6 +30,61 @@ class SettingField:
|
||||
i18n_description_key: Optional[str] = None
|
||||
|
||||
|
||||
def _payment_presentation_fields(method_key: str, subsection: str) -> List[SettingField]:
|
||||
prefix = f"PAYMENT_{method_key}"
|
||||
return [
|
||||
SettingField(
|
||||
f"{prefix}_WEBAPP_LABEL_RU",
|
||||
"string",
|
||||
"payments",
|
||||
"WebApp button text (RU)",
|
||||
"Custom Russian text shown in the Web App payment method button.",
|
||||
subsection=subsection,
|
||||
),
|
||||
SettingField(
|
||||
f"{prefix}_WEBAPP_LABEL_EN",
|
||||
"string",
|
||||
"payments",
|
||||
"WebApp button text (EN)",
|
||||
"Custom English text shown in the Web App payment method button.",
|
||||
subsection=subsection,
|
||||
),
|
||||
SettingField(
|
||||
f"{prefix}_WEBAPP_ICON",
|
||||
"icon",
|
||||
"payments",
|
||||
"WebApp button icon",
|
||||
"Lucide icon name rendered inside the Web App payment method button.",
|
||||
subsection=subsection,
|
||||
),
|
||||
SettingField(
|
||||
f"{prefix}_TELEGRAM_LABEL_RU",
|
||||
"string",
|
||||
"payments",
|
||||
"Telegram button text (RU)",
|
||||
"Custom Russian text shown in Telegram bot payment buttons.",
|
||||
subsection=subsection,
|
||||
),
|
||||
SettingField(
|
||||
f"{prefix}_TELEGRAM_LABEL_EN",
|
||||
"string",
|
||||
"payments",
|
||||
"Telegram button text (EN)",
|
||||
"Custom English text shown in Telegram bot payment buttons.",
|
||||
subsection=subsection,
|
||||
),
|
||||
SettingField(
|
||||
f"{prefix}_TELEGRAM_EMOJI",
|
||||
"string",
|
||||
"payments",
|
||||
"Telegram button emoji",
|
||||
"Emoji prepended to the Telegram bot payment button when customized.",
|
||||
subsection=subsection,
|
||||
placeholder="💳",
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
SETTINGS_MANIFEST: List[SettingField] = [
|
||||
# ─── General ────────────────────────────────────────────────────
|
||||
SettingField(
|
||||
@@ -136,6 +191,7 @@ SETTINGS_MANIFEST: List[SettingField] = [
|
||||
# ─── Payment providers (toggles) ───────────────────────────────
|
||||
# Common
|
||||
SettingField("STARS_ENABLED", "bool", "payments", "Telegram Stars", subsection="Общие"),
|
||||
*_payment_presentation_fields("STARS", "Telegram Stars"),
|
||||
SettingField(
|
||||
"PAYMENT_METHODS_ORDER",
|
||||
"string",
|
||||
@@ -187,6 +243,7 @@ SETTINGS_MANIFEST: List[SettingField] = [
|
||||
"Принудительная привязка карты",
|
||||
subsection="YooKassa",
|
||||
),
|
||||
*_payment_presentation_fields("YOOKASSA", "YooKassa"),
|
||||
# FreeKassa
|
||||
SettingField("FREEKASSA_ENABLED", "bool", "payments", "Включена", subsection="FreeKassa"),
|
||||
SettingField(
|
||||
@@ -243,6 +300,7 @@ SETTINGS_MANIFEST: List[SettingField] = [
|
||||
"Через запятую — IP-адреса, с которых принимаются нотификации",
|
||||
subsection="FreeKassa",
|
||||
),
|
||||
*_payment_presentation_fields("FREEKASSA", "FreeKassa"),
|
||||
# Platega
|
||||
SettingField("PLATEGA_ENABLED", "bool", "payments", "Включена", subsection="Platega"),
|
||||
SettingField(
|
||||
@@ -270,6 +328,8 @@ SETTINGS_MANIFEST: List[SettingField] = [
|
||||
),
|
||||
SettingField("PLATEGA_RETURN_URL", "url", "payments", "Return URL", subsection="Platega"),
|
||||
SettingField("PLATEGA_FAILED_URL", "url", "payments", "Failed URL", subsection="Platega"),
|
||||
*_payment_presentation_fields("PLATEGA_SBP", "Platega SBP"),
|
||||
*_payment_presentation_fields("PLATEGA_CRYPTO", "Platega Crypto"),
|
||||
# SeverPay
|
||||
SettingField("SEVERPAY_ENABLED", "bool", "payments", "Включена", subsection="SeverPay"),
|
||||
SettingField("SEVERPAY_MID", "int", "payments", "MID", subsection="SeverPay"),
|
||||
@@ -295,6 +355,7 @@ SETTINGS_MANIFEST: List[SettingField] = [
|
||||
min=30,
|
||||
max=4320,
|
||||
),
|
||||
*_payment_presentation_fields("SEVERPAY", "SeverPay"),
|
||||
# Wata
|
||||
SettingField("WATA_ENABLED", "bool", "payments", "Enabled", subsection="Wata"),
|
||||
SettingField(
|
||||
@@ -349,6 +410,7 @@ SETTINGS_MANIFEST: List[SettingField] = [
|
||||
"Comma-separated IP addresses accepted for Wata webhooks.",
|
||||
subsection="Wata",
|
||||
),
|
||||
*_payment_presentation_fields("WATA", "Wata"),
|
||||
# CryptoPay
|
||||
SettingField("CRYPTOPAY_ENABLED", "bool", "payments", "Включена", subsection="CryptoPay"),
|
||||
SettingField(
|
||||
@@ -373,6 +435,7 @@ SETTINGS_MANIFEST: List[SettingField] = [
|
||||
SettingField(
|
||||
"CRYPTOPAY_ASSET", "string", "payments", "Asset", placeholder="RUB", subsection="CryptoPay"
|
||||
),
|
||||
*_payment_presentation_fields("CRYPTOPAY", "CryptoPay"),
|
||||
# ─── Trial ─────────────────────────────────────────────────────
|
||||
SettingField("TRIAL_ENABLED", "bool", "trial", "Триал включён"),
|
||||
SettingField("TRIAL_DURATION_DAYS", "int", "trial", "Длительность триала (дней)", min=0),
|
||||
|
||||
@@ -7,6 +7,7 @@ from aiogram.webhook.aiohttp_server import SimpleRequestHandler, setup_applicati
|
||||
from aiohttp import web
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from bot.payment_providers import iter_provider_specs, iter_service_keys
|
||||
from config.settings import Settings
|
||||
|
||||
|
||||
@@ -29,20 +30,15 @@ def _inject_shared_instances(
|
||||
app["settings"] = settings
|
||||
app["async_session_factory"] = async_session_factory
|
||||
app["i18n"] = dp.get("i18n_instance")
|
||||
for key in (
|
||||
"yookassa_service",
|
||||
"lknpd_service",
|
||||
shared_keys = [
|
||||
"subscription_service",
|
||||
"referral_service",
|
||||
"panel_service",
|
||||
"stars_service",
|
||||
"freekassa_service",
|
||||
"cryptopay_service",
|
||||
"panel_webhook_service",
|
||||
"platega_service",
|
||||
"severpay_service",
|
||||
"wata_service",
|
||||
):
|
||||
"lknpd_service",
|
||||
*iter_service_keys(),
|
||||
]
|
||||
for key in shared_keys:
|
||||
if hasattr(dp, "workflow_data") and key in dp.workflow_data: # type: ignore
|
||||
app[key] = dp.workflow_data[key] # type: ignore
|
||||
|
||||
@@ -91,44 +87,21 @@ async def build_and_start_web_app(
|
||||
f"Telegram webhook route configured at: [POST] {telegram_webhook_path} (relative to base URL)" # noqa: E501
|
||||
)
|
||||
|
||||
from bot.handlers.user.payment import yookassa_webhook_route
|
||||
from bot.services.crypto_pay_service import cryptopay_webhook_route
|
||||
from bot.services.freekassa_service import freekassa_webhook_route
|
||||
from bot.services.panel_webhook_service import panel_webhook_route
|
||||
from bot.services.platega_service import platega_webhook_route
|
||||
from bot.services.severpay_service import severpay_webhook_route
|
||||
from bot.services.wata_service import wata_webhook_route
|
||||
|
||||
cp_path = settings.cryptopay_webhook_path
|
||||
if cp_path.startswith("/"):
|
||||
app.router.add_post(cp_path, cryptopay_webhook_route)
|
||||
logging.info(f"CryptoPay webhook route configured at: [POST] {cp_path}")
|
||||
|
||||
fk_path = settings.freekassa_webhook_path
|
||||
if fk_path.startswith("/"):
|
||||
app.router.add_post(fk_path, freekassa_webhook_route)
|
||||
logging.info(f"FreeKassa webhook route configured at: [POST] {fk_path}")
|
||||
|
||||
pg_path = settings.platega_webhook_path
|
||||
if pg_path.startswith("/"):
|
||||
app.router.add_post(pg_path, platega_webhook_route)
|
||||
logging.info(f"Platega webhook route configured at: [POST] {pg_path}")
|
||||
|
||||
sp_path = settings.severpay_webhook_path
|
||||
if sp_path.startswith("/"):
|
||||
app.router.add_post(sp_path, severpay_webhook_route)
|
||||
logging.info(f"SeverPay webhook route configured at: [POST] {sp_path}")
|
||||
|
||||
wata_path = settings.wata_webhook_path
|
||||
if wata_path.startswith("/"):
|
||||
app.router.add_post(wata_path, wata_webhook_route)
|
||||
logging.info(f"Wata webhook route configured at: [POST] {wata_path}")
|
||||
|
||||
# YooKassa webhook (register only when base URL present and path configured)
|
||||
yk_path = settings.yookassa_webhook_path
|
||||
if settings.WEBHOOK_BASE_URL and yk_path and yk_path.startswith("/"):
|
||||
app.router.add_post(yk_path, yookassa_webhook_route)
|
||||
logging.info(f"YooKassa webhook route configured at: [POST] {yk_path}")
|
||||
registered_webhook_paths: set[str] = set()
|
||||
for spec in iter_provider_specs():
|
||||
webhook_route = spec.load_webhook_route()
|
||||
if not spec.webhook_path or not webhook_route:
|
||||
continue
|
||||
if spec.webhook_requires_base_url and not settings.WEBHOOK_BASE_URL:
|
||||
continue
|
||||
path = spec.webhook_path(settings)
|
||||
if not path or not path.startswith("/") or path in registered_webhook_paths:
|
||||
continue
|
||||
registered_webhook_paths.add(path)
|
||||
app.router.add_post(path, webhook_route)
|
||||
logging.info("%s webhook route configured at: [POST] %s", spec.label, path)
|
||||
|
||||
panel_path = settings.panel_webhook_path
|
||||
if panel_path.startswith("/"):
|
||||
|
||||
@@ -21,7 +21,6 @@ from typing import Any, Dict, List, Optional, Tuple
|
||||
from urllib.parse import parse_qsl, quote, urlencode, urlsplit, urlunsplit
|
||||
|
||||
from aiogram import Bot, Dispatcher
|
||||
from aiogram.types import LabeledPrice
|
||||
from aiohttp import ClientSession, ClientTimeout, web
|
||||
from pydantic import BaseModel, ConfigDict, EmailStr, ValidationError, constr, field_validator
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
@@ -43,17 +42,11 @@ from bot.app.web.webapp_auth import (
|
||||
verify_webapp_session_token,
|
||||
)
|
||||
from bot.infra.redis import cache_get_json, cache_set_json, get_redis, redis_key
|
||||
from bot.services.crypto_pay_service import CryptoPayService
|
||||
from bot.services.email_auth_service import EmailAuthService, normalize_email
|
||||
from bot.services.email_templates import render_account_merged
|
||||
from bot.services.freekassa_service import FreeKassaService
|
||||
from bot.services.platega_service import PlategaService
|
||||
from bot.services.promo_code_service import PromoCodeService
|
||||
from bot.services.referral_service import ReferralService
|
||||
from bot.services.severpay_service import SeverPayService
|
||||
from bot.services.subscription_service import SubscriptionService
|
||||
from bot.services.wata_service import WataService
|
||||
from bot.services.yookassa_service import YooKassaService
|
||||
from bot.utils.config_link import prepare_config_links
|
||||
from bot.utils.request_security import parse_ip_entries, request_client_ip
|
||||
from bot.utils.text_sanitizer import sanitize_display_name, sanitize_username
|
||||
|
||||
@@ -38,17 +38,14 @@ def create_subscription_webapp_application(
|
||||
app.on_startup.append(_startup)
|
||||
app.on_shutdown.append(_shutdown)
|
||||
|
||||
from bot.payment_providers import iter_service_keys
|
||||
|
||||
for key in (
|
||||
"subscription_service",
|
||||
"yookassa_service",
|
||||
"freekassa_service",
|
||||
"cryptopay_service",
|
||||
"platega_service",
|
||||
"severpay_service",
|
||||
"wata_service",
|
||||
"promo_code_service",
|
||||
"referral_service",
|
||||
"panel_service",
|
||||
*iter_service_keys(),
|
||||
):
|
||||
if hasattr(dp, "workflow_data") and key in dp.workflow_data: # type: ignore[attr-defined]
|
||||
app[key] = dp.workflow_data[key] # type: ignore[index]
|
||||
|
||||
@@ -651,625 +651,27 @@ async def _create_subscription_payment(
|
||||
else _payment_description(int(months), lang)
|
||||
)
|
||||
|
||||
if method == "yookassa":
|
||||
if not settings.YOOKASSA_ENABLED:
|
||||
from bot.payment_providers import WebAppPaymentContext, get_provider_spec
|
||||
|
||||
provider_spec = get_provider_spec(method)
|
||||
if provider_spec and provider_spec.create_webapp_payment:
|
||||
if not provider_spec.is_enabled(settings):
|
||||
return _json_error(400, "payment_unavailable", "Payment method unavailable")
|
||||
return await _create_yookassa_payment(
|
||||
request,
|
||||
session,
|
||||
user_id,
|
||||
months,
|
||||
price,
|
||||
description,
|
||||
sale_mode=sale_mode,
|
||||
traffic_gb=traffic_gb,
|
||||
)
|
||||
if method == "freekassa":
|
||||
if not settings.FREEKASSA_ENABLED:
|
||||
return _json_error(400, "payment_unavailable", "Payment method unavailable")
|
||||
return await _create_freekassa_payment(
|
||||
request,
|
||||
session,
|
||||
user_id,
|
||||
months,
|
||||
price,
|
||||
description,
|
||||
sale_mode=sale_mode,
|
||||
traffic_gb=traffic_gb,
|
||||
)
|
||||
if method in ("platega", "platega_sbp", "platega_crypto"):
|
||||
if not settings.PLATEGA_ENABLED:
|
||||
return _json_error(400, "payment_unavailable", "Payment method unavailable")
|
||||
if method == "platega_sbp" and not settings.PLATEGA_SBP_ENABLED:
|
||||
return _json_error(400, "payment_unavailable", "Payment method unavailable")
|
||||
if method == "platega_crypto" and not settings.PLATEGA_CRYPTO_ENABLED:
|
||||
return _json_error(400, "payment_unavailable", "Payment method unavailable")
|
||||
return await _create_platega_payment(
|
||||
request,
|
||||
session,
|
||||
user_id,
|
||||
months,
|
||||
price,
|
||||
description,
|
||||
variant=method,
|
||||
sale_mode=sale_mode,
|
||||
traffic_gb=traffic_gb,
|
||||
)
|
||||
if method == "severpay":
|
||||
if not settings.SEVERPAY_ENABLED:
|
||||
return _json_error(400, "payment_unavailable", "Payment method unavailable")
|
||||
return await _create_severpay_payment(
|
||||
request,
|
||||
session,
|
||||
user_id,
|
||||
months,
|
||||
price,
|
||||
description,
|
||||
sale_mode=sale_mode,
|
||||
traffic_gb=traffic_gb,
|
||||
)
|
||||
if method == "wata":
|
||||
if not settings.WATA_ENABLED:
|
||||
return _json_error(400, "payment_unavailable", "Payment method unavailable")
|
||||
return await _create_wata_payment(
|
||||
request,
|
||||
session,
|
||||
user_id,
|
||||
months,
|
||||
price,
|
||||
description,
|
||||
sale_mode=sale_mode,
|
||||
traffic_gb=traffic_gb,
|
||||
)
|
||||
if method == "cryptopay":
|
||||
service: CryptoPayService = request.app["cryptopay_service"]
|
||||
if not settings.CRYPTOPAY_ENABLED or not service or not service.configured:
|
||||
return _json_error(400, "payment_unavailable", "Payment method unavailable")
|
||||
url = await service.create_invoice(
|
||||
session=session,
|
||||
user_id=user_id,
|
||||
months=months,
|
||||
amount=price,
|
||||
description=description,
|
||||
sale_mode=sale_mode,
|
||||
url_kind="web",
|
||||
)
|
||||
if not url:
|
||||
return _json_error(502, "payment_failed", "Failed to create payment")
|
||||
return web.json_response(
|
||||
{"ok": True, "action": "open_link", "payment_url": url, "payment_id": None}
|
||||
)
|
||||
if method == "stars":
|
||||
if not settings.STARS_ENABLED or stars_price is None:
|
||||
return _json_error(400, "payment_unavailable", "Payment method unavailable")
|
||||
return await _create_stars_payment(
|
||||
request,
|
||||
session,
|
||||
user_id,
|
||||
months,
|
||||
int(stars_price),
|
||||
description,
|
||||
sale_mode=sale_mode,
|
||||
traffic_gb=traffic_gb,
|
||||
return await provider_spec.create_webapp_payment(
|
||||
WebAppPaymentContext(
|
||||
request=request,
|
||||
session=session,
|
||||
user_id=user_id,
|
||||
method=method,
|
||||
months=months,
|
||||
price=price,
|
||||
stars_price=stars_price,
|
||||
description=description,
|
||||
sale_mode=sale_mode,
|
||||
traffic_gb=traffic_gb,
|
||||
)
|
||||
)
|
||||
|
||||
return _json_error(400, "payment_unavailable", "Payment method unavailable")
|
||||
|
||||
|
||||
async def _create_base_payment_record(
|
||||
session: AsyncSession,
|
||||
*,
|
||||
user_id: int,
|
||||
amount: float,
|
||||
currency: str,
|
||||
status: str,
|
||||
description: str,
|
||||
months: int,
|
||||
provider: str,
|
||||
sale_mode: Optional[str] = None,
|
||||
tariff_key: Optional[str] = None,
|
||||
purchased_gb: Optional[float] = None,
|
||||
purchased_hwid_devices: Optional[int] = None,
|
||||
) -> Payment:
|
||||
payment = await payment_dal.create_payment_record(
|
||||
session,
|
||||
{
|
||||
"user_id": user_id,
|
||||
"amount": amount,
|
||||
"currency": currency,
|
||||
"status": status,
|
||||
"description": description,
|
||||
"subscription_duration_months": months,
|
||||
"provider": provider,
|
||||
"sale_mode": sale_mode,
|
||||
"tariff_key": tariff_key,
|
||||
"purchased_gb": purchased_gb,
|
||||
"purchased_hwid_devices": purchased_hwid_devices,
|
||||
},
|
||||
)
|
||||
await session.commit()
|
||||
return payment
|
||||
|
||||
|
||||
async def _create_yookassa_payment(
|
||||
request: web.Request,
|
||||
session: AsyncSession,
|
||||
user_id: int,
|
||||
months: Any,
|
||||
price: float,
|
||||
description: str,
|
||||
*,
|
||||
sale_mode: str = "subscription",
|
||||
traffic_gb: Optional[float] = None,
|
||||
) -> web.Response:
|
||||
settings: Settings = request.app["settings"]
|
||||
service: YooKassaService = request.app["yookassa_service"]
|
||||
if not service or not service.configured:
|
||||
return _json_error(400, "payment_unavailable", "Payment method unavailable")
|
||||
|
||||
try:
|
||||
traffic_sale = _sale_mode_is_traffic(sale_mode)
|
||||
hwid_devices_sale = _sale_mode_is_hwid_devices(sale_mode)
|
||||
payment = await _create_base_payment_record(
|
||||
session,
|
||||
user_id=user_id,
|
||||
amount=price,
|
||||
currency="RUB",
|
||||
status="pending_yookassa",
|
||||
description=description,
|
||||
months=int(float(months)) if not traffic_sale else int(float(traffic_gb or months)),
|
||||
provider="yookassa",
|
||||
sale_mode=sale_mode,
|
||||
tariff_key=_sale_mode_tariff_key(sale_mode),
|
||||
purchased_gb=float(traffic_gb or months) if traffic_sale else None,
|
||||
purchased_hwid_devices=int(float(months)) if hwid_devices_sale else None,
|
||||
)
|
||||
metadata = {
|
||||
"user_id": str(user_id),
|
||||
"subscription_months": str(
|
||||
int(float(months)) if not traffic_sale and not hwid_devices_sale else 0
|
||||
),
|
||||
"payment_db_id": str(payment.payment_id),
|
||||
"sale_mode": sale_mode,
|
||||
"source": "webapp",
|
||||
}
|
||||
if traffic_sale:
|
||||
metadata["traffic_gb"] = _format_number_for_payload(traffic_gb or months)
|
||||
if hwid_devices_sale:
|
||||
metadata["hwid_devices"] = str(int(float(months)))
|
||||
if _sale_mode_tariff_key(sale_mode):
|
||||
metadata["tariff_key"] = _sale_mode_tariff_key(sale_mode)
|
||||
response = await service.create_payment(
|
||||
amount=price,
|
||||
currency="RUB",
|
||||
description=description,
|
||||
metadata=metadata,
|
||||
receipt_email=settings.YOOKASSA_DEFAULT_RECEIPT_EMAIL,
|
||||
save_payment_method=bool(
|
||||
settings.yookassa_autopayments_active
|
||||
and settings.YOOKASSA_AUTOPAYMENTS_REQUIRE_CARD_BINDING
|
||||
),
|
||||
)
|
||||
payment_url = response.get("confirmation_url") if response else None
|
||||
if not payment_url:
|
||||
await payment_dal.update_payment_status_by_db_id(
|
||||
session, payment.payment_id, "failed_creation"
|
||||
)
|
||||
await session.commit()
|
||||
return _json_error(502, "payment_failed", "Failed to create payment")
|
||||
|
||||
await payment_dal.update_payment_status_by_db_id(
|
||||
session,
|
||||
payment.payment_id,
|
||||
response.get("status", "pending"),
|
||||
yk_payment_id=response.get("id"),
|
||||
)
|
||||
await session.commit()
|
||||
return web.json_response(
|
||||
{
|
||||
"ok": True,
|
||||
"action": "open_link",
|
||||
"payment_url": payment_url,
|
||||
"payment_id": payment.payment_id,
|
||||
}
|
||||
)
|
||||
except Exception:
|
||||
await session.rollback()
|
||||
logger.exception("YooKassa WebApp payment failed")
|
||||
return _json_error(502, "payment_failed", "Failed to create payment")
|
||||
|
||||
|
||||
async def _create_freekassa_payment(
|
||||
request: web.Request,
|
||||
session: AsyncSession,
|
||||
user_id: int,
|
||||
months: Any,
|
||||
price: float,
|
||||
description: str,
|
||||
*,
|
||||
sale_mode: str = "subscription",
|
||||
traffic_gb: Optional[float] = None,
|
||||
) -> web.Response:
|
||||
request.app["settings"]
|
||||
service: FreeKassaService = request.app["freekassa_service"]
|
||||
if not service or not service.configured or not service.payment_method_id:
|
||||
return _json_error(400, "payment_unavailable", "Payment method unavailable")
|
||||
|
||||
try:
|
||||
traffic_sale = _sale_mode_is_traffic(sale_mode)
|
||||
hwid_devices_sale = _sale_mode_is_hwid_devices(sale_mode)
|
||||
payment = await _create_base_payment_record(
|
||||
session,
|
||||
user_id=user_id,
|
||||
amount=price,
|
||||
currency=service.default_currency,
|
||||
status="pending_freekassa",
|
||||
description=description,
|
||||
months=int(float(months)) if not traffic_sale else int(float(traffic_gb or months)),
|
||||
provider="freekassa",
|
||||
sale_mode=sale_mode,
|
||||
tariff_key=_sale_mode_tariff_key(sale_mode),
|
||||
purchased_gb=float(traffic_gb or months) if traffic_sale else None,
|
||||
purchased_hwid_devices=int(float(months)) if hwid_devices_sale else None,
|
||||
)
|
||||
success, response_data = await service.create_order(
|
||||
payment_db_id=payment.payment_id,
|
||||
user_id=user_id,
|
||||
months=months,
|
||||
amount=price,
|
||||
currency=service.default_currency,
|
||||
payment_method_id=service.payment_method_id,
|
||||
ip_address=service.server_ip,
|
||||
extra_params={"us_method": service.payment_method_id},
|
||||
)
|
||||
payment_url = response_data.get("location") if success else None
|
||||
provider_id = response_data.get("orderHash") or response_data.get("orderId")
|
||||
if provider_id:
|
||||
await payment_dal.update_provider_payment_and_status(
|
||||
session, payment.payment_id, str(provider_id), payment.status
|
||||
)
|
||||
await session.commit()
|
||||
if not payment_url:
|
||||
await payment_dal.update_payment_status_by_db_id(
|
||||
session, payment.payment_id, "failed_creation"
|
||||
)
|
||||
await session.commit()
|
||||
return _json_error(502, "payment_failed", "Failed to create payment")
|
||||
return web.json_response(
|
||||
{
|
||||
"ok": True,
|
||||
"action": "open_link",
|
||||
"payment_url": payment_url,
|
||||
"payment_id": payment.payment_id,
|
||||
}
|
||||
)
|
||||
except Exception:
|
||||
await session.rollback()
|
||||
logger.exception("FreeKassa WebApp payment failed")
|
||||
return _json_error(502, "payment_failed", "Failed to create payment")
|
||||
|
||||
|
||||
async def _create_platega_payment(
|
||||
request: web.Request,
|
||||
session: AsyncSession,
|
||||
user_id: int,
|
||||
months: Any,
|
||||
price: float,
|
||||
description: str,
|
||||
variant: str = "platega_sbp",
|
||||
sale_mode: str = "subscription",
|
||||
traffic_gb: Optional[float] = None,
|
||||
) -> web.Response:
|
||||
settings: Settings = request.app["settings"]
|
||||
service: PlategaService = request.app["platega_service"]
|
||||
if not service or not service.configured:
|
||||
return _json_error(400, "payment_unavailable", "Payment method unavailable")
|
||||
if variant == "platega_crypto":
|
||||
if not settings.PLATEGA_CRYPTO_ENABLED:
|
||||
return _json_error(400, "payment_unavailable", "Payment method unavailable")
|
||||
platega_method_id = settings.PLATEGA_CRYPTO_METHOD
|
||||
else:
|
||||
if variant == "platega_sbp" and not settings.PLATEGA_SBP_ENABLED:
|
||||
return _json_error(400, "payment_unavailable", "Payment method unavailable")
|
||||
platega_method_id = settings.platega_sbp_method_resolved
|
||||
|
||||
try:
|
||||
traffic_sale = _sale_mode_is_traffic(sale_mode)
|
||||
hwid_devices_sale = _sale_mode_is_hwid_devices(sale_mode)
|
||||
payment = await _create_base_payment_record(
|
||||
session,
|
||||
user_id=user_id,
|
||||
amount=price,
|
||||
currency=settings.DEFAULT_CURRENCY_SYMBOL or "RUB",
|
||||
status="pending_platega",
|
||||
description=description,
|
||||
months=int(float(months)) if not traffic_sale else int(float(traffic_gb or months)),
|
||||
provider="platega",
|
||||
sale_mode=sale_mode,
|
||||
tariff_key=_sale_mode_tariff_key(sale_mode),
|
||||
purchased_gb=float(traffic_gb or months) if traffic_sale else None,
|
||||
purchased_hwid_devices=int(float(months)) if hwid_devices_sale else None,
|
||||
)
|
||||
months_for_provider = (
|
||||
int(float(months)) if not traffic_sale else int(float(traffic_gb or months))
|
||||
)
|
||||
payload = json.dumps(
|
||||
{
|
||||
"payment_db_id": payment.payment_id,
|
||||
"user_id": user_id,
|
||||
"months": months_for_provider if not traffic_sale else 0,
|
||||
"sale_mode": sale_mode,
|
||||
"traffic_gb": _format_number_for_payload(traffic_gb or months)
|
||||
if traffic_sale
|
||||
else None,
|
||||
"hwid_devices": int(float(months)) if hwid_devices_sale else None,
|
||||
"source": "webapp",
|
||||
"platega_variant": "crypto" if variant == "platega_crypto" else "sbp",
|
||||
}
|
||||
)
|
||||
success, response_data = await service.create_transaction(
|
||||
payment_db_id=payment.payment_id,
|
||||
user_id=user_id,
|
||||
months=months_for_provider,
|
||||
amount=price,
|
||||
currency=settings.DEFAULT_CURRENCY_SYMBOL or "RUB",
|
||||
description=description,
|
||||
payload=payload,
|
||||
payment_method=platega_method_id,
|
||||
)
|
||||
payment_url = (
|
||||
(
|
||||
response_data.get("redirect")
|
||||
or response_data.get("url")
|
||||
or response_data.get("paymentUrl")
|
||||
)
|
||||
if success
|
||||
else None
|
||||
)
|
||||
provider_id = response_data.get("transactionId") or response_data.get("id")
|
||||
if provider_id:
|
||||
await payment_dal.update_provider_payment_and_status(
|
||||
session,
|
||||
payment.payment_id,
|
||||
str(provider_id),
|
||||
str(response_data.get("status", payment.status)),
|
||||
)
|
||||
await session.commit()
|
||||
if not payment_url:
|
||||
await payment_dal.update_payment_status_by_db_id(
|
||||
session, payment.payment_id, "failed_creation"
|
||||
)
|
||||
await session.commit()
|
||||
return _json_error(502, "payment_failed", "Failed to create payment")
|
||||
return web.json_response(
|
||||
{
|
||||
"ok": True,
|
||||
"action": "open_link",
|
||||
"payment_url": payment_url,
|
||||
"payment_id": payment.payment_id,
|
||||
}
|
||||
)
|
||||
except Exception:
|
||||
await session.rollback()
|
||||
logger.exception("Platega WebApp payment failed")
|
||||
return _json_error(502, "payment_failed", "Failed to create payment")
|
||||
|
||||
|
||||
async def _create_severpay_payment(
|
||||
request: web.Request,
|
||||
session: AsyncSession,
|
||||
user_id: int,
|
||||
months: Any,
|
||||
price: float,
|
||||
description: str,
|
||||
*,
|
||||
sale_mode: str = "subscription",
|
||||
traffic_gb: Optional[float] = None,
|
||||
) -> web.Response:
|
||||
settings: Settings = request.app["settings"]
|
||||
service: SeverPayService = request.app["severpay_service"]
|
||||
if not service or not service.configured:
|
||||
return _json_error(400, "payment_unavailable", "Payment method unavailable")
|
||||
|
||||
try:
|
||||
traffic_sale = _sale_mode_is_traffic(sale_mode)
|
||||
hwid_devices_sale = _sale_mode_is_hwid_devices(sale_mode)
|
||||
payment = await _create_base_payment_record(
|
||||
session,
|
||||
user_id=user_id,
|
||||
amount=price,
|
||||
currency=settings.DEFAULT_CURRENCY_SYMBOL or "RUB",
|
||||
status="pending_severpay",
|
||||
description=description,
|
||||
months=int(float(months)) if not traffic_sale else int(float(traffic_gb or months)),
|
||||
provider="severpay",
|
||||
sale_mode=sale_mode,
|
||||
tariff_key=_sale_mode_tariff_key(sale_mode),
|
||||
purchased_gb=float(traffic_gb or months) if traffic_sale else None,
|
||||
purchased_hwid_devices=int(float(months)) if hwid_devices_sale else None,
|
||||
)
|
||||
success, response_data = await service.create_payment(
|
||||
payment_db_id=payment.payment_id,
|
||||
user_id=user_id,
|
||||
months=months,
|
||||
amount=price,
|
||||
currency=settings.DEFAULT_CURRENCY_SYMBOL or "RUB",
|
||||
description=description,
|
||||
)
|
||||
payment_url = (
|
||||
(
|
||||
response_data.get("url")
|
||||
or response_data.get("payment_url")
|
||||
or response_data.get("paymentUrl")
|
||||
)
|
||||
if success
|
||||
else None
|
||||
)
|
||||
provider_id = response_data.get("id") or response_data.get("uid")
|
||||
if provider_id:
|
||||
await payment_dal.update_provider_payment_and_status(
|
||||
session, payment.payment_id, str(provider_id), payment.status
|
||||
)
|
||||
await session.commit()
|
||||
if not payment_url:
|
||||
await payment_dal.update_payment_status_by_db_id(
|
||||
session, payment.payment_id, "failed_creation"
|
||||
)
|
||||
await session.commit()
|
||||
return _json_error(502, "payment_failed", "Failed to create payment")
|
||||
return web.json_response(
|
||||
{
|
||||
"ok": True,
|
||||
"action": "open_link",
|
||||
"payment_url": payment_url,
|
||||
"payment_id": payment.payment_id,
|
||||
}
|
||||
)
|
||||
except Exception:
|
||||
await session.rollback()
|
||||
logger.exception("SeverPay WebApp payment failed")
|
||||
return _json_error(502, "payment_failed", "Failed to create payment")
|
||||
|
||||
|
||||
async def _create_wata_payment(
|
||||
request: web.Request,
|
||||
session: AsyncSession,
|
||||
user_id: int,
|
||||
months: Any,
|
||||
price: float,
|
||||
description: str,
|
||||
*,
|
||||
sale_mode: str = "subscription",
|
||||
traffic_gb: Optional[float] = None,
|
||||
) -> web.Response:
|
||||
settings: Settings = request.app["settings"]
|
||||
service: WataService = request.app["wata_service"]
|
||||
if not service or not service.configured:
|
||||
return _json_error(400, "payment_unavailable", "Payment method unavailable")
|
||||
|
||||
try:
|
||||
traffic_sale = _sale_mode_is_traffic(sale_mode)
|
||||
hwid_devices_sale = _sale_mode_is_hwid_devices(sale_mode)
|
||||
payment = await _create_base_payment_record(
|
||||
session,
|
||||
user_id=user_id,
|
||||
amount=price,
|
||||
currency=settings.DEFAULT_CURRENCY_SYMBOL or "RUB",
|
||||
status="pending_wata",
|
||||
description=description,
|
||||
months=int(float(months)) if not traffic_sale else int(float(traffic_gb or months)),
|
||||
provider="wata",
|
||||
sale_mode=sale_mode,
|
||||
tariff_key=_sale_mode_tariff_key(sale_mode),
|
||||
purchased_gb=float(traffic_gb or months) if traffic_sale else None,
|
||||
purchased_hwid_devices=int(float(months)) if hwid_devices_sale else None,
|
||||
)
|
||||
success, response_data = await service.create_payment_link(
|
||||
payment_db_id=payment.payment_id,
|
||||
amount=price,
|
||||
currency=settings.DEFAULT_CURRENCY_SYMBOL or "RUB",
|
||||
description=description,
|
||||
)
|
||||
payment_url = response_data.get("url") if success else None
|
||||
provider_id = response_data.get("id")
|
||||
if provider_id:
|
||||
await payment_dal.update_provider_payment_and_status(
|
||||
session,
|
||||
payment.payment_id,
|
||||
str(provider_id),
|
||||
payment.status,
|
||||
)
|
||||
await session.commit()
|
||||
if not payment_url:
|
||||
await payment_dal.update_payment_status_by_db_id(
|
||||
session,
|
||||
payment.payment_id,
|
||||
"failed_creation",
|
||||
)
|
||||
await session.commit()
|
||||
return _json_error(502, "payment_failed", "Failed to create payment")
|
||||
return web.json_response(
|
||||
{
|
||||
"ok": True,
|
||||
"action": "open_link",
|
||||
"payment_url": payment_url,
|
||||
"payment_id": payment.payment_id,
|
||||
}
|
||||
)
|
||||
except Exception:
|
||||
await session.rollback()
|
||||
logger.exception("Wata WebApp payment failed")
|
||||
return _json_error(502, "payment_failed", "Failed to create payment")
|
||||
|
||||
|
||||
async def _create_stars_payment(
|
||||
request: web.Request,
|
||||
session: AsyncSession,
|
||||
user_id: int,
|
||||
months: Any,
|
||||
stars_price: int,
|
||||
description: str,
|
||||
sale_mode: str = "subscription",
|
||||
traffic_gb: Optional[float] = None,
|
||||
) -> web.Response:
|
||||
bot: Bot = request.app["bot"]
|
||||
try:
|
||||
traffic_sale = _sale_mode_is_traffic(sale_mode)
|
||||
hwid_devices_sale = _sale_mode_is_hwid_devices(sale_mode)
|
||||
payment = await _create_base_payment_record(
|
||||
session,
|
||||
user_id=user_id,
|
||||
amount=float(stars_price),
|
||||
currency="XTR",
|
||||
status="pending_stars",
|
||||
description=description,
|
||||
months=int(float(months)) if not traffic_sale else int(float(traffic_gb or months)),
|
||||
provider="telegram_stars",
|
||||
sale_mode=sale_mode,
|
||||
tariff_key=_sale_mode_tariff_key(sale_mode),
|
||||
purchased_gb=float(traffic_gb or months) if traffic_sale else None,
|
||||
purchased_hwid_devices=int(float(months)) if hwid_devices_sale else None,
|
||||
)
|
||||
payload_units = traffic_gb if traffic_sale and traffic_gb is not None else months
|
||||
payload = f"{payment.payment_id}:{_format_number_for_payload(payload_units)}:{sale_mode}"
|
||||
prices = [LabeledPrice(label=description, amount=stars_price)]
|
||||
create_invoice_link = getattr(bot, "create_invoice_link", None)
|
||||
if callable(create_invoice_link):
|
||||
invoice_url = await create_invoice_link(
|
||||
title=description,
|
||||
description=description,
|
||||
payload=payload,
|
||||
# Required to be empty for Telegram Stars (XTR) per Telegram Bot API.
|
||||
provider_token="",
|
||||
currency="XTR",
|
||||
prices=prices,
|
||||
)
|
||||
return web.json_response(
|
||||
{
|
||||
"ok": True,
|
||||
"action": "open_invoice",
|
||||
"payment_url": invoice_url,
|
||||
"payment_id": payment.payment_id,
|
||||
}
|
||||
)
|
||||
|
||||
await bot.send_invoice(
|
||||
chat_id=user_id,
|
||||
title=description,
|
||||
description=description,
|
||||
payload=payload,
|
||||
provider_token="",
|
||||
currency="XTR",
|
||||
prices=prices,
|
||||
)
|
||||
return web.json_response(
|
||||
{
|
||||
"ok": True,
|
||||
"action": "invoice_sent",
|
||||
"payment_id": payment.payment_id,
|
||||
}
|
||||
)
|
||||
except Exception:
|
||||
await session.rollback()
|
||||
logger.exception("Stars WebApp payment failed")
|
||||
return _json_error(502, "payment_failed", "Failed to create invoice")
|
||||
|
||||
@@ -97,7 +97,7 @@ async def _build_user_payload(request: web.Request, user_id: int) -> Dict[str, A
|
||||
traffic_packages=cached["traffic_packages"],
|
||||
stars_traffic_packages=cached["stars_traffic_packages"],
|
||||
),
|
||||
"payment_methods": _serialize_payment_methods(settings, request.app),
|
||||
"payment_methods": _serialize_payment_methods(settings, request.app, lang),
|
||||
"themes_catalog": public_themes_catalog_payload(
|
||||
settings.webapp_themes_catalog,
|
||||
settings.WEBAPP_PRIMARY_COLOR or "#00fe7a",
|
||||
@@ -566,66 +566,23 @@ def _serialize_tariff_change_target(
|
||||
def _serialize_payment_methods(
|
||||
settings: Settings,
|
||||
app: web.Application,
|
||||
lang: str = "ru",
|
||||
) -> List[Dict[str, Any]]:
|
||||
labels = {
|
||||
"wata": "Wata",
|
||||
"severpay": "SeverPay",
|
||||
"freekassa": "FreeKassa / СБП",
|
||||
"platega_sbp": "Platega · СБП",
|
||||
"platega_crypto": "Platega · Crypto",
|
||||
"yookassa": "Банковская карта",
|
||||
"stars": "Telegram Stars",
|
||||
"cryptopay": "CryptoPay",
|
||||
}
|
||||
from bot.payment_providers import get_provider_spec, resolve_provider_presentation
|
||||
|
||||
methods: List[Dict[str, Any]] = []
|
||||
for method in settings.payment_methods_order:
|
||||
method = method.lower()
|
||||
if (
|
||||
method == "severpay"
|
||||
and settings.SEVERPAY_ENABLED
|
||||
and _service_configured(app, "severpay_service")
|
||||
):
|
||||
methods.append({"id": method, "name": labels[method]})
|
||||
elif (
|
||||
method == "freekassa"
|
||||
and settings.FREEKASSA_ENABLED
|
||||
and _service_configured(app, "freekassa_service")
|
||||
):
|
||||
methods.append({"id": method, "name": labels[method]})
|
||||
elif (
|
||||
method == "platega_sbp"
|
||||
and settings.PLATEGA_ENABLED
|
||||
and settings.PLATEGA_SBP_ENABLED
|
||||
and _service_configured(app, "platega_service")
|
||||
):
|
||||
methods.append({"id": method, "name": labels[method]})
|
||||
elif (
|
||||
method == "platega_crypto"
|
||||
and settings.PLATEGA_ENABLED
|
||||
and settings.PLATEGA_CRYPTO_ENABLED
|
||||
and _service_configured(app, "platega_service")
|
||||
):
|
||||
methods.append({"id": method, "name": labels[method]})
|
||||
elif (
|
||||
method == "wata"
|
||||
and settings.WATA_ENABLED
|
||||
and _service_configured(app, "wata_service")
|
||||
):
|
||||
methods.append({"id": method, "name": labels[method]})
|
||||
elif (
|
||||
method == "yookassa"
|
||||
and settings.YOOKASSA_ENABLED
|
||||
and _service_configured(app, "yookassa_service")
|
||||
):
|
||||
methods.append({"id": method, "name": labels[method]})
|
||||
elif method == "stars" and settings.STARS_ENABLED:
|
||||
methods.append({"id": method, "name": labels[method]})
|
||||
elif (
|
||||
method == "cryptopay"
|
||||
and settings.CRYPTOPAY_ENABLED
|
||||
and _service_configured(app, "cryptopay_service")
|
||||
):
|
||||
methods.append({"id": method, "name": labels[method]})
|
||||
spec = get_provider_spec(method)
|
||||
if spec and spec.is_visible(settings, app):
|
||||
presentation = resolve_provider_presentation(spec, settings, language=lang)
|
||||
methods.append(
|
||||
{
|
||||
"id": method,
|
||||
"name": presentation.webapp_label,
|
||||
"icon": presentation.webapp_icon,
|
||||
}
|
||||
)
|
||||
return methods
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user