diff --git a/backend/bot/app/web/admin_settings_manifest.py b/backend/bot/app/web/admin_settings_manifest.py index ff9f152..08dfd47 100644 --- a/backend/bot/app/web/admin_settings_manifest.py +++ b/backend/bot/app/web/admin_settings_manifest.py @@ -250,63 +250,6 @@ SETTINGS_MANIFEST: List[SettingField] = [ subsection="YooKassa", ), *_payment_presentation_fields("YOOKASSA", "YooKassa", default_icon="CreditCard"), - # FreeKassa - SettingField("FREEKASSA_ENABLED", "bool", "payments", "Включена", subsection="FreeKassa"), - SettingField( - "FREEKASSA_MERCHANT_ID", "string", "payments", "Merchant ID", subsection="FreeKassa" - ), - SettingField( - "FREEKASSA_FIRST_SECRET", - "string", - "payments", - "First secret", - subsection="FreeKassa", - secret=True, - ), - SettingField( - "FREEKASSA_SECOND_SECRET", - "string", - "payments", - "Second secret", - "Используется для проверки подписи входящих уведомлений", - subsection="FreeKassa", - secret=True, - ), - SettingField( - "FREEKASSA_API_KEY", "string", "payments", "API key", subsection="FreeKassa", secret=True - ), - SettingField( - "FREEKASSA_PAYMENT_URL", - "url", - "payments", - "Payment URL", - placeholder="https://pay.freekassa.ru/", - subsection="FreeKassa", - ), - SettingField( - "FREEKASSA_PAYMENT_METHOD_ID", - "int", - "payments", - "Метод оплаты по умолчанию", - subsection="FreeKassa", - ), - SettingField( - "FREEKASSA_PAYMENT_IP", - "string", - "payments", - "IP сервера", - "Передаётся в подпись запроса при создании платежа", - subsection="FreeKassa", - ), - SettingField( - "FREEKASSA_TRUSTED_IPS", - "string", - "payments", - "Доверенные IP", - "Через запятую — IP-адреса, с которых принимаются нотификации", - subsection="FreeKassa", - ), - *_payment_presentation_fields("FREEKASSA", "FreeKassa", default_icon="Smartphone"), # Platega SettingField("PLATEGA_ENABLED", "bool", "payments", "Включена", subsection="Platega"), SettingField( diff --git a/backend/bot/handlers/user/subscription/payments_subscription.py b/backend/bot/handlers/user/subscription/payments_subscription.py index d988f63..f1d96e9 100644 --- a/backend/bot/handlers/user/subscription/payments_subscription.py +++ b/backend/bot/handlers/user/subscription/payments_subscription.py @@ -59,14 +59,11 @@ async def select_subscription_period_callback_handler( if price_rub is None: if traffic_mode and not price_source and stars_price is not None: + from bot.payment_providers import iter_provider_specs + currency_methods_enabled = any( - [ - settings.FREEKASSA_ENABLED, - settings.PLATEGA_ENABLED, - settings.SEVERPAY_ENABLED, - settings.YOOKASSA_ENABLED, - settings.CRYPTOPAY_ENABLED, - ] + spec.price_source != "stars" and spec.is_enabled(settings) + for spec in iter_provider_specs() ) if currency_methods_enabled: logging.error( diff --git a/backend/bot/payment_providers/freekassa.py b/backend/bot/payment_providers/freekassa.py index 1341b06..b10f401 100644 --- a/backend/bot/payment_providers/freekassa.py +++ b/backend/bot/payment_providers/freekassa.py @@ -10,6 +10,8 @@ from urllib.parse import parse_qsl 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 @@ -22,6 +24,8 @@ from db.dal import payment_dal from .base import ( PaymentProviderSpec, + ProviderEnvConfig, + ProviderManifestField, ServiceFactoryContext, WebAppPaymentContext, ) @@ -50,12 +54,77 @@ from .shared import ( _LOG = "freekassa" +class FreeKassaConfig(ProviderEnvConfig): + model_config = SettingsConfigDict( + env_file=".env", + env_file_encoding="utf-8", + env_prefix="FREEKASSA_", + extra="ignore", + ) + + ENABLED: bool = Field(default=False) + MERCHANT_ID: Optional[str] = None + FIRST_SECRET: Optional[str] = None + SECOND_SECRET: Optional[str] = None + PAYMENT_URL: str = Field(default="https://pay.freekassa.ru/") + API_KEY: Optional[str] = None + PAYMENT_IP: Optional[str] = None + PAYMENT_METHOD_ID: Optional[int] = None + TRUSTED_IPS: str = Field( + default="168.119.157.136,168.119.60.227,178.154.197.79,51.250.54.238" + ) + + @field_validator("PAYMENT_METHOD_ID", 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( + "MERCHANT_ID", "FIRST_SECRET", "SECOND_SECRET", "API_KEY", "PAYMENT_IP", + 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/freekassa" + + @property + def trusted_ips_list(self) -> list: + return [item.strip() for item in (self.TRUSTED_IPS or "").split(",") if item.strip()] + + +class FreeKassaPresentation(ProviderEnvConfig): + model_config = SettingsConfigDict( + env_file=".env", + env_file_encoding="utf-8", + env_prefix="PAYMENT_FREEKASSA_", + 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 FreeKassaService(HttpClientMixin): def __init__( self, *, bot: Bot, settings: Settings, + config: FreeKassaConfig, i18n: JsonI18n, async_session_factory: sessionmaker, subscription_service: SubscriptionService, @@ -63,29 +132,30 @@ class FreeKassaService(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.shop_id: Optional[str] = settings.FREEKASSA_MERCHANT_ID - self.api_key: Optional[str] = settings.FREEKASSA_API_KEY - self.second_secret: Optional[str] = settings.FREEKASSA_SECOND_SECRET + self.shop_id: Optional[str] = config.MERCHANT_ID + self.api_key: Optional[str] = config.API_KEY + self.second_secret: Optional[str] = config.SECOND_SECRET self.default_currency: str = (settings.DEFAULT_CURRENCY_SYMBOL or "RUB").upper() - self.server_ip: Optional[str] = settings.FREEKASSA_PAYMENT_IP - self.payment_method_id: Optional[int] = settings.FREEKASSA_PAYMENT_METHOD_ID + self.server_ip: Optional[str] = config.PAYMENT_IP + self.payment_method_id: Optional[int] = config.PAYMENT_METHOD_ID self.api_base_url: str = "https://api.fk.life/v1" self._init_http_client(total_timeout=15) self._nonce_lock = asyncio.Lock() self._last_nonce = int(time.time() * 1000) - self.configured: bool = bool(settings.FREEKASSA_ENABLED and self.shop_id and self.api_key) + self.configured: bool = bool(config.ENABLED and self.shop_id and self.api_key) if not self.configured: logging.warning( "FreeKassaService initialized but not fully configured. Payments disabled." ) - if settings.FREEKASSA_ENABLED and not self.server_ip: + if config.ENABLED and not self.server_ip: logging.warning( "FreeKassaService: FREEKASSA_PAYMENT_IP is not set. Requests may be rejected by the provider." # noqa: E501 ) @@ -185,7 +255,7 @@ class FreeKassaService(HttpClientMixin): try: client_ip = request_client_ip(request, trusted_proxies=self.settings.trusted_proxies) - if not ip_in_allowlist(client_ip, self.settings.freekassa_trusted_ips): + if not ip_in_allowlist(client_ip, self.config.trusted_ips_list): return web.Response(status=403) raw_body = await request.read() @@ -431,9 +501,12 @@ async def pay_fk_callback_handler( def create_service(ctx: ServiceFactoryContext) -> FreeKassaService: + bundle = ctx.config_for("freekassa_service") + config = bundle.config if bundle and isinstance(bundle.config, FreeKassaConfig) else FreeKassaConfig() return FreeKassaService( bot=ctx.bot, settings=ctx.settings, + config=config, i18n=ctx.i18n, async_session_factory=ctx.async_session_factory, subscription_service=ctx.subscription_service, @@ -479,6 +552,56 @@ async def create_webapp_payment(ctx: WebAppPaymentContext) -> web.Response: ) +_PRESENTATION_MANIFEST = tuple( + ProviderManifestField( + key=key, type=type_, label=label, description=description, + placeholder=placeholder, subsection="FreeKassa", + target="presentation", attr=attr, + ) + for key, type_, label, description, placeholder, attr in ( + ("PAYMENT_FREEKASSA_WEBAPP_LABEL_RU", "string", "WebApp button text (RU)", + "Custom Russian text shown in the Web App payment method button.", "", "WEBAPP_LABEL_RU"), + ("PAYMENT_FREEKASSA_WEBAPP_LABEL_EN", "string", "WebApp button text (EN)", + "Custom English text shown in the Web App payment method button.", "", "WEBAPP_LABEL_EN"), + ("PAYMENT_FREEKASSA_WEBAPP_ICON", "icon", "WebApp button icon", + "Lucide icon name rendered inside the Web App payment method button.", + "Smartphone", "WEBAPP_ICON"), + ("PAYMENT_FREEKASSA_TELEGRAM_LABEL_RU", "string", "Telegram button text (RU)", + "Custom Russian text shown in Telegram bot payment buttons.", "", "TELEGRAM_LABEL_RU"), + ("PAYMENT_FREEKASSA_TELEGRAM_LABEL_EN", "string", "Telegram button text (EN)", + "Custom English text shown in Telegram bot payment buttons.", "", "TELEGRAM_LABEL_EN"), + ("PAYMENT_FREEKASSA_TELEGRAM_EMOJI", "string", "Telegram button emoji", + "Emoji prepended to the Telegram bot payment button when customized.", + "📱", "TELEGRAM_EMOJI"), + ) +) + +_CONFIG_MANIFEST = ( + ProviderManifestField("FREEKASSA_ENABLED", "bool", "Включена", + subsection="FreeKassa", attr="ENABLED"), + ProviderManifestField("FREEKASSA_MERCHANT_ID", "string", "Merchant ID", + subsection="FreeKassa", attr="MERCHANT_ID"), + ProviderManifestField("FREEKASSA_FIRST_SECRET", "string", "First secret", + subsection="FreeKassa", secret=True, attr="FIRST_SECRET"), + ProviderManifestField("FREEKASSA_SECOND_SECRET", "string", "Second secret", + subsection="FreeKassa", secret=True, attr="SECOND_SECRET"), + ProviderManifestField("FREEKASSA_API_KEY", "string", "API key", + subsection="FreeKassa", secret=True, attr="API_KEY"), + ProviderManifestField("FREEKASSA_PAYMENT_URL", "url", "Payment URL", + placeholder="https://pay.freekassa.ru/", + subsection="FreeKassa", attr="PAYMENT_URL"), + ProviderManifestField("FREEKASSA_PAYMENT_METHOD_ID", "int", "Payment method ID", + description="See https://merchant.freekassa.net/settings/currencies", + subsection="FreeKassa", attr="PAYMENT_METHOD_ID"), + ProviderManifestField("FREEKASSA_PAYMENT_IP", "string", "Server IP", + description="Public IP address reported to FreeKassa.", + subsection="FreeKassa", attr="PAYMENT_IP"), + ProviderManifestField("FREEKASSA_TRUSTED_IPS", "string", "Trusted IPs", + description="Comma-separated IP addresses accepted for FreeKassa webhooks.", + subsection="FreeKassa", attr="TRUSTED_IPS"), +) + + SPEC = PaymentProviderSpec( id="freekassa", provider_key="freekassa", @@ -489,12 +612,15 @@ SPEC = PaymentProviderSpec( telegram_labels={"ru": "СБП", "en": "SBP"}, telegram_emoji="📱", pending_status="pending_freekassa", - enabled=lambda settings: settings.FREEKASSA_ENABLED, + enabled=lambda config: bool(getattr(config, "ENABLED", False)), service_key="freekassa_service", callback_prefix="pay_fk", router=router, create_service=create_service, - webhook_path=lambda settings: settings.freekassa_webhook_path, + webhook_path=lambda source: "/webhook/freekassa", webhook_route=freekassa_webhook_route, create_webapp_payment=create_webapp_payment, + config_class=FreeKassaConfig, + presentation_class=FreeKassaPresentation, + manifest_fields=_CONFIG_MANIFEST + _PRESENTATION_MANIFEST, ) diff --git a/backend/config/settings.py b/backend/config/settings.py index 5289c20..9511ed8 100644 --- a/backend/config/settings.py +++ b/backend/config/settings.py @@ -38,13 +38,6 @@ class PaymentSettings(BaseModel): yookassa_payment_subject: str yookassa_autopayments_enabled: bool yookassa_autopayments_require_card_binding: bool - freekassa_enabled: bool - freekassa_merchant_id: Optional[str] - freekassa_second_secret: Optional[str] - freekassa_api_key: Optional[str] - freekassa_payment_ip: Optional[str] - freekassa_payment_method_id: Optional[int] - freekassa_trusted_ips: List[str] platega_enabled: bool platega_base_url: str platega_merchant_id: Optional[str] @@ -226,31 +219,12 @@ class Settings(BaseSettings): PLATEGA_RETURN_URL: Optional[str] = Field(default=None) PLATEGA_FAILED_URL: Optional[str] = Field(default=None) - FREEKASSA_ENABLED: bool = Field(default=False) - FREEKASSA_MERCHANT_ID: Optional[str] = None - FREEKASSA_FIRST_SECRET: Optional[str] = None - FREEKASSA_SECOND_SECRET: Optional[str] = None - FREEKASSA_PAYMENT_URL: str = Field(default="https://pay.freekassa.ru/") - FREEKASSA_API_KEY: Optional[str] = None - FREEKASSA_PAYMENT_IP: Optional[str] = None - FREEKASSA_PAYMENT_METHOD_ID: Optional[int] = None - FREEKASSA_TRUSTED_IPS: str = Field( - default="168.119.157.136,168.119.60.227,178.154.197.79,51.250.54.238", - description="Comma-separated FreeKassa webhook IP allowlist.", - ) - YOOKASSA_ENABLED: bool = Field(default=True) STARS_ENABLED: bool = Field(default=True) PAYMENT_METHODS_ORDER: Optional[str] = Field( default=None, description="Comma-separated list of payment methods to show (e.g., severpay,wata,freekassa,yookassa,platega,stars,cryptopay)", # noqa: E501 ) - PAYMENT_FREEKASSA_WEBAPP_LABEL_RU: Optional[str] = None - PAYMENT_FREEKASSA_WEBAPP_LABEL_EN: Optional[str] = None - PAYMENT_FREEKASSA_WEBAPP_ICON: Optional[str] = None - PAYMENT_FREEKASSA_TELEGRAM_LABEL_RU: Optional[str] = None - PAYMENT_FREEKASSA_TELEGRAM_LABEL_EN: Optional[str] = None - PAYMENT_FREEKASSA_TELEGRAM_EMOJI: Optional[str] = None PAYMENT_PLATEGA_SBP_WEBAPP_LABEL_RU: Optional[str] = None PAYMENT_PLATEGA_SBP_WEBAPP_LABEL_EN: Optional[str] = None PAYMENT_PLATEGA_SBP_WEBAPP_ICON: Optional[str] = None @@ -528,13 +502,6 @@ class Settings(BaseSettings): yookassa_payment_subject=self.YOOKASSA_PAYMENT_SUBJECT, yookassa_autopayments_enabled=self.YOOKASSA_AUTOPAYMENTS_ENABLED, yookassa_autopayments_require_card_binding=self.YOOKASSA_AUTOPAYMENTS_REQUIRE_CARD_BINDING, - freekassa_enabled=self.FREEKASSA_ENABLED, - freekassa_merchant_id=self.FREEKASSA_MERCHANT_ID, - freekassa_second_secret=self.FREEKASSA_SECOND_SECRET, - freekassa_api_key=self.FREEKASSA_API_KEY, - freekassa_payment_ip=self.FREEKASSA_PAYMENT_IP, - freekassa_payment_method_id=self.FREEKASSA_PAYMENT_METHOD_ID, - freekassa_trusted_ips=self.freekassa_trusted_ips, platega_enabled=self.PLATEGA_ENABLED, platega_base_url=self.PLATEGA_BASE_URL, platega_merchant_id=self.PLATEGA_MERCHANT_ID, @@ -657,11 +624,6 @@ class Settings(BaseSettings): def trusted_proxies(self) -> List[str]: return _split_csv(self.TRUSTED_PROXIES) - @computed_field - @property - def freekassa_trusted_ips(self) -> List[str]: - return _split_csv(self.FREEKASSA_TRUSTED_IPS) - @computed_field @property def telegram_webhook_path(self) -> str: @@ -707,18 +669,6 @@ class Settings(BaseSettings): return f"{base.rstrip('/')}{self.cryptopay_webhook_path}" return None - @computed_field - @property - def freekassa_webhook_path(self) -> str: - return "/webhook/freekassa" - - @computed_field - @property - def freekassa_full_webhook_url(self) -> Optional[str]: - base = self.WEBHOOK_BASE_URL - if base: - return f"{base.rstrip('/')}{self.freekassa_webhook_path}" - return None @computed_field @property @@ -1152,23 +1102,6 @@ def get_settings() -> Settings: logging.warning( "WARNING: LKNPD credentials are incomplete. Receipt sending will be disabled." ) - if _settings_instance.FREEKASSA_ENABLED: - if ( - not _settings_instance.FREEKASSA_MERCHANT_ID - or not _settings_instance.FREEKASSA_API_KEY - ): - logging.warning( - "CRITICAL: FreeKassa is enabled but SHOP_ID or API key is missing. FreeKassa payments will not work." # noqa: E501 - ) - if not _settings_instance.FREEKASSA_SECOND_SECRET: - logging.warning( - "WARNING: FreeKassa second secret is not set. Incoming payment notifications cannot be verified." # noqa: E501 - ) - if not _settings_instance.subscription_options: - logging.warning( - "CRITICAL: FreeKassa is enabled but no subscription prices are configured (RUB_PRICE_*). Users will not see payment buttons." # noqa: E501 - ) - if _settings_instance.PLATEGA_ENABLED: if ( not _settings_instance.PLATEGA_MERCHANT_ID diff --git a/tests/test_security.py b/tests/test_security.py index bd40076..d7a79ca 100644 --- a/tests/test_security.py +++ b/tests/test_security.py @@ -60,21 +60,25 @@ class RequestSecurityTests(unittest.IsolatedAsyncioTestCase): class FreeKassaServiceTests(unittest.TestCase): def _make_service(self) -> FreeKassaService: + from bot.payment_providers.freekassa import FreeKassaConfig + settings = SimpleNamespace( - FREEKASSA_ENABLED=True, - FREEKASSA_MERCHANT_ID="123456", - FREEKASSA_API_KEY="api-key", - FREEKASSA_SECOND_SECRET="second-secret", DEFAULT_CURRENCY_SYMBOL="RUB", - FREEKASSA_PAYMENT_IP="203.0.113.10", - FREEKASSA_PAYMENT_METHOD_ID=44, - FREEKASSA_TRUSTED_IPS="127.0.0.1,203.0.113.0/24", trusted_proxies=["127.0.0.1"], - freekassa_trusted_ips=["127.0.0.1", "203.0.113.0/24"], + ) + config = FreeKassaConfig( + ENABLED=True, + MERCHANT_ID="123456", + API_KEY="api-key", + SECOND_SECRET="second-secret", + PAYMENT_IP="203.0.113.10", + PAYMENT_METHOD_ID=44, + TRUSTED_IPS="127.0.0.1,203.0.113.0/24", ) return FreeKassaService( bot=object(), settings=settings, + config=config, i18n=object(), async_session_factory=object(), subscription_service=object(),