refactor: provider env-config lives in modules (heleket poc)

This commit is contained in:
3252a8
2026-05-18 16:03:01 +03:00
parent 07b35036e7
commit 87efa6c77b
8 changed files with 473 additions and 141 deletions
+7 -1
View File
@@ -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(
+32 -3
View File
@@ -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 = {
+16
View File
@@ -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",
+74 -6
View File
@@ -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
+171 -20
View File
@@ -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,
)
+108 -8
View File
@@ -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_<ID>_<suffix>`` env name, but without the
``PAYMENT_<ID>_`` 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_<ID>_<suffix>_<LANG> 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
@@ -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))
-101
View File
@@ -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}")