fix: make provider services reactive to runtime config changes

This commit is contained in:
3252a8
2026-05-18 21:53:53 +03:00
parent d870915dc0
commit b79f5f4d7d
8 changed files with 260 additions and 96 deletions
+30 -11
View File
@@ -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)
+24 -6
View File
@@ -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,
*,
+49 -14
View File
@@ -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,
*,
+45 -15
View File
@@ -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,
*,
+25 -7
View File
@@ -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}"
+38 -9
View File
@@ -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}",
+46 -33
View File
@@ -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,
+3 -1
View File
@@ -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):