From 15b3f9c084b6d9266882b0d3ce39b8cbc873462f Mon Sep 17 00:00:00 2001 From: 3252a8 <3252a8@proton.me> Date: Mon, 18 May 2026 16:13:58 +0300 Subject: [PATCH] refactor: move severpay env-config into module --- .../bot/app/web/admin_settings_manifest.py | 26 ---- backend/bot/payment_providers/severpay.py | 129 ++++++++++++++++-- backend/config/settings.py | 51 +------ 3 files changed, 119 insertions(+), 87 deletions(-) diff --git a/backend/bot/app/web/admin_settings_manifest.py b/backend/bot/app/web/admin_settings_manifest.py index 670a149..5eadf24 100644 --- a/backend/bot/app/web/admin_settings_manifest.py +++ b/backend/bot/app/web/admin_settings_manifest.py @@ -336,32 +336,6 @@ SETTINGS_MANIFEST: List[SettingField] = [ SettingField("PLATEGA_FAILED_URL", "url", "payments", "Failed URL", subsection="Platega"), *_payment_presentation_fields("PLATEGA_SBP", "Platega SBP", default_icon="CreditCard"), *_payment_presentation_fields("PLATEGA_CRYPTO", "Platega Crypto", default_icon="Bitcoin"), - # SeverPay - SettingField("SEVERPAY_ENABLED", "bool", "payments", "Включена", subsection="SeverPay"), - SettingField("SEVERPAY_MID", "int", "payments", "MID", subsection="SeverPay"), - SettingField( - "SEVERPAY_TOKEN", "string", "payments", "Token", subsection="SeverPay", secret=True - ), - SettingField( - "SEVERPAY_BASE_URL", - "url", - "payments", - "Base URL", - placeholder="https://severpay.io/api/merchant", - subsection="SeverPay", - ), - SettingField("SEVERPAY_RETURN_URL", "url", "payments", "Return URL", subsection="SeverPay"), - SettingField( - "SEVERPAY_LIFETIME_MINUTES", - "int", - "payments", - "Срок жизни ссылки (мин)", - "30..4320; пусто — значение провайдера", - subsection="SeverPay", - min=30, - max=4320, - ), - *_payment_presentation_fields("SEVERPAY", "SeverPay", default_icon="CreditCard"), # Wata SettingField("WATA_ENABLED", "bool", "payments", "Enabled", subsection="Wata"), SettingField( diff --git a/backend/bot/payment_providers/severpay.py b/backend/bot/payment_providers/severpay.py index 2a73e92..76ed725 100644 --- a/backend/bot/payment_providers/severpay.py +++ b/backend/bot/payment_providers/severpay.py @@ -7,6 +7,8 @@ from typing import Any, Dict, Optional, Tuple from aiogram import Bot, F, Router, types from aiohttp import web +from pydantic import Field, field_validator +from pydantic_settings import SettingsConfigDict from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import sessionmaker @@ -16,7 +18,13 @@ from bot.services.subscription_service import SubscriptionService from config.settings import Settings from db.dal import payment_dal -from .base import PaymentProviderSpec, ServiceFactoryContext, WebAppPaymentContext +from .base import ( + PaymentProviderSpec, + ProviderEnvConfig, + ProviderManifestField, + ServiceFactoryContext, + WebAppPaymentContext, +) from .shared import ( HttpClientMixin, PaymentSuccessRequest, @@ -43,12 +51,65 @@ from .shared import ( _LOG = "severpay" +class SeverPayConfig(ProviderEnvConfig): + model_config = SettingsConfigDict( + env_file=".env", + env_file_encoding="utf-8", + env_prefix="SEVERPAY_", + extra="ignore", + ) + + ENABLED: bool = Field(default=False) + MID: Optional[int] = None + TOKEN: Optional[str] = None + RETURN_URL: Optional[str] = None + BASE_URL: str = Field(default="https://severpay.io/api/merchant") + LIFETIME_MINUTES: Optional[int] = None + + @field_validator("MID", "LIFETIME_MINUTES", mode="before") + @classmethod + def _empty_to_none_int(cls, v): + if isinstance(v, str): + v = v.strip() + if not v: + return None + return v + + @field_validator("TOKEN", "RETURN_URL", mode="before") + @classmethod + def _strip_optional(cls, v): + if isinstance(v, str) and not v.strip(): + return None + return v + + @property + def webhook_path(self) -> str: + return "/webhook/severpay" + + +class SeverPayPresentation(ProviderEnvConfig): + model_config = SettingsConfigDict( + env_file=".env", + env_file_encoding="utf-8", + env_prefix="PAYMENT_SEVERPAY_", + extra="ignore", + ) + + WEBAPP_LABEL_RU: Optional[str] = None + WEBAPP_LABEL_EN: Optional[str] = None + WEBAPP_ICON: Optional[str] = None + TELEGRAM_LABEL_RU: Optional[str] = None + TELEGRAM_LABEL_EN: Optional[str] = None + TELEGRAM_EMOJI: Optional[str] = None + + class SeverPayService(HttpClientMixin): def __init__( self, *, bot: Bot, settings: Settings, + config: SeverPayConfig, i18n: JsonI18n, async_session_factory: sessionmaker, subscription_service: SubscriptionService, @@ -57,22 +118,21 @@ class SeverPayService(HttpClientMixin): ): self.bot = bot self.settings = settings + self.config = config self.i18n = i18n self.async_session_factory = async_session_factory self.subscription_service = subscription_service self.referral_service = referral_service - self.base_url = (settings.SEVERPAY_BASE_URL or "https://severpay.io/api/merchant").rstrip( - "/" - ) - self.mid = settings.SEVERPAY_MID - self.token = settings.SEVERPAY_TOKEN or "" - self.return_url = settings.SEVERPAY_RETURN_URL or f"https://t.me/{default_return_url}" - self.lifetime_minutes = settings.SEVERPAY_LIFETIME_MINUTES + self.base_url = (config.BASE_URL or "https://severpay.io/api/merchant").rstrip("/") + self.mid = config.MID + self.token = config.TOKEN or "" + self.return_url = config.RETURN_URL or f"https://t.me/{default_return_url}" + self.lifetime_minutes = config.LIFETIME_MINUTES self._init_http_client(total_timeout=15) - self.configured: bool = bool(settings.SEVERPAY_ENABLED and self.mid and self.token) + self.configured: bool = bool(config.ENABLED and self.mid and self.token) if not self.configured: logging.warning( "SeverPayService initialized but not fully configured. Payments disabled." @@ -373,9 +433,12 @@ async def pay_severpay_callback_handler( def create_service(ctx: ServiceFactoryContext) -> SeverPayService: + bundle = ctx.config_for("severpay_service") + config = bundle.config if bundle and isinstance(bundle.config, SeverPayConfig) else SeverPayConfig() return SeverPayService( bot=ctx.bot, settings=ctx.settings, + config=config, i18n=ctx.i18n, async_session_factory=ctx.async_session_factory, subscription_service=ctx.subscription_service, @@ -422,6 +485,47 @@ async def create_webapp_payment(ctx: WebAppPaymentContext) -> web.Response: ) +_PRESENTATION_MANIFEST = tuple( + ProviderManifestField( + key=key, type=type_, label=label, description=description, + placeholder=placeholder, subsection="SeverPay", + target="presentation", attr=attr, + ) + for key, type_, label, description, placeholder, attr in ( + ("PAYMENT_SEVERPAY_WEBAPP_LABEL_RU", "string", "WebApp button text (RU)", + "Custom Russian text shown in the Web App payment method button.", "", "WEBAPP_LABEL_RU"), + ("PAYMENT_SEVERPAY_WEBAPP_LABEL_EN", "string", "WebApp button text (EN)", + "Custom English text shown in the Web App payment method button.", "", "WEBAPP_LABEL_EN"), + ("PAYMENT_SEVERPAY_WEBAPP_ICON", "icon", "WebApp button icon", + "Lucide icon name rendered inside the Web App payment method button.", + "CreditCard", "WEBAPP_ICON"), + ("PAYMENT_SEVERPAY_TELEGRAM_LABEL_RU", "string", "Telegram button text (RU)", + "Custom Russian text shown in Telegram bot payment buttons.", "", "TELEGRAM_LABEL_RU"), + ("PAYMENT_SEVERPAY_TELEGRAM_LABEL_EN", "string", "Telegram button text (EN)", + "Custom English text shown in Telegram bot payment buttons.", "", "TELEGRAM_LABEL_EN"), + ("PAYMENT_SEVERPAY_TELEGRAM_EMOJI", "string", "Telegram button emoji", + "Emoji prepended to the Telegram bot payment button when customized.", + "💳", "TELEGRAM_EMOJI"), + ) +) + +_CONFIG_MANIFEST = ( + ProviderManifestField("SEVERPAY_ENABLED", "bool", "Включена", + subsection="SeverPay", attr="ENABLED"), + ProviderManifestField("SEVERPAY_MID", "int", "MID", subsection="SeverPay", attr="MID"), + ProviderManifestField("SEVERPAY_TOKEN", "string", "Token", subsection="SeverPay", + secret=True, attr="TOKEN"), + ProviderManifestField("SEVERPAY_BASE_URL", "url", "Base URL", + placeholder="https://severpay.io/api/merchant", + subsection="SeverPay", attr="BASE_URL"), + ProviderManifestField("SEVERPAY_RETURN_URL", "url", "Return URL", + subsection="SeverPay", attr="RETURN_URL"), + ProviderManifestField("SEVERPAY_LIFETIME_MINUTES", "int", "Payment link lifetime (minutes)", + description="30..4320; leave empty for the SeverPay default.", + subsection="SeverPay", min=30, max=4320, attr="LIFETIME_MINUTES"), +) + + SPEC = PaymentProviderSpec( id="severpay", provider_key="severpay", @@ -432,12 +536,15 @@ SPEC = PaymentProviderSpec( telegram_labels={"ru": "SeverPay", "en": "SeverPay"}, telegram_emoji="💳", pending_status="pending_severpay", - enabled=lambda settings: settings.SEVERPAY_ENABLED, + enabled=lambda config: bool(getattr(config, "ENABLED", False)), service_key="severpay_service", callback_prefix="pay_severpay", router=router, create_service=create_service, - webhook_path=lambda settings: settings.severpay_webhook_path, + webhook_path=lambda source: "/webhook/severpay", webhook_route=severpay_webhook_route, create_webapp_payment=create_webapp_payment, + config_class=SeverPayConfig, + presentation_class=SeverPayPresentation, + manifest_fields=_CONFIG_MANIFEST + _PRESENTATION_MANIFEST, ) diff --git a/backend/config/settings.py b/backend/config/settings.py index 2c3e4dd..b913b45 100644 --- a/backend/config/settings.py +++ b/backend/config/settings.py @@ -56,12 +56,6 @@ class PaymentSettings(BaseModel): platega_crypto_method: int platega_return_url: Optional[str] platega_failed_url: Optional[str] - severpay_enabled: bool - severpay_mid: Optional[int] - severpay_token: Optional[str] - severpay_return_url: Optional[str] - severpay_base_url: str - severpay_lifetime_minutes: Optional[int] wata_enabled: bool wata_api_token: Optional[str] wata_base_url: str @@ -254,16 +248,6 @@ class Settings(BaseSettings): description="Comma-separated FreeKassa webhook IP allowlist.", ) - SEVERPAY_ENABLED: bool = Field(default=False) - SEVERPAY_MID: Optional[int] = None - SEVERPAY_TOKEN: Optional[str] = None - SEVERPAY_RETURN_URL: Optional[str] = None - SEVERPAY_BASE_URL: str = Field(default="https://severpay.io/api/merchant") - SEVERPAY_LIFETIME_MINUTES: Optional[int] = Field( - default=None, - description="Lifetime of the payment link in minutes (30-4320, defaults to provider value)", - ) - WATA_ENABLED: bool = Field(default=False) WATA_API_TOKEN: Optional[str] = None WATA_BASE_URL: str = Field(default="https://api.wata.pro/api/h2h") @@ -307,12 +291,6 @@ class Settings(BaseSettings): PAYMENT_PLATEGA_CRYPTO_TELEGRAM_LABEL_RU: Optional[str] = None PAYMENT_PLATEGA_CRYPTO_TELEGRAM_LABEL_EN: Optional[str] = None PAYMENT_PLATEGA_CRYPTO_TELEGRAM_EMOJI: Optional[str] = None - PAYMENT_SEVERPAY_WEBAPP_LABEL_RU: Optional[str] = None - PAYMENT_SEVERPAY_WEBAPP_LABEL_EN: Optional[str] = None - PAYMENT_SEVERPAY_WEBAPP_ICON: Optional[str] = None - PAYMENT_SEVERPAY_TELEGRAM_LABEL_RU: Optional[str] = None - PAYMENT_SEVERPAY_TELEGRAM_LABEL_EN: Optional[str] = None - PAYMENT_SEVERPAY_TELEGRAM_EMOJI: Optional[str] = None PAYMENT_WATA_WEBAPP_LABEL_RU: Optional[str] = None PAYMENT_WATA_WEBAPP_LABEL_EN: Optional[str] = None PAYMENT_WATA_WEBAPP_ICON: Optional[str] = None @@ -602,12 +580,6 @@ class Settings(BaseSettings): platega_crypto_method=self.PLATEGA_CRYPTO_METHOD, platega_return_url=self.PLATEGA_RETURN_URL, platega_failed_url=self.PLATEGA_FAILED_URL, - severpay_enabled=self.SEVERPAY_ENABLED, - severpay_mid=self.SEVERPAY_MID, - severpay_token=self.SEVERPAY_TOKEN, - severpay_return_url=self.SEVERPAY_RETURN_URL, - severpay_base_url=self.SEVERPAY_BASE_URL, - severpay_lifetime_minutes=self.SEVERPAY_LIFETIME_MINUTES, wata_enabled=self.WATA_ENABLED, wata_api_token=self.WATA_API_TOKEN, wata_base_url=self.WATA_BASE_URL, @@ -791,19 +763,6 @@ class Settings(BaseSettings): return f"{base.rstrip('/')}{self.freekassa_webhook_path}" return None - @computed_field - @property - def severpay_webhook_path(self) -> str: - return "/webhook/severpay" - - @computed_field - @property - def severpay_full_webhook_url(self) -> Optional[str]: - base = self.WEBHOOK_BASE_URL - if base: - return f"{base.rstrip('/')}{self.severpay_webhook_path}" - return None - @computed_field @property def wata_webhook_path(self) -> str: @@ -1164,7 +1123,6 @@ class Settings(BaseSettings): "REQUIRED_CHANNEL_LINK", "PLATEGA_RETURN_URL", "PLATEGA_FAILED_URL", - "SEVERPAY_RETURN_URL", "WATA_RETURN_URL", "WATA_FAILED_URL", "WATA_API_TOKEN", @@ -1189,9 +1147,7 @@ class Settings(BaseSettings): return None return v - @field_validator( - "USER_HWID_DEVICE_LIMIT", "SEVERPAY_MID", "SEVERPAY_LIFETIME_MINUTES", mode="before" - ) + @field_validator("USER_HWID_DEVICE_LIMIT", mode="before") @classmethod def validate_optional_int(cls, v): if isinstance(v, str): @@ -1297,11 +1253,6 @@ def get_settings() -> Settings: logging.warning( "CRITICAL: Platega is enabled but merchant credentials (PLATEGA_MERCHANT_ID/PLATEGA_SECRET) are missing. Platega payments will not work." # noqa: E501 ) - if _settings_instance.SEVERPAY_ENABLED: - if not _settings_instance.SEVERPAY_MID or not _settings_instance.SEVERPAY_TOKEN: - logging.warning( - "CRITICAL: SeverPay is enabled but MID or TOKEN is missing. SeverPay payments will not work." # noqa: E501 - ) if _settings_instance.WATA_ENABLED: if not _settings_instance.WATA_API_TOKEN: logging.warning(