From b79f5f4d7dfa9994ef61a48e29c15fd50f849dad Mon Sep 17 00:00:00 2001 From: 3252a8 <3252a8@proton.me> Date: Mon, 18 May 2026 21:53:53 +0300 Subject: [PATCH] fix: make provider services reactive to runtime config changes --- backend/bot/payment_providers/cryptopay.py | 41 ++++++++--- backend/bot/payment_providers/freekassa.py | 30 ++++++-- backend/bot/payment_providers/heleket.py | 63 +++++++++++++---- backend/bot/payment_providers/platega.py | 60 ++++++++++++---- backend/bot/payment_providers/severpay.py | 32 +++++++-- backend/bot/payment_providers/wata.py | 47 ++++++++++--- backend/bot/payment_providers/yookassa.py | 79 +++++++++++++--------- tests/test_security.py | 4 +- 8 files changed, 260 insertions(+), 96 deletions(-) diff --git a/backend/bot/payment_providers/cryptopay.py b/backend/bot/payment_providers/cryptopay.py index f23a0be..64e747e 100644 --- a/backend/bot/payment_providers/cryptopay.py +++ b/backend/bot/payment_providers/cryptopay.py @@ -107,21 +107,40 @@ class CryptoPayService: self.async_session_factory = async_session_factory self.subscription_service = subscription_service self.referral_service = referral_service - self.token = config.TOKEN - if self.token: - net = Networks.TEST_NET if str(config.NETWORK).lower() == "testnet" else Networks.MAIN_NET - self.client = AioCryptoPay(token=self.token, network=net) - self.client.register_pay_handler(self._invoice_paid_handler) - self.configured = True - else: + self._client = None + self._client_token = None + self._client_network = None + if not self.config.TOKEN: logging.warning("CryptoPay token not provided. CryptoPay disabled") - self.client = None - self.configured = False + + @property + def token(self): + return self.config.TOKEN + + @property + def configured(self) -> bool: + return bool(self.config.ENABLED and self.config.TOKEN) + + @property + def client(self): + # Recreate the SDK client whenever the admin changes the token / network + # at runtime — otherwise we'd keep talking to the old account. + token = self.config.TOKEN + network = self.config.NETWORK + if not token: + return None + if self._client is None or token != self._client_token or network != self._client_network: + net = Networks.TEST_NET if str(network).lower() == "testnet" else Networks.MAIN_NET + self._client = AioCryptoPay(token=token, network=net) + self._client.register_pay_handler(self._invoice_paid_handler) + self._client_token = token + self._client_network = network + return self._client async def close(self): - if self.client: + if self._client: try: - await self.client.close() + await self._client.close() logging.info("CryptoPay client session closed.") except Exception as e: logging.warning("Failed to close CryptoPay client: %s", e) diff --git a/backend/bot/payment_providers/freekassa.py b/backend/bot/payment_providers/freekassa.py index 077c337..0e733a4 100644 --- a/backend/bot/payment_providers/freekassa.py +++ b/backend/bot/payment_providers/freekassa.py @@ -139,19 +139,13 @@ class FreeKassaService(HttpClientMixin): self.subscription_service = subscription_service self.referral_service = referral_service - self.shop_id: Optional[str] = config.MERCHANT_ID - self.api_key: Optional[str] = config.API_KEY - self.second_secret: Optional[str] = config.SECOND_SECRET self.default_currency: str = (settings.DEFAULT_CURRENCY_SYMBOL or "RUB").upper() - self.server_ip: Optional[str] = config.PAYMENT_IP - self.payment_method_id: Optional[int] = config.PAYMENT_METHOD_ID self.api_base_url: str = "https://api.fk.life/v1" self._init_http_client(total_timeout=15) self._nonce_lock = asyncio.Lock() self._last_nonce = int(time.time() * 1000) - self.configured: bool = bool(config.ENABLED and self.shop_id and self.api_key) if not self.configured: logging.warning( "FreeKassaService initialized but not fully configured. Payments disabled." @@ -161,6 +155,30 @@ class FreeKassaService(HttpClientMixin): "FreeKassaService: FREEKASSA_PAYMENT_IP is not set. Requests may be rejected by the provider." # noqa: E501 ) + @property + def configured(self) -> bool: + return bool(self.config.ENABLED and self.shop_id and self.api_key) + + @property + def shop_id(self): + return self.config.MERCHANT_ID + + @property + def api_key(self): + return self.config.API_KEY + + @property + def second_secret(self): + return self.config.SECOND_SECRET + + @property + def server_ip(self): + return self.config.PAYMENT_IP + + @property + def payment_method_id(self): + return self.config.PAYMENT_METHOD_ID + async def create_order( self, *, diff --git a/backend/bot/payment_providers/heleket.py b/backend/bot/payment_providers/heleket.py index 7d66f99..d27708d 100644 --- a/backend/bot/payment_providers/heleket.py +++ b/backend/bot/payment_providers/heleket.py @@ -171,27 +171,62 @@ class HeleketService(HttpClientMixin): self.async_session_factory = async_session_factory self.subscription_service = subscription_service self.referral_service = referral_service - - 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._default_return_url = default_return_url self._init_http_client(total_timeout=20) - self.configured: bool = bool( - config.ENABLED and self.merchant_id and self.api_key - ) if not self.configured: logging.warning( "HeleketService initialized but not fully configured. Payments disabled." ) + # All of the following are properties on top of the live ``self.config`` + # so admin UI changes (which mutate the config bundle) take effect without + # restarting the bot — otherwise ``configured`` would be frozen to the + # ``False`` state from startup and the button would never appear. + @property + def configured(self) -> bool: + return bool(self.config.ENABLED and self.merchant_id and self.api_key) + + @property + def base_url(self) -> str: + return (self.config.BASE_URL or "https://api.heleket.com").rstrip("/") + + @property + def merchant_id(self) -> str: + return self.config.MERCHANT_ID or "" + + @property + def api_key(self) -> str: + return self.config.API_KEY or "" + + @property + def currency(self) -> str: + return (self.config.CURRENCY or "RUB").upper() + + @property + def to_currency(self): + return (self.config.TO_CURRENCY or "").strip() or None + + @property + def network(self): + return (self.config.NETWORK or "").strip() or None + + @property + def return_url(self) -> str: + return self.config.RETURN_URL or f"https://t.me/{self._default_return_url}" + + @property + def success_url(self) -> str: + return self.config.SUCCESS_URL or self.return_url + + @property + def lifetime_seconds(self) -> int: + return self.config.LIFETIME_SECONDS + + @property + def verify_webhook_signature(self) -> bool: + return self.config.VERIFY_WEBHOOK_SIGNATURE + async def create_payment_link( self, *, diff --git a/backend/bot/payment_providers/platega.py b/backend/bot/payment_providers/platega.py index 1c198ed..27f425a 100644 --- a/backend/bot/payment_providers/platega.py +++ b/backend/bot/payment_providers/platega.py @@ -143,23 +143,9 @@ class PlategaService(HttpClientMixin): self.async_session_factory = async_session_factory self.subscription_service = subscription_service self.referral_service = referral_service - - self.base_url = (config.BASE_URL or "https://app.platega.io").rstrip("/") - self.merchant_id = config.MERCHANT_ID - self.secret = config.SECRET - self.payment_method = config.PAYMENT_METHOD - self.sbp_method = config.sbp_method_resolved - self.crypto_method = config.CRYPTO_METHOD - self.return_url = config.RETURN_URL or f"https://t.me/{default_return_url}" - self.failed_url = config.FAILED_URL or self.return_url + self._default_return_url = default_return_url self._init_http_client(total_timeout=20) - self._auth_headers = { - "X-MerchantId": self.merchant_id or "", - "X-Secret": self.secret or "", - "Content-Type": "application/json", - } - self.configured: bool = bool(config.ENABLED and self.merchant_id and self.secret) if not self.configured: logging.warning( "PlategaService initialized but not fully configured. Payments disabled." @@ -173,6 +159,50 @@ class PlategaService(HttpClientMixin): self.crypto_method, ) + @property + def configured(self) -> bool: + return bool(self.config.ENABLED and self.merchant_id and self.secret) + + @property + def base_url(self) -> str: + return (self.config.BASE_URL or "https://app.platega.io").rstrip("/") + + @property + def merchant_id(self): + return self.config.MERCHANT_ID + + @property + def secret(self): + return self.config.SECRET + + @property + def payment_method(self) -> int: + return self.config.PAYMENT_METHOD + + @property + def sbp_method(self) -> int: + return self.config.sbp_method_resolved + + @property + def crypto_method(self) -> int: + return self.config.CRYPTO_METHOD + + @property + def return_url(self) -> str: + return self.config.RETURN_URL or f"https://t.me/{self._default_return_url}" + + @property + def failed_url(self) -> str: + return self.config.FAILED_URL or self.return_url + + @property + def _auth_headers(self) -> dict: + return { + "X-MerchantId": self.merchant_id or "", + "X-Secret": self.secret or "", + "Content-Type": "application/json", + } + async def create_transaction( self, *, diff --git a/backend/bot/payment_providers/severpay.py b/backend/bot/payment_providers/severpay.py index 35c242c..ef19b33 100644 --- a/backend/bot/payment_providers/severpay.py +++ b/backend/bot/payment_providers/severpay.py @@ -124,21 +124,39 @@ class SeverPayService(HttpClientMixin): self.async_session_factory = async_session_factory self.subscription_service = subscription_service self.referral_service = referral_service - - self.base_url = (config.BASE_URL or "https://severpay.io/api/merchant").rstrip("/") - self.mid = config.MID - self.token = config.TOKEN or "" - self.return_url = config.RETURN_URL or f"https://t.me/{default_return_url}" - self.lifetime_minutes = config.LIFETIME_MINUTES + self._default_return_url = default_return_url self._init_http_client(total_timeout=15) - self.configured: bool = bool(config.ENABLED and self.mid and self.token) if not self.configured: logging.warning( "SeverPayService initialized but not fully configured. Payments disabled." ) + @property + def configured(self) -> bool: + return bool(self.config.ENABLED and self.mid and self.token) + + @property + def base_url(self) -> str: + return (self.config.BASE_URL or "https://severpay.io/api/merchant").rstrip("/") + + @property + def mid(self): + return self.config.MID + + @property + def token(self) -> str: + return self.config.TOKEN or "" + + @property + def return_url(self) -> str: + return self.config.RETURN_URL or f"https://t.me/{self._default_return_url}" + + @property + def lifetime_minutes(self): + return self.config.LIFETIME_MINUTES + @staticmethod def _format_amount(amount: float) -> str: return f"{format_decimal_amount(amount):.2f}" diff --git a/backend/bot/payment_providers/wata.py b/backend/bot/payment_providers/wata.py index 4c062c7..40cceec 100644 --- a/backend/bot/payment_providers/wata.py +++ b/backend/bot/payment_providers/wata.py @@ -138,20 +138,49 @@ class WataService(HttpClientMixin): self.async_session_factory = async_session_factory self.subscription_service = subscription_service self.referral_service = referral_service - - self.base_url = (config.BASE_URL or "https://api.wata.pro/api/h2h").rstrip("/") - self.api_token = config.API_TOKEN or "" - self.return_url = config.RETURN_URL or f"https://t.me/{default_return_url}" - self.failed_url = config.FAILED_URL or self.return_url - self.payment_link_ttl_days = config.PAYMENT_LINK_TTL_DAYS - self.verify_webhook_signature = config.WEBHOOK_VERIFY_SIGNATURE - self._public_key_pem = config.PUBLIC_KEY + self._default_return_url = default_return_url + self._cached_public_key_pem = None # populated by webhook on first verify self._init_http_client(total_timeout=20) - self.configured: bool = bool(config.ENABLED and self.api_token) if not self.configured: logging.warning("WataService initialized but not fully configured. Payments disabled.") + @property + def configured(self) -> bool: + return bool(self.config.ENABLED and self.api_token) + + @property + def base_url(self) -> str: + return (self.config.BASE_URL or "https://api.wata.pro/api/h2h").rstrip("/") + + @property + def api_token(self) -> str: + return self.config.API_TOKEN or "" + + @property + def return_url(self) -> str: + return self.config.RETURN_URL or f"https://t.me/{self._default_return_url}" + + @property + def failed_url(self) -> str: + return self.config.FAILED_URL or self.return_url + + @property + def payment_link_ttl_days(self) -> int: + return self.config.PAYMENT_LINK_TTL_DAYS + + @property + def verify_webhook_signature(self) -> bool: + return self.config.WEBHOOK_VERIFY_SIGNATURE + + @property + def _public_key_pem(self): + return self.config.PUBLIC_KEY or self._cached_public_key_pem + + @_public_key_pem.setter + def _public_key_pem(self, value): + self._cached_public_key_pem = value + def _auth_headers(self) -> Dict[str, str]: return { "Authorization": f"Bearer {self.api_token}", diff --git a/backend/bot/payment_providers/yookassa.py b/backend/bot/payment_providers/yookassa.py index 1ae666d..42b586c 100644 --- a/backend/bot/payment_providers/yookassa.py +++ b/backend/bot/payment_providers/yookassa.py @@ -145,41 +145,54 @@ class YooKassaService: self.settings = settings_obj self.config = config or YooKassaConfig() + self._bot_username_for_default_return = bot_username_for_default_return + self._configured_return_url_override = configured_return_url + self._sdk_configured_for = None # (shop_id, secret_key) currently loaded into the global SDK - if not self.config.ENABLED: - logging.warning( - "YooKassa is disabled via YOOKASSA_ENABLED flag. Payment functionality will be DISABLED." # noqa: E501 - ) - self.configured = False - elif not shop_id or not secret_key: - logging.warning( - "YooKassa SHOP_ID or SECRET_KEY not configured in settings. " - "Payment functionality will be DISABLED." - ) - self.configured = False - else: - try: - Configuration.configure(shop_id, secret_key) - self.configured = True - logging.info(f"YooKassa SDK configured for shop_id: {shop_id[:5]}...") - except Exception: - logging.exception("Failed to configure YooKassa SDK.") - self.configured = False + if not self.configured: + if not self.config.ENABLED: + logging.warning( + "YooKassa is disabled via YOOKASSA_ENABLED flag. Payment functionality will be DISABLED." # noqa: E501 + ) + else: + logging.warning( + "YooKassa SHOP_ID or SECRET_KEY not configured in settings. " + "Payment functionality will be DISABLED." + ) + logging.info("YooKassa Service effective return_url for payments: %s", self.return_url) - if configured_return_url: - self.return_url = configured_return_url - elif bot_username_for_default_return: - self.return_url = f"https://t.me/{bot_username_for_default_return}" - logging.info( - f"YOOKASSA_RETURN_URL not set, using dynamic default based on bot username: {self.return_url}" # noqa: E501 - ) - else: - self.return_url = "https://example.com/payment_error_no_return_url_configured" - logging.warning( - f"CRITICAL: YOOKASSA_RETURN_URL not set AND bot username not provided. " - f"Using placeholder: {self.return_url}. Payments may not complete correctly." - ) - logging.info(f"YooKassa Service effective return_url for payments: {self.return_url}") + @property + def configured(self) -> bool: + if not (self.config.ENABLED and self.config.SHOP_ID and self.config.SECRET_KEY): + return False + self._ensure_sdk_configured() + return self._sdk_configured_for is not None + + def _ensure_sdk_configured(self) -> None: + """Reconfigure the global YooKassa SDK if shop_id/secret_key changed at runtime.""" + shop_id = self.config.SHOP_ID + secret_key = self.config.SECRET_KEY + if not shop_id or not secret_key: + self._sdk_configured_for = None + return + if self._sdk_configured_for == (shop_id, secret_key): + return + try: + Configuration.configure(shop_id, secret_key) + self._sdk_configured_for = (shop_id, secret_key) + logging.info("YooKassa SDK (re)configured for shop_id: %s...", shop_id[:5]) + except Exception: + logging.exception("Failed to configure YooKassa SDK.") + self._sdk_configured_for = None + + @property + def return_url(self) -> str: + url = self._configured_return_url_override or self.config.RETURN_URL + if url: + return url + if self._bot_username_for_default_return: + return f"https://t.me/{self._bot_username_for_default_return}" + return "https://example.com/payment_error_no_return_url_configured" async def create_payment( self, diff --git a/tests/test_security.py b/tests/test_security.py index 0bb6985..2a3a986 100644 --- a/tests/test_security.py +++ b/tests/test_security.py @@ -117,8 +117,10 @@ class FreeKassaServiceTests(unittest.TestCase): class CryptoPayServiceTests(unittest.TestCase): def _make_service(self) -> CryptoPayService: + from bot.payment_providers.cryptopay import CryptoPayConfig + service = CryptoPayService.__new__(CryptoPayService) - service.token = "cryptopay-token" + service.config = CryptoPayConfig(TOKEN="cryptopay-token") return service def test_validate_webhook_signature_accepts_valid_signature(self):