refactor: provider env-config lives in modules (heleket poc)
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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}")
|
||||
|
||||
Reference in New Issue
Block a user