diff --git a/backend/bot/app/factories/build_services.py b/backend/bot/app/factories/build_services.py index 9526f93..d382447 100644 --- a/backend/bot/app/factories/build_services.py +++ b/backend/bot/app/factories/build_services.py @@ -2,7 +2,11 @@ from aiogram import Bot from sqlalchemy.orm import sessionmaker from bot.middlewares.i18n import JsonI18n -from bot.payment_providers import ServiceFactoryContext, build_provider_services +from bot.payment_providers import ( + ServiceFactoryContext, + build_provider_configs, + 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 @@ -26,6 +30,7 @@ def build_core_services( panel_webhook_service = PanelWebhookService( bot, settings, i18n, async_session_factory, panel_service ) + provider_configs = build_provider_configs() payment_services = build_provider_services( ServiceFactoryContext( settings=settings, @@ -35,6 +40,7 @@ def build_core_services( bot_username_for_default_return=bot_username_for_default_return, subscription_service=subscription_service, referral_service=referral_service, + provider_configs=provider_configs, ) ) lknpd_service = LknpdService( diff --git a/backend/bot/app/web/admin_settings_manifest.py b/backend/bot/app/web/admin_settings_manifest.py index fd927a5..670a149 100644 --- a/backend/bot/app/web/admin_settings_manifest.py +++ b/backend/bot/app/web/admin_settings_manifest.py @@ -568,15 +568,44 @@ SETTINGS_MANIFEST: List[SettingField] = [ ] +def _provider_field_to_setting_field( + spec: Any, manifest_field: Any +) -> SettingField: + return SettingField( + key=manifest_field.key, + type=manifest_field.type, + section="payments", + label=manifest_field.label, + description=manifest_field.description, + placeholder=manifest_field.placeholder, + optional=manifest_field.optional, + secret=manifest_field.secret, + min=manifest_field.min, + max=manifest_field.max, + choices=tuple(manifest_field.choices) if manifest_field.choices else None, + subsection=manifest_field.subsection, + ) + + +def aggregated_manifest() -> List[SettingField]: + """SETTINGS_MANIFEST + per-provider fragments declared in provider SPECs.""" + from bot.payment_providers import iter_provider_manifest_fields # local to avoid cycle + + fields: List[SettingField] = list(SETTINGS_MANIFEST) + for spec, manifest_field in iter_provider_manifest_fields(): + fields.append(_provider_field_to_setting_field(spec, manifest_field)) + return fields + + def get_field_by_key(key: str) -> Optional[SettingField]: - for field in SETTINGS_MANIFEST: + for field in aggregated_manifest(): if field.key == key: return field return None def manifest_keys() -> List[str]: - return [f.key for f in SETTINGS_MANIFEST] + return [f.key for f in aggregated_manifest()] def coerce_value(field: SettingField, raw: Any) -> Any: @@ -635,7 +664,7 @@ def manifest_payload() -> List[dict]: "devices": 8, } items: List[dict] = [] - for field in SETTINGS_MANIFEST: + for field in aggregated_manifest(): auto_label_i18n_key = f"settings_field_{field.key.lower()}_label" auto_description_i18n_key = f"settings_field_{field.key.lower()}_description" item = { diff --git a/backend/bot/payment_providers/__init__.py b/backend/bot/payment_providers/__init__.py index a8e66a7..8a5a3ad 100644 --- a/backend/bot/payment_providers/__init__.py +++ b/backend/bot/payment_providers/__init__.py @@ -1,13 +1,21 @@ from .base import ( PaymentProviderPresentation, PaymentProviderSpec, + ProviderConfigBundle, + ProviderEnvConfig, + ProviderManifestField, ServiceFactoryContext, WebAppPaymentContext, ) from .registry import ( PAYMENT_PROVIDER_SPECS, + build_provider_configs, build_provider_services, + current_provider_configs, + find_manifest_owner, + get_provider_bundle, get_provider_spec, + iter_provider_manifest_fields, iter_provider_specs, iter_service_keys, iter_unique_provider_routers, @@ -22,10 +30,18 @@ __all__ = [ "PAYMENT_PROVIDER_SPECS", "PaymentProviderPresentation", "PaymentProviderSpec", + "ProviderConfigBundle", + "ProviderEnvConfig", + "ProviderManifestField", "ServiceFactoryContext", "WebAppPaymentContext", + "build_provider_configs", "build_provider_services", + "current_provider_configs", + "find_manifest_owner", + "get_provider_bundle", "get_provider_spec", + "iter_provider_manifest_fields", "iter_provider_specs", "iter_service_keys", "iter_unique_provider_routers", diff --git a/backend/bot/payment_providers/base.py b/backend/bot/payment_providers/base.py index b273249..7a2c2ae 100644 --- a/backend/bot/payment_providers/base.py +++ b/backend/bot/payment_providers/base.py @@ -1,7 +1,57 @@ from __future__ import annotations -from dataclasses import dataclass -from typing import Any, Awaitable, Callable, Mapping, Optional, Sequence +from dataclasses import dataclass, field +from typing import Any, Awaitable, Callable, List, Mapping, Optional, Sequence, Type + +from pydantic_settings import BaseSettings, SettingsConfigDict + + +class ProviderEnvConfig(BaseSettings): + """Base class for per-provider env-config models. + + Subclasses declare their own ``env_prefix`` (e.g. ``HELEKET_``) and + fields, so the provider module is the single source of truth for the + env vars it consumes — no edits in the global ``Settings`` required. + """ + + model_config = SettingsConfigDict( + env_file=".env", + env_file_encoding="utf-8", + extra="ignore", + populate_by_name=True, + ) + + +@dataclass(frozen=True) +class ProviderConfigBundle: + """Functional config + presentation overrides for a single provider.""" + + config: Optional[ProviderEnvConfig] = None + presentation: Optional[ProviderEnvConfig] = None + + +@dataclass(frozen=True) +class ProviderManifestField: + """Self-contained manifest entry declared by a provider module. + + Aggregated by the registry into the admin settings manifest, so the + admin UI gets per-provider fields without anyone editing + ``admin_settings_manifest.py``. + """ + + key: str + type: str + label: str + description: str = "" + placeholder: str = "" + secret: bool = False + optional: bool = True + min: Optional[float] = None + max: Optional[float] = None + choices: Optional[Sequence[tuple[str, str]]] = None + subsection: Optional[str] = None + target: str = "config" # "config" or "presentation" — which bundle slot it writes to + attr: Optional[str] = None # attribute name on the target model; defaults to key without env_prefix @dataclass(frozen=True) @@ -13,6 +63,12 @@ class ServiceFactoryContext: bot_username_for_default_return: str subscription_service: Any referral_service: Any + provider_configs: Mapping[str, ProviderConfigBundle] = field(default_factory=dict) + + def config_for(self, service_key: Optional[str]) -> Optional[ProviderConfigBundle]: + if not service_key: + return None + return self.provider_configs.get(service_key) @dataclass(frozen=True) @@ -60,6 +116,9 @@ class PaymentProviderSpec: emoji: str = "💳" webapp_icon: Optional[str] = None telegram_emoji: Optional[str] = None + config_class: Optional[Type[ProviderEnvConfig]] = None + presentation_class: Optional[Type[ProviderEnvConfig]] = None + manifest_fields: Sequence[ProviderManifestField] = () @property def settings_key(self) -> str: @@ -73,8 +132,17 @@ class PaymentProviderSpec: def method_ids(self) -> tuple[str, ...]: return (self.id, *tuple(self.aliases)) - def is_enabled(self, settings: Any) -> bool: - return bool(self.enabled(settings)) + def is_enabled(self, source: Any) -> bool: + # If this spec carries a provider-local config_class, prefer the live + # config bundle so callers can pass plain Settings without having to + # know about provider-local env layouts. + if self.config_class is not None and self.service_key: + from .registry import get_provider_bundle + + bundle = get_provider_bundle(self.service_key) + if bundle and bundle.config is not None: + return bool(self.enabled(bundle.config)) + return bool(self.enabled(source)) def is_service_configured(self, app: Any) -> bool: if not self.requires_configured_service: @@ -84,8 +152,8 @@ class PaymentProviderSpec: service = app.get(self.service_key) if hasattr(app, "get") else None return bool(service and getattr(service, "configured", False)) - def is_visible(self, settings: Any, app: Any) -> bool: - return self.is_enabled(settings) and self.is_service_configured(app) + def is_visible(self, source: Any, app: Any) -> bool: + return self.is_enabled(source) and self.is_service_configured(app) def load_router(self) -> Any: return self.router diff --git a/backend/bot/payment_providers/heleket.py b/backend/bot/payment_providers/heleket.py index 6533d4b..3ba4807 100644 --- a/backend/bot/payment_providers/heleket.py +++ b/backend/bot/payment_providers/heleket.py @@ -3,10 +3,12 @@ import hashlib import hmac import json import logging -from typing import Any, Dict, Optional, Tuple +from typing import Any, Dict, List, 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 @@ -17,7 +19,13 @@ from bot.utils.request_security import ip_in_allowlist, request_client_ip 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, @@ -48,6 +56,82 @@ _SUCCESS_STATUSES = {"paid", "paid_over"} _FAILED_STATUSES = {"fail", "wrong_amount", "cancel", "system_fail"} +class HeleketConfig(ProviderEnvConfig): + """All Heleket-specific env vars. Lives inside the provider module.""" + + model_config = SettingsConfigDict( + env_file=".env", + env_file_encoding="utf-8", + env_prefix="HELEKET_", + extra="ignore", + ) + + ENABLED: bool = Field(default=False) + MERCHANT_ID: Optional[str] = None + API_KEY: Optional[str] = None + BASE_URL: str = Field(default="https://api.heleket.com") + CURRENCY: str = Field(default="RUB") + TO_CURRENCY: Optional[str] = None + NETWORK: Optional[str] = None + RETURN_URL: Optional[str] = None + SUCCESS_URL: Optional[str] = None + LIFETIME_SECONDS: int = Field(default=3600) + VERIFY_WEBHOOK_SIGNATURE: bool = Field(default=True) + TRUSTED_IPS: str = Field(default="31.133.220.8") + + @field_validator("LIFETIME_SECONDS", mode="before") + @classmethod + def _clamp_lifetime(cls, v): + if isinstance(v, str): + v = v.strip() + try: + value = int(v) + except (TypeError, ValueError): + return 3600 + return min(43200, max(300, value)) + + @field_validator( + "MERCHANT_ID", "API_KEY", "TO_CURRENCY", "NETWORK", "RETURN_URL", "SUCCESS_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/heleket" + + def full_webhook_url(self, base: Optional[str]) -> Optional[str]: + if not base: + return None + return f"{base.rstrip('/')}{self.webhook_path}" + + @property + def trusted_ips_list(self) -> List[str]: + return [item.strip() for item in (self.TRUSTED_IPS or "").split(",") if item.strip()] + + +class HeleketPresentation(ProviderEnvConfig): + """Admin-tunable button text/icon overrides for Heleket.""" + + model_config = SettingsConfigDict( + env_file=".env", + env_file_encoding="utf-8", + env_prefix="PAYMENT_HELEKET_", + 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 + + def _serialize_for_signature(payload: Dict[str, Any]) -> str: """Serialize JSON exactly the way Heleket signs it. @@ -72,6 +156,7 @@ class HeleketService(HttpClientMixin): *, bot: Bot, settings: Settings, + config: HeleketConfig, i18n: JsonI18n, async_session_factory: sessionmaker, subscription_service: SubscriptionService, @@ -80,25 +165,26 @@ class HeleketService(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.HELEKET_BASE_URL or "https://api.heleket.com").rstrip("/") - self.merchant_id = settings.HELEKET_MERCHANT_ID or "" - self.api_key = settings.HELEKET_API_KEY or "" - self.currency = (settings.HELEKET_CURRENCY or "RUB").upper() - self.to_currency = (settings.HELEKET_TO_CURRENCY or "").strip() or None - self.network = (settings.HELEKET_NETWORK or "").strip() or None - self.return_url = settings.HELEKET_RETURN_URL or f"https://t.me/{default_return_url}" - self.success_url = settings.HELEKET_SUCCESS_URL or self.return_url - self.lifetime_seconds = settings.HELEKET_LIFETIME_SECONDS - self.verify_webhook_signature = settings.HELEKET_VERIFY_WEBHOOK_SIGNATURE + self.base_url = (config.BASE_URL or "https://api.heleket.com").rstrip("/") + self.merchant_id = config.MERCHANT_ID or "" + self.api_key = config.API_KEY or "" + self.currency = (config.CURRENCY or "RUB").upper() + self.to_currency = (config.TO_CURRENCY or "").strip() or None + self.network = (config.NETWORK or "").strip() or None + self.return_url = config.RETURN_URL or f"https://t.me/{default_return_url}" + self.success_url = config.SUCCESS_URL or self.return_url + self.lifetime_seconds = config.LIFETIME_SECONDS + self.verify_webhook_signature = config.VERIFY_WEBHOOK_SIGNATURE self._init_http_client(total_timeout=20) self.configured: bool = bool( - settings.HELEKET_ENABLED and self.merchant_id and self.api_key + config.ENABLED and self.merchant_id and self.api_key ) if not self.configured: logging.warning( @@ -184,9 +270,8 @@ class HeleketService(HttpClientMixin): return web.Response(status=503, text="heleket_disabled") client_ip = request_client_ip(request, trusted_proxies=self.settings.trusted_proxies) - if self.settings.heleket_trusted_ips and not ip_in_allowlist( - client_ip, self.settings.heleket_trusted_ips - ): + trusted = self.config.trusted_ips_list + if trusted and not ip_in_allowlist(client_ip, trusted): logging.warning("Heleket webhook denied from unauthorized IP source.") return web.Response(status=403, text="forbidden") @@ -386,7 +471,7 @@ async def pay_heleket_callback_handler( amount=parts.price, currency=currency_code, description=payment_description, - url_callback=settings.heleket_full_webhook_url, + url_callback=heleket_service.config.full_webhook_url(settings.WEBHOOK_BASE_URL), ) result = response_data.get("result") if isinstance(response_data, dict) else None await render_link_or_fail( @@ -424,7 +509,7 @@ async def create_webapp_payment(ctx: WebAppPaymentContext) -> web.Response: amount=ctx.price, currency=currency, description=ctx.description, - url_callback=settings.heleket_full_webhook_url, + url_callback=service.config.full_webhook_url(settings.WEBHOOK_BASE_URL), ) except Exception: await ctx.session.rollback() @@ -448,9 +533,12 @@ async def heleket_webhook_route(request: web.Request) -> web.Response: def create_service(ctx: ServiceFactoryContext) -> HeleketService: + bundle = ctx.config_for("heleket_service") + config = bundle.config if bundle and isinstance(bundle.config, HeleketConfig) else HeleketConfig() return HeleketService( bot=ctx.bot, settings=ctx.settings, + config=config, i18n=ctx.i18n, async_session_factory=ctx.async_session_factory, subscription_service=ctx.subscription_service, @@ -459,6 +547,66 @@ def create_service(ctx: ServiceFactoryContext) -> HeleketService: ) +_PRESENTATION_MANIFEST = tuple( + ProviderManifestField( + key=key, + type=type_, + label=label, + description=description, + placeholder=placeholder, + subsection="Heleket", + target="presentation", + attr=attr, + ) + for key, type_, label, description, placeholder, attr in ( + ("PAYMENT_HELEKET_WEBAPP_LABEL_RU", "string", "WebApp button text (RU)", + "Custom Russian text shown in the Web App payment method button.", "", "WEBAPP_LABEL_RU"), + ("PAYMENT_HELEKET_WEBAPP_LABEL_EN", "string", "WebApp button text (EN)", + "Custom English text shown in the Web App payment method button.", "", "WEBAPP_LABEL_EN"), + ("PAYMENT_HELEKET_WEBAPP_ICON", "icon", "WebApp button icon", + "Lucide icon name rendered inside the Web App payment method button.", "Bitcoin", "WEBAPP_ICON"), + ("PAYMENT_HELEKET_TELEGRAM_LABEL_RU", "string", "Telegram button text (RU)", + "Custom Russian text shown in Telegram bot payment buttons.", "", "TELEGRAM_LABEL_RU"), + ("PAYMENT_HELEKET_TELEGRAM_LABEL_EN", "string", "Telegram button text (EN)", + "Custom English text shown in Telegram bot payment buttons.", "", "TELEGRAM_LABEL_EN"), + ("PAYMENT_HELEKET_TELEGRAM_EMOJI", "string", "Telegram button emoji", + "Emoji prepended to the Telegram bot payment button when customized.", "🪙", "TELEGRAM_EMOJI"), + ) +) + + +_CONFIG_MANIFEST = ( + ProviderManifestField("HELEKET_ENABLED", "bool", "Enabled", subsection="Heleket", attr="ENABLED"), + ProviderManifestField("HELEKET_MERCHANT_ID", "string", "Merchant ID", subsection="Heleket", + secret=True, attr="MERCHANT_ID"), + ProviderManifestField("HELEKET_API_KEY", "string", "Payment API key", subsection="Heleket", + secret=True, attr="API_KEY"), + ProviderManifestField("HELEKET_BASE_URL", "url", "Base URL", + placeholder="https://api.heleket.com", subsection="Heleket", attr="BASE_URL"), + ProviderManifestField("HELEKET_CURRENCY", "string", "Invoice currency", + description="Fiat or crypto code (RUB, USD, USDT).", + placeholder="RUB", subsection="Heleket", attr="CURRENCY"), + ProviderManifestField("HELEKET_TO_CURRENCY", "string", "Target crypto", + description="Optional target cryptocurrency for conversion.", + subsection="Heleket", attr="TO_CURRENCY"), + ProviderManifestField("HELEKET_NETWORK", "string", "Blockchain network", + description="Optional blockchain network code (tron, bsc, eth).", + subsection="Heleket", attr="NETWORK"), + ProviderManifestField("HELEKET_RETURN_URL", "url", "Return URL", subsection="Heleket", + attr="RETURN_URL"), + ProviderManifestField("HELEKET_SUCCESS_URL", "url", "Success URL", subsection="Heleket", + attr="SUCCESS_URL"), + ProviderManifestField("HELEKET_LIFETIME_SECONDS", "int", "Invoice lifetime (seconds)", + description="300..43200; Heleket defaults to 3600.", + subsection="Heleket", min=300, max=43200, attr="LIFETIME_SECONDS"), + ProviderManifestField("HELEKET_VERIFY_WEBHOOK_SIGNATURE", "bool", "Verify webhook signature", + subsection="Heleket", attr="VERIFY_WEBHOOK_SIGNATURE"), + ProviderManifestField("HELEKET_TRUSTED_IPS", "string", "Trusted IPs", + description="Comma-separated IP addresses accepted for Heleket webhooks.", + subsection="Heleket", attr="TRUSTED_IPS"), +) + + SPEC = PaymentProviderSpec( id="heleket", provider_key="heleket", @@ -469,13 +617,16 @@ SPEC = PaymentProviderSpec( telegram_labels={"ru": "Heleket", "en": "Heleket"}, telegram_emoji="🪙", pending_status="pending_heleket", - enabled=lambda settings: settings.HELEKET_ENABLED, + enabled=lambda config: bool(getattr(config, "ENABLED", False)), service_key="heleket_service", callback_prefix="pay_heleket", router=router, create_service=create_service, - webhook_path=lambda settings: settings.heleket_webhook_path, + webhook_path=lambda source: "/webhook/heleket", webhook_route=heleket_webhook_route, create_webapp_payment=create_webapp_payment, emoji="🪙", + config_class=HeleketConfig, + presentation_class=HeleketPresentation, + manifest_fields=_CONFIG_MANIFEST + _PRESENTATION_MANIFEST, ) diff --git a/backend/bot/payment_providers/registry.py b/backend/bot/payment_providers/registry.py index 28bfc0f..2aec35e 100644 --- a/backend/bot/payment_providers/registry.py +++ b/backend/bot/payment_providers/registry.py @@ -3,7 +3,13 @@ from __future__ import annotations from typing import Any, Dict, Iterable, List, Mapping, Optional from . import cryptopay, freekassa, heleket, platega, severpay, stars, wata, yookassa -from .base import PaymentProviderPresentation, PaymentProviderSpec, ServiceFactoryContext +from .base import ( + PaymentProviderPresentation, + PaymentProviderSpec, + ProviderConfigBundle, + ProviderManifestField, + ServiceFactoryContext, +) PAYMENT_PROVIDER_SPECS: tuple[PaymentProviderSpec, ...] = ( freekassa.SPEC, @@ -18,6 +24,13 @@ PAYMENT_PROVIDER_SPECS: tuple[PaymentProviderSpec, ...] = ( ) +# Provider configs (env-loaded BaseSettings models) live here as a process-wide +# singleton populated by build_provider_configs() on startup. Modules that need +# to read provider configs without changing call signatures (e.g. presentation +# resolution from arbitrary callers) can look them up via current_provider_configs(). +_provider_configs: Dict[str, ProviderConfigBundle] = {} + + def iter_provider_specs() -> Iterable[PaymentProviderSpec]: return PAYMENT_PROVIDER_SPECS @@ -30,10 +43,44 @@ def get_provider_spec(method: str) -> Optional[PaymentProviderSpec]: return None -def _setting_value(settings: Any, key: str) -> Optional[str]: - if settings is None: +def build_provider_configs() -> Dict[str, ProviderConfigBundle]: + """Instantiate per-provider BaseSettings models declared on each SPEC. + + Returns a mapping ``service_key`` → ``ProviderConfigBundle(config, presentation)``. + Specs with neither ``config_class`` nor ``presentation_class`` are skipped. + The result is cached as the process-wide bundle. + """ + bundles: Dict[str, ProviderConfigBundle] = {} + seen: set[str] = set() + for spec in PAYMENT_PROVIDER_SPECS: + if not spec.service_key or spec.service_key in seen: + continue + if spec.config_class is None and spec.presentation_class is None: + continue + seen.add(spec.service_key) + bundles[spec.service_key] = ProviderConfigBundle( + config=spec.config_class() if spec.config_class else None, + presentation=spec.presentation_class() if spec.presentation_class else None, + ) + _provider_configs.clear() + _provider_configs.update(bundles) + return bundles + + +def current_provider_configs() -> Mapping[str, ProviderConfigBundle]: + return _provider_configs + + +def get_provider_bundle(service_key: Optional[str]) -> Optional[ProviderConfigBundle]: + if not service_key: return None - value = getattr(settings, key, None) + return _provider_configs.get(service_key) + + +def _setting_value(source: Any, key: str) -> Optional[str]: + if source is None: + return None + value = getattr(source, key, None) if value is None: return None value = str(value).strip() @@ -50,18 +97,58 @@ def _normalize_language(language: Optional[str], settings: Any = None) -> str: return normalized or "ru" +def _presentation_attr(suffix: str, language: Optional[str] = None) -> str: + """Attribute name on a provider's presentation BaseSettings model. + + Mirrors the legacy ``PAYMENT__`` env name, but without the + ``PAYMENT__`` prefix (which is supplied by env_prefix on the model). + """ + if language: + return f"{suffix}_{language.upper()}" + return suffix + + +def _provider_presentation_value( + spec: PaymentProviderSpec, + suffix: str, + *, + language: Optional[str] = None, +) -> Optional[str]: + bundle = _provider_configs.get(spec.service_key) if spec.service_key else None + if not bundle or not bundle.presentation: + return None + attr = _presentation_attr(suffix, language=language) + return _setting_value(bundle.presentation, attr) + + def _localized_setting_value( settings: Any, spec: PaymentProviderSpec, suffix: str, language: str, ) -> Optional[str]: + # 1) Per-provider presentation model (new pattern) takes priority. + provider_value = _provider_presentation_value(spec, suffix, language=language) + if provider_value is not None: + return provider_value + # 2) Legacy: PAYMENT___ on the global Settings. return _setting_value( settings, _presentation_setting(spec, f"{suffix}_{language.upper()}"), ) +def _bare_setting_value( + settings: Any, + spec: PaymentProviderSpec, + suffix: str, +) -> Optional[str]: + provider_value = _provider_presentation_value(spec, suffix) + if provider_value is not None: + return provider_value + return _setting_value(settings, _presentation_setting(spec, suffix)) + + def _localized_default( values: Optional[Mapping[str, str]], language: str, @@ -91,7 +178,7 @@ def resolve_provider_presentation( or spec.label ) webapp_icon = ( - _setting_value(settings, _presentation_setting(spec, "WEBAPP_ICON")) + _bare_setting_value(settings, spec, "WEBAPP_ICON") or spec.webapp_icon ) telegram_label_override = _localized_setting_value( @@ -100,9 +187,7 @@ def resolve_provider_presentation( "TELEGRAM_LABEL", lang, ) - telegram_emoji_override = _setting_value( - settings, _presentation_setting(spec, "TELEGRAM_EMOJI") - ) + telegram_emoji_override = _bare_setting_value(settings, spec, "TELEGRAM_EMOJI") telegram_label = ( telegram_label_override or _localized_default(spec.telegram_labels, lang, None) @@ -200,3 +285,18 @@ def pending_statuses() -> List[str]: if spec.pending_status not in statuses: statuses.append(spec.pending_status) return statuses + + +def iter_provider_manifest_fields() -> Iterable[tuple[PaymentProviderSpec, ProviderManifestField]]: + """Yield (spec, manifest_field) for every fragment declared on a provider SPEC.""" + for spec in PAYMENT_PROVIDER_SPECS: + for field in spec.manifest_fields: + yield spec, field + + +def find_manifest_owner(key: str) -> Optional[tuple[PaymentProviderSpec, ProviderManifestField]]: + """Find which provider owns a manifest key (if any).""" + for spec, field in iter_provider_manifest_fields(): + if field.key == key: + return spec, field + return None diff --git a/backend/bot/services/settings_override_service.py b/backend/bot/services/settings_override_service.py index d4d8ca6..9b3ca55 100644 --- a/backend/bot/services/settings_override_service.py +++ b/backend/bot/services/settings_override_service.py @@ -60,7 +60,41 @@ def _resolve_attribute_name(settings: Settings, key: str) -> Optional[str]: return None +def _apply_to_provider_bundle(key: str, value: Any) -> bool: + """Route an override into the matching provider config/presentation model. + + Provider modules own their env-config via BaseSettings subclasses; here + we look up which one owns ``key`` and write the value into the right + attribute on the right model. + """ + from bot.payment_providers import ( + find_manifest_owner, + get_provider_bundle, + ) + + owner = find_manifest_owner(key) + if owner is None: + return False + spec, manifest_field = owner + bundle = get_provider_bundle(spec.service_key) + if bundle is None: + return False + target = bundle.presentation if manifest_field.target == "presentation" else bundle.config + if target is None: + return False + attr_name = manifest_field.attr or key + try: + setattr(target, attr_name, value) + return True + except Exception as exc: # pragma: no cover - defensive + logger.warning("Failed to apply provider override %s=%r: %s", key, value, exc) + return False + + def _apply_value(settings: Settings, key: str, value: Any) -> bool: + # Provider-owned keys go to provider models, not the central Settings. + if _apply_to_provider_bundle(key, value): + return True attr_name = _resolve_attribute_name(settings, key) if not attr_name: return False @@ -233,12 +267,41 @@ async def update_overrides( await app_settings_dal.delete_override(session, key) # Apply locally; deletes need an env-default fallback. We re-read the env - # default by instantiating a fresh Settings() (cheap; just a few ms) and - # copying the matching attributes back over. + # default by instantiating a fresh Settings() / provider-config model + # (cheap; just a few ms) and copying the matching attributes back over. if valid_deletes: + from bot.payment_providers import find_manifest_owner, get_provider_bundle + try: env_only = Settings() for key in valid_deletes: + owner = find_manifest_owner(key) + if owner is not None: + spec, manifest_field = owner + bundle = get_provider_bundle(spec.service_key) + if bundle is None: + continue + target = ( + bundle.presentation + if manifest_field.target == "presentation" + else bundle.config + ) + if target is None: + continue + cls = type(target) + try: + fresh = cls() + except Exception as exc: + logger.warning( + "Failed to reload provider env defaults for %s: %s", + key, + exc, + ) + continue + attr = manifest_field.attr or key + if hasattr(fresh, attr): + setattr(target, attr, getattr(fresh, attr)) + continue attr_name = _resolve_attribute_name(env_only, key) or key if hasattr(env_only, attr_name): setattr(settings, attr_name, getattr(env_only, attr_name)) diff --git a/backend/config/settings.py b/backend/config/settings.py index b628af5..2c3e4dd 100644 --- a/backend/config/settings.py +++ b/backend/config/settings.py @@ -76,18 +76,6 @@ class PaymentSettings(BaseModel): cryptopay_network: str cryptopay_currency_type: str cryptopay_asset: str - heleket_enabled: bool - heleket_merchant_id: Optional[str] - heleket_api_key: Optional[str] - heleket_base_url: str - heleket_currency: str - heleket_to_currency: Optional[str] - heleket_network: Optional[str] - heleket_return_url: Optional[str] - heleket_success_url: Optional[str] - heleket_lifetime_seconds: int - heleket_verify_webhook_signature: bool - heleket_trusted_ips: List[str] class EmailSettings(BaseModel): @@ -295,34 +283,6 @@ class Settings(BaseSettings): description="Comma-separated Wata webhook IP allowlist.", ) - HELEKET_ENABLED: bool = Field(default=False) - HELEKET_MERCHANT_ID: Optional[str] = None - HELEKET_API_KEY: Optional[str] = None - HELEKET_BASE_URL: str = Field(default="https://api.heleket.com") - HELEKET_CURRENCY: str = Field( - default="RUB", - description="Invoice currency (fiat or crypto code).", - ) - HELEKET_TO_CURRENCY: Optional[str] = Field( - default=None, - description="Optional target cryptocurrency for conversion (e.g. USDT).", - ) - HELEKET_NETWORK: Optional[str] = Field( - default=None, - description="Optional blockchain network code (e.g. tron, bsc).", - ) - HELEKET_RETURN_URL: Optional[str] = None - HELEKET_SUCCESS_URL: Optional[str] = None - HELEKET_LIFETIME_SECONDS: int = Field( - default=3600, - description="Invoice lifetime in seconds (300-43200).", - ) - HELEKET_VERIFY_WEBHOOK_SIGNATURE: bool = Field(default=True) - HELEKET_TRUSTED_IPS: str = Field( - default="31.133.220.8", - description="Comma-separated Heleket webhook IP allowlist.", - ) - YOOKASSA_ENABLED: bool = Field(default=True) STARS_ENABLED: bool = Field(default=True) PAYMENT_METHODS_ORDER: Optional[str] = Field( @@ -377,12 +337,6 @@ class Settings(BaseSettings): PAYMENT_CRYPTOPAY_TELEGRAM_LABEL_RU: Optional[str] = None PAYMENT_CRYPTOPAY_TELEGRAM_LABEL_EN: Optional[str] = None PAYMENT_CRYPTOPAY_TELEGRAM_EMOJI: Optional[str] = None - PAYMENT_HELEKET_WEBAPP_LABEL_RU: Optional[str] = None - PAYMENT_HELEKET_WEBAPP_LABEL_EN: Optional[str] = None - PAYMENT_HELEKET_WEBAPP_ICON: Optional[str] = None - PAYMENT_HELEKET_TELEGRAM_LABEL_RU: Optional[str] = None - PAYMENT_HELEKET_TELEGRAM_LABEL_EN: Optional[str] = None - PAYMENT_HELEKET_TELEGRAM_EMOJI: Optional[str] = None MONTH_1_ENABLED: bool = Field(default=True, alias="1_MONTH_ENABLED") MONTH_3_ENABLED: bool = Field(default=True, alias="3_MONTHS_ENABLED") @@ -668,18 +622,6 @@ class Settings(BaseSettings): cryptopay_network=self.CRYPTOPAY_NETWORK, cryptopay_currency_type=self.CRYPTOPAY_CURRENCY_TYPE, cryptopay_asset=self.CRYPTOPAY_ASSET, - heleket_enabled=self.HELEKET_ENABLED, - heleket_merchant_id=self.HELEKET_MERCHANT_ID, - heleket_api_key=self.HELEKET_API_KEY, - heleket_base_url=self.HELEKET_BASE_URL, - heleket_currency=self.HELEKET_CURRENCY, - heleket_to_currency=self.HELEKET_TO_CURRENCY, - heleket_network=self.HELEKET_NETWORK, - heleket_return_url=self.HELEKET_RETURN_URL, - heleket_success_url=self.HELEKET_SUCCESS_URL, - heleket_lifetime_seconds=self.HELEKET_LIFETIME_SECONDS, - heleket_verify_webhook_signature=self.HELEKET_VERIFY_WEBHOOK_SIGNATURE, - heleket_trusted_ips=self.heleket_trusted_ips, ) @computed_field @@ -880,24 +822,6 @@ class Settings(BaseSettings): def wata_trusted_ips(self) -> List[str]: return _split_csv(self.WATA_TRUSTED_IPS) - @computed_field - @property - def heleket_webhook_path(self) -> str: - return "/webhook/heleket" - - @computed_field - @property - def heleket_full_webhook_url(self) -> Optional[str]: - base = self.WEBHOOK_BASE_URL - if base: - return f"{base.rstrip('/')}{self.heleket_webhook_path}" - return None - - @computed_field - @property - def heleket_trusted_ips(self) -> List[str]: - return _split_csv(self.HELEKET_TRUSTED_IPS) - @computed_field @property def platega_webhook_path(self) -> str: @@ -1245,12 +1169,6 @@ class Settings(BaseSettings): "WATA_FAILED_URL", "WATA_API_TOKEN", "WATA_PUBLIC_KEY", - "HELEKET_MERCHANT_ID", - "HELEKET_API_KEY", - "HELEKET_RETURN_URL", - "HELEKET_SUCCESS_URL", - "HELEKET_TO_CURRENCY", - "HELEKET_NETWORK", "CRYPT4_REDIRECT_URL", "PRIVACY_POLICY_URL", "USER_AGREEMENT_URL", @@ -1293,17 +1211,6 @@ class Settings(BaseSettings): return 3 return min(30, max(1, value)) - @field_validator("HELEKET_LIFETIME_SECONDS", mode="before") - @classmethod - def validate_heleket_lifetime(cls, v): - if isinstance(v, str): - v = v.strip() - try: - value = int(v) - except (TypeError, ValueError): - return 3600 - return min(43200, max(300, value)) - # Notification types LOG_NEW_USERS: bool = Field( default=True, description="Send notifications for new user registrations" @@ -1400,14 +1307,6 @@ def get_settings() -> Settings: logging.warning( "CRITICAL: Wata is enabled but WATA_API_TOKEN is missing. Wata payments will not work." # noqa: E501 ) - if _settings_instance.HELEKET_ENABLED: - if ( - not _settings_instance.HELEKET_MERCHANT_ID - or not _settings_instance.HELEKET_API_KEY - ): - logging.warning( - "CRITICAL: Heleket is enabled but HELEKET_MERCHANT_ID or HELEKET_API_KEY is missing. Heleket payments will not work." # noqa: E501 - ) except ValidationError as e: logging.critical(f"Pydantic validation error while loading settings: {e}")