diff --git a/backend/bot/app/web/admin_settings_manifest.py b/backend/bot/app/web/admin_settings_manifest.py index e5d3301..2cf93d8 100644 --- a/backend/bot/app/web/admin_settings_manifest.py +++ b/backend/bot/app/web/admin_settings_manifest.py @@ -261,6 +261,19 @@ SETTINGS_MANIFEST: List[SettingField] = [ # ─── Payment providers (toggles) ─────────────────────────────── # Common SettingField("STARS_ENABLED", "bool", "payments", "Telegram Stars", subsection="common"), + SettingField( + "STARS_ADMIN_ONLY_ENABLED", + "bool", + "payments", + "Telegram Stars admin-only", + ( + "Shows Telegram Stars only to users from ADMIN_IDS. " + "Payment callbacks remain active for admin test payments." + ), + subsection="common", + i18n_label_key="admin_settings_provider_admin_only_label", + i18n_description_key="admin_settings_provider_admin_only_description", + ), SettingField( "PAYMENT_METHODS_ORDER", "string", @@ -603,6 +616,7 @@ def manifest_payload() -> List[dict]: from bot.payment_providers import ( find_manifest_owner, manifest_field_default, + provider_admin_only_pairs, provider_webhook_metadata, ) @@ -618,6 +632,11 @@ def manifest_payload() -> List[dict]: "devices": 9, "subscription_guides": 10, } + exclusive_map = { + key: opposite + for public_key, admin_key in provider_admin_only_pairs() + for key, opposite in ((public_key, admin_key), (admin_key, public_key)) + } items: List[dict] = [] for field in aggregated_manifest(): auto_label_i18n_key = f"admin_settings_field_{field.key.lower()}_label" @@ -659,6 +678,8 @@ def manifest_payload() -> List[dict]: "optional": field.optional, "secret": field.secret, } + if field.key in exclusive_map: + item["mutually_exclusive_key"] = exclusive_map[field.key] if default_value is not None: item["default"] = default_value if webhook_metadata: diff --git a/backend/bot/app/web/webapp/billing.py b/backend/bot/app/web/webapp/billing.py index f937c73..6547bbc 100644 --- a/backend/bot/app/web/webapp/billing.py +++ b/backend/bot/app/web/webapp/billing.py @@ -253,6 +253,8 @@ async def create_payment_route(request: web.Request) -> web.Response: if not db_user or db_user.is_banned: return _json_error(403, "access_denied", "Access denied") lang = db_user.language_code or settings.DEFAULT_LANGUAGE + admin_ids = {int(item) for item in (settings.ADMIN_IDS or [])} + is_admin = bool(db_user.telegram_id and int(db_user.telegram_id) in admin_ids) return await _create_subscription_payment( request=request, session=session, @@ -264,6 +266,7 @@ async def create_payment_route(request: web.Request) -> web.Response: lang=lang, sale_mode=sale_mode, traffic_gb=traffic_gb_for_payment, + is_admin=is_admin, ) @@ -796,6 +799,7 @@ async def _create_subscription_payment( lang: str, sale_mode: str = "subscription", traffic_gb: Optional[float] = None, + is_admin: bool = False, ) -> web.Response: settings: Settings = request.app["settings"] sale_mode = str(sale_mode or "subscription") @@ -813,11 +817,11 @@ async def _create_subscription_payment( provider_spec = get_provider_spec(method) if provider_spec and provider_spec.create_webapp_payment: - if not provider_spec.is_visible(settings, request.app): + if not provider_spec.is_visible_for_user(settings, request.app, is_admin=is_admin): logger.warning( "WebApp payment method unavailable: method=%s enabled=%s configured=%s", method, - provider_spec.is_enabled(settings), + provider_spec.is_effectively_enabled(settings), provider_spec.is_service_configured(request.app), ) return _json_error(400, "payment_unavailable", "Payment method unavailable") diff --git a/backend/bot/app/web/webapp/serializers.py b/backend/bot/app/web/webapp/serializers.py index eeecbbd..6936e7b 100644 --- a/backend/bot/app/web/webapp/serializers.py +++ b/backend/bot/app/web/webapp/serializers.py @@ -118,7 +118,12 @@ async def _build_user_payload(request: web.Request, user_id: int) -> Dict[str, A traffic_packages=cached["traffic_packages"], stars_traffic_packages=cached["stars_traffic_packages"], ), - "payment_methods": _serialize_payment_methods(settings, request.app, lang), + "payment_methods": _serialize_payment_methods( + settings, + request.app, + lang, + is_admin=is_admin, + ), "themes_catalog": public_themes_catalog_payload( settings.webapp_themes_catalog, settings.WEBAPP_PRIMARY_COLOR or "#00fe7a", @@ -645,6 +650,8 @@ def _serialize_payment_methods( settings: Settings, app: web.Application, lang: str = "ru", + *, + is_admin: bool = False, ) -> List[Dict[str, Any]]: from bot.payment_providers import get_provider_spec, resolve_provider_presentation @@ -652,7 +659,7 @@ def _serialize_payment_methods( for method in settings.payment_methods_order: method = method.lower() spec = get_provider_spec(method) - if spec and spec.is_visible(settings, app): + if spec and spec.is_visible_for_user(settings, app, is_admin=is_admin): presentation = resolve_provider_presentation(spec, settings, language=lang) methods.append( { diff --git a/backend/bot/handlers/user/subscription/core.py b/backend/bot/handlers/user/subscription/core.py index d39278b..09de085 100644 --- a/backend/bot/handlers/user/subscription/core.py +++ b/backend/bot/handlers/user/subscription/core.py @@ -330,6 +330,7 @@ async def select_tariff_period_callback( settings, sale_mode=sale_mode_with_callback_context(f"subscription@{tariff.key}", callback_context), back_callback=f"tariff:select:{tariff.key}{callback_suffix_for_context(callback_context)}", + user_id=callback.from_user.id, ) await callback.message.edit_text(get_text("choose_payment_method"), reply_markup=markup) await callback.answer() @@ -382,6 +383,7 @@ async def select_tariff_package_callback( settings, sale_mode=sale_mode, back_callback=back_callback, + user_id=callback.from_user.id, ) await callback.message.edit_text(get_text("choose_payment_method_traffic"), reply_markup=markup) await callback.answer() @@ -494,6 +496,7 @@ async def select_tariff_premium_package_callback( settings, sale_mode=f"premium_topup@{tariff.key}", back_callback="tariff_topup:list", + user_id=callback.from_user.id, ) await callback.message.edit_text(get_text("choose_payment_method_traffic"), reply_markup=markup) await callback.answer() @@ -573,6 +576,7 @@ async def hwid_devices_package_callback( settings, sale_mode=f"hwid_devices@{tariff.key}", back_callback="hwid_devices:list", + user_id=callback.from_user.id, ) await callback.message.edit_text( get_text("choose_payment_method_hwid_devices"), reply_markup=markup @@ -843,6 +847,7 @@ async def tariff_change_pay_callback( settings, sale_mode=f"tariff_upgrade@{tariff_key}", back_callback=f"tariff_change:confirm_pay:{tariff_key}:{amount_raw}", + user_id=callback.from_user.id, ) await callback.message.edit_text("Выберите способ оплаты", reply_markup=markup) await callback.answer() diff --git a/backend/bot/handlers/user/subscription/payments_subscription.py b/backend/bot/handlers/user/subscription/payments_subscription.py index f1d96e9..3e2cd49 100644 --- a/backend/bot/handlers/user/subscription/payments_subscription.py +++ b/backend/bot/handlers/user/subscription/payments_subscription.py @@ -62,7 +62,12 @@ async def select_subscription_period_callback_handler( from bot.payment_providers import iter_provider_specs currency_methods_enabled = any( - spec.price_source != "stars" and spec.is_enabled(settings) + spec.price_source != "stars" + and spec.is_available_to_user( + settings, + user_id=callback.from_user.id, + require_configured=False, + ) for spec in iter_provider_specs() ) if currency_methods_enabled: @@ -104,6 +109,7 @@ async def select_subscription_period_callback_handler( "traffic" if traffic_mode else "subscription", callback_context ), back_callback=subscription_options_callback(callback_context), + user_id=callback.from_user.id, ) try: diff --git a/backend/bot/keyboards/inline/user_keyboards.py b/backend/bot/keyboards/inline/user_keyboards.py index 8823072..e9ed30d 100644 --- a/backend/bot/keyboards/inline/user_keyboards.py +++ b/backend/bot/keyboards/inline/user_keyboards.py @@ -451,6 +451,8 @@ def get_payment_method_keyboard( settings: Settings, sale_mode: str = "subscription", back_callback: Optional[str] = None, + user_id: Optional[int] = None, + is_admin: Optional[bool] = None, ) -> InlineKeyboardMarkup: _ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs) builder = InlineKeyboardBuilder() @@ -469,7 +471,16 @@ def get_payment_method_keyboard( for method in settings.payment_methods_order: spec = get_provider_spec(method) - if not spec or not spec.callback_prefix or not spec.is_enabled(settings): + if ( + not spec + or not spec.callback_prefix + or not spec.is_available_to_user( + settings, + user_id=user_id, + is_admin=is_admin, + require_configured=False, + ) + ): continue callback_data = spec.callback_data( value=value_str, diff --git a/backend/bot/payment_providers/__init__.py b/backend/bot/payment_providers/__init__.py index 5c39619..91466d1 100644 --- a/backend/bot/payment_providers/__init__.py +++ b/backend/bot/payment_providers/__init__.py @@ -22,6 +22,7 @@ from .registry import ( iter_unique_provider_routers, manifest_field_default, pending_statuses, + provider_admin_only_pairs, provider_emoji_map, provider_label_map, provider_telegram_button_text, @@ -53,6 +54,7 @@ __all__ = [ "pending_statuses", "provider_telegram_button_text", "provider_emoji_map", + "provider_admin_only_pairs", "provider_label_map", "provider_webhook_metadata", "resolve_provider_presentation", diff --git a/backend/bot/payment_providers/base.py b/backend/bot/payment_providers/base.py index 3002f43..e8e023b 100644 --- a/backend/bot/payment_providers/base.py +++ b/backend/bot/payment_providers/base.py @@ -28,6 +28,8 @@ class ProviderEnvConfig(BaseSettings): env vars it consumes — no edits in the global ``Settings`` required. """ + ADMIN_ONLY_ENABLED: bool = False + model_config = SettingsConfigDict( env_file=".env", env_file_encoding="utf-8", @@ -36,6 +38,15 @@ class ProviderEnvConfig(BaseSettings): ) +def provider_runtime_enabled(config: Any, *admin_only_attrs: str) -> bool: + """Return True when a provider should run for public or admin-only payments.""" + + if bool(getattr(config, "ENABLED", False)): + return True + attrs = admin_only_attrs or ("ADMIN_ONLY_ENABLED",) + return any(bool(getattr(config, attr, False)) for attr in attrs) + + @dataclass(frozen=True) class ProviderConfigBundle: """Functional config + presentation overrides for a single provider.""" @@ -68,6 +79,9 @@ class ProviderManifestField: attr: Optional[str] = ( None # attribute name on the target model; defaults to key without env_prefix ) + i18n_label_key: Optional[str] = None + i18n_description_key: Optional[str] = None + i18n_subsection_key: Optional[str] = None @dataclass(frozen=True) @@ -135,11 +149,23 @@ class PaymentProviderSpec: config_class: Optional[Type[ProviderEnvConfig]] = None presentation_class: Optional[Type[ProviderEnvConfig]] = None manifest_fields: Sequence[ProviderManifestField] = () + enabled_manifest_key: Optional[str] = None + admin_only_manifest_key: Optional[str] = None + admin_only_config_attr: str = "ADMIN_ONLY_ENABLED" + admin_only_enabled: Optional[EnabledPredicate] = None @property def settings_key(self) -> str: return self.id.upper() + @property + def enabled_field_key(self) -> str: + return self.enabled_manifest_key or f"{self.settings_key}_ENABLED" + + @property + def admin_only_field_key(self) -> str: + return self.admin_only_manifest_key or f"{self.settings_key}_ADMIN_ONLY_ENABLED" + @property def default_telegram_emoji(self) -> str: return self.telegram_emoji or self.emoji @@ -148,7 +174,7 @@ class PaymentProviderSpec: def method_ids(self) -> tuple[str, ...]: return (self.id, *tuple(self.aliases)) - def is_enabled(self, source: Any) -> bool: + def _predicate_value(self, predicate: EnabledPredicate, 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. @@ -157,8 +183,46 @@ class PaymentProviderSpec: 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)) + return bool(predicate(bundle.config)) + return bool(predicate(source)) + + def is_enabled(self, source: Any) -> bool: + return self._predicate_value(self.enabled, source) + + def is_admin_only_enabled(self, source: Any) -> bool: + if self.admin_only_enabled is not None: + return self._predicate_value(self.admin_only_enabled, source) + 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(getattr(bundle.config, self.admin_only_config_attr, False)) + return bool(getattr(source, self.admin_only_field_key, False)) + + def is_effectively_enabled(self, source: Any) -> bool: + return self.is_enabled(source) or self.is_admin_only_enabled(source) + + def _is_admin_user( + self, + source: Any, + *, + user_id: Optional[int] = None, + is_admin: Optional[bool] = None, + ) -> bool: + if is_admin is not None: + return bool(is_admin) + if user_id is None: + return False + try: + normalized_user_id = int(user_id) + except (TypeError, ValueError): + return False + try: + admin_ids = {int(item) for item in (getattr(source, "ADMIN_IDS", None) or [])} + except (TypeError, ValueError): + return False + return normalized_user_id in admin_ids def is_service_configured(self, app: Any) -> bool: if not self.requires_configured_service: @@ -171,6 +235,43 @@ class PaymentProviderSpec: def is_visible(self, source: Any, app: Any) -> bool: return self.is_enabled(source) and self.is_service_configured(app) + def is_available_to_user( + self, + source: Any, + app: Any = None, + *, + user_id: Optional[int] = None, + is_admin: Optional[bool] = None, + require_configured: bool = True, + ) -> bool: + public_enabled = self.is_enabled(source) + admin_only_visible = self.is_admin_only_enabled(source) and self._is_admin_user( + source, + user_id=user_id, + is_admin=is_admin, + ) + if not (public_enabled or admin_only_visible): + return False + if require_configured and app is not None and not self.is_service_configured(app): + return False + return True + + def is_visible_for_user( + self, + source: Any, + app: Any, + *, + user_id: Optional[int] = None, + is_admin: Optional[bool] = None, + ) -> bool: + return self.is_available_to_user( + source, + app, + user_id=user_id, + is_admin=is_admin, + require_configured=True, + ) + def load_router(self) -> Any: return self.router diff --git a/backend/bot/payment_providers/cryptopay.py b/backend/bot/payment_providers/cryptopay.py index e9f76c7..39922be 100644 --- a/backend/bot/payment_providers/cryptopay.py +++ b/backend/bot/payment_providers/cryptopay.py @@ -26,6 +26,7 @@ from .base import ( ServiceFactoryContext, WebAppPaymentContext, provider_env_file, + provider_runtime_enabled, ) from .shared import ( PaymentSuccessRequest, @@ -119,7 +120,7 @@ class CryptoPayService: @property def configured(self) -> bool: - return bool(self.config.ENABLED and self.config.TOKEN) + return bool(provider_runtime_enabled(self.config) and self.config.TOKEN) @property def client(self): @@ -349,6 +350,14 @@ async def pay_crypto_callback_handler( await notify_callback_parse_error(callback, translator) return + if not SPEC.is_available_to_user( + settings, + user_id=callback.from_user.id, + require_configured=False, + ): + await notify_service_unavailable(callback, translator) + return + if not cryptopay_service or not getattr(cryptopay_service, "configured", False): await notify_service_unavailable(callback, translator) return diff --git a/backend/bot/payment_providers/freekassa.py b/backend/bot/payment_providers/freekassa.py index f7c47d2..2df1fc0 100644 --- a/backend/bot/payment_providers/freekassa.py +++ b/backend/bot/payment_providers/freekassa.py @@ -29,6 +29,7 @@ from .base import ( ServiceFactoryContext, WebAppPaymentContext, provider_env_file, + provider_runtime_enabled, ) from .shared import ( HttpClientMixin, @@ -152,14 +153,14 @@ class FreeKassaService(HttpClientMixin): logging.warning( "FreeKassaService initialized but not fully configured. Payments disabled." ) - if config.ENABLED and not self.server_ip: + if provider_runtime_enabled(config) and not self.server_ip: logging.warning( "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) + return bool(provider_runtime_enabled(self.config) and self.shop_id and self.api_key) @property def shop_id(self): @@ -451,6 +452,14 @@ async def pay_fk_callback_handler( await notify_callback_parse_error(callback, translator) return + if not SPEC.is_available_to_user( + settings, + user_id=callback.from_user.id, + require_configured=False, + ): + await notify_service_unavailable(callback, translator) + return + if not freekassa_service or not freekassa_service.configured: logging.error("FreeKassa service is not configured or unavailable.") await notify_service_unavailable(callback, translator) diff --git a/backend/bot/payment_providers/heleket.py b/backend/bot/payment_providers/heleket.py index d3319a3..b681b63 100644 --- a/backend/bot/payment_providers/heleket.py +++ b/backend/bot/payment_providers/heleket.py @@ -27,6 +27,7 @@ from .base import ( ServiceFactoryContext, WebAppPaymentContext, provider_env_file, + provider_runtime_enabled, ) from .shared import ( HttpClientMixin, @@ -241,7 +242,7 @@ class HeleketService(HttpClientMixin): # ``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) + return bool(provider_runtime_enabled(self.config) and self.merchant_id and self.api_key) @property def base_url(self) -> str: @@ -546,6 +547,14 @@ async def pay_heleket_callback_handler( await notify_callback_parse_error(callback, translator) return + if not SPEC.is_available_to_user( + settings, + user_id=callback.from_user.id, + require_configured=False, + ): + await notify_service_unavailable(callback, translator) + return + if not heleket_service or not heleket_service.configured: logging.error("Heleket service is not configured or unavailable.") await notify_service_unavailable(callback, translator) diff --git a/backend/bot/payment_providers/platega.py b/backend/bot/payment_providers/platega.py index 013620b..59e9c4e 100644 --- a/backend/bot/payment_providers/platega.py +++ b/backend/bot/payment_providers/platega.py @@ -23,6 +23,7 @@ from .base import ( ServiceFactoryContext, WebAppPaymentContext, provider_env_file, + provider_runtime_enabled, ) from .shared import ( HttpClientMixin, @@ -66,7 +67,9 @@ class PlategaConfig(ProviderEnvConfig): SECRET: Optional[str] = None PAYMENT_METHOD: int = Field(default=2) SBP_ENABLED: bool = Field(default=False) + SBP_ADMIN_ONLY_ENABLED: bool = Field(default=False) CRYPTO_ENABLED: bool = Field(default=False) + CRYPTO_ADMIN_ONLY_ENABLED: bool = Field(default=False) SBP_METHOD: int = Field(default=2) CRYPTO_METHOD: int = Field(default=13) RETURN_URL: Optional[str] = None @@ -161,7 +164,15 @@ class PlategaService(HttpClientMixin): @property def configured(self) -> bool: - return bool(self.config.ENABLED and self.merchant_id and self.secret) + return bool( + provider_runtime_enabled( + self.config, + "SBP_ADMIN_ONLY_ENABLED", + "CRYPTO_ADMIN_ONLY_ENABLED", + ) + and self.merchant_id + and self.secret + ) @property def base_url(self) -> str: @@ -395,17 +406,23 @@ def _resolve_platega_variant( ) -> Optional[Tuple[str, int]]: """Map the callback prefix to (variant, payment_method_id) or ``None`` if disabled.""" if callback_prefix == "pay_platega_crypto": - if not config.CRYPTO_ENABLED: + if not (config.CRYPTO_ENABLED or config.CRYPTO_ADMIN_ONLY_ENABLED): return None return "crypto", config.CRYPTO_METHOD if callback_prefix == "pay_platega_sbp": - if not config.SBP_ENABLED: + if not (config.SBP_ENABLED or config.SBP_ADMIN_ONLY_ENABLED): return None return "sbp", config.sbp_method_resolved # Legacy "pay_platega:" callback — keep working as SBP. return "sbp", config.sbp_method_resolved +def _platega_spec_for_callback_prefix(callback_prefix: str) -> PaymentProviderSpec: + if callback_prefix == "pay_platega_crypto": + return CRYPTO_SPEC + return SBP_SPEC + + @router.callback_query( F.data.startswith("pay_platega_sbp:") | F.data.startswith("pay_platega_crypto:") @@ -429,6 +446,15 @@ async def pay_platega_callback_handler( return callback_prefix, _, _ = (callback.data or "").partition(":") + spec = _platega_spec_for_callback_prefix(callback_prefix) + if not spec.is_available_to_user( + settings, + user_id=callback.from_user.id, + require_configured=False, + ): + await notify_service_unavailable(callback, translator) + return + variant = ( _resolve_platega_variant(callback_prefix, platega_service.config) if platega_service @@ -538,11 +564,13 @@ async def _create_webapp_payment(ctx: WebAppPaymentContext, variant: str) -> web if not service or not service.configured: return payment_unavailable() if variant == "platega_crypto": - if not service.config.CRYPTO_ENABLED: + if not (service.config.CRYPTO_ENABLED or service.config.CRYPTO_ADMIN_ONLY_ENABLED): return payment_unavailable() platega_method_id = service.config.CRYPTO_METHOD else: - if variant == "platega_sbp" and not service.config.SBP_ENABLED: + if variant == "platega_sbp" and not ( + service.config.SBP_ENABLED or service.config.SBP_ADMIN_ONLY_ENABLED + ): return payment_unavailable() platega_method_id = service.config.sbp_method_resolved @@ -738,6 +766,8 @@ SBP_SPEC = PaymentProviderSpec( enabled=lambda config: bool( getattr(config, "ENABLED", False) and getattr(config, "SBP_ENABLED", False) ), + admin_only_enabled=lambda config: bool(getattr(config, "SBP_ADMIN_ONLY_ENABLED", False)), + admin_only_config_attr="SBP_ADMIN_ONLY_ENABLED", service_key="platega_service", callback_prefix="pay_platega_sbp", aliases=("platega",), @@ -767,6 +797,8 @@ CRYPTO_SPEC = PaymentProviderSpec( enabled=lambda config: bool( getattr(config, "ENABLED", False) and getattr(config, "CRYPTO_ENABLED", False) ), + admin_only_enabled=lambda config: bool(getattr(config, "CRYPTO_ADMIN_ONLY_ENABLED", False)), + admin_only_config_attr="CRYPTO_ADMIN_ONLY_ENABLED", service_key="platega_service", callback_prefix="pay_platega_crypto", create_webapp_payment=create_crypto_webapp_payment, diff --git a/backend/bot/payment_providers/registry.py b/backend/bot/payment_providers/registry.py index 194aa62..1767ca9 100644 --- a/backend/bot/payment_providers/registry.py +++ b/backend/bot/payment_providers/registry.py @@ -321,8 +321,13 @@ def pending_statuses() -> List[str]: 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: + emitted_keys: set[str] = set() for field in spec.manifest_fields: + emitted_keys.add(field.key) yield spec, field + admin_only_field = provider_admin_only_manifest_field(spec) + if admin_only_field is not None and admin_only_field.key not in emitted_keys: + yield spec, admin_only_field def find_manifest_owner(key: str) -> Optional[tuple[PaymentProviderSpec, ProviderManifestField]]: @@ -333,6 +338,45 @@ def find_manifest_owner(key: str) -> Optional[tuple[PaymentProviderSpec, Provide return None +def provider_admin_only_manifest_field( + spec: PaymentProviderSpec, +) -> Optional[ProviderManifestField]: + if spec.config_class is None: + return None + + subsection = spec.label + for field in spec.manifest_fields: + if field.subsection: + subsection = field.subsection + break + + return ProviderManifestField( + spec.admin_only_field_key, + "bool", + "Only for admins", + ( + "Shows this payment method only to users from ADMIN_IDS. " + "Webhooks and provider services remain active for admin test payments." + ), + subsection=subsection, + attr=spec.admin_only_config_attr, + i18n_label_key="admin_settings_provider_admin_only_label", + i18n_description_key="admin_settings_provider_admin_only_description", + ) + + +def provider_admin_only_pairs() -> List[tuple[str, str]]: + pairs: List[tuple[str, str]] = [] + seen: set[tuple[str, str]] = set() + for spec in PAYMENT_PROVIDER_SPECS: + pair = (spec.enabled_field_key, spec.admin_only_field_key) + if pair in seen: + continue + seen.add(pair) + pairs.append(pair) + return pairs + + def _webhook_spec_for(spec: PaymentProviderSpec) -> Optional[PaymentProviderSpec]: if spec.webhook_path and spec.webhook_route: return spec diff --git a/backend/bot/payment_providers/severpay.py b/backend/bot/payment_providers/severpay.py index 91c8241..2d28c65 100644 --- a/backend/bot/payment_providers/severpay.py +++ b/backend/bot/payment_providers/severpay.py @@ -25,6 +25,7 @@ from .base import ( ServiceFactoryContext, WebAppPaymentContext, provider_env_file, + provider_runtime_enabled, ) from .shared import ( HttpClientMixin, @@ -135,7 +136,7 @@ class SeverPayService(HttpClientMixin): @property def configured(self) -> bool: - return bool(self.config.ENABLED and self.mid and self.token) + return bool(provider_runtime_enabled(self.config) and self.mid and self.token) @property def base_url(self) -> str: @@ -395,6 +396,14 @@ async def pay_severpay_callback_handler( await notify_callback_parse_error(callback, translator) return + if not SPEC.is_available_to_user( + settings, + user_id=callback.from_user.id, + require_configured=False, + ): + await notify_service_unavailable(callback, translator) + return + if not severpay_service or not severpay_service.configured: logging.error("SeverPay service is not configured or unavailable.") await notify_service_unavailable(callback, translator) diff --git a/backend/bot/payment_providers/stars.py b/backend/bot/payment_providers/stars.py index cbe62a9..980f2e8 100644 --- a/backend/bot/payment_providers/stars.py +++ b/backend/bot/payment_providers/stars.py @@ -197,7 +197,11 @@ async def pay_stars_callback_handler( await notify_callback_parse_error(callback, translator) return - if not settings.STARS_ENABLED: + if not SPEC.is_available_to_user( + settings, + user_id=callback.from_user.id, + require_configured=False, + ): await notify_service_unavailable(callback, translator) return @@ -440,9 +444,8 @@ SPEC = PaymentProviderSpec( webapp_icon="Sparkles", telegram_labels={"ru": "Звёзды Telegram", "en": "Telegram Stars"}, pending_status="pending_stars", - # STARS_ENABLED stays on the global Settings — subscription_options reads - # it together with STARS_PRICE_* fields, so it has cross-cutting bizlogic - # reach beyond just the provider flag. + # Stars toggles stay on global Settings because stars_subscription_options + # reads them together with STARS_PRICE_* fields. enabled=lambda settings: bool(getattr(settings, "STARS_ENABLED", False)), service_key="stars_service", callback_prefix="pay_stars", diff --git a/backend/bot/payment_providers/wata.py b/backend/bot/payment_providers/wata.py index 35bdf1b..11fa2c5 100644 --- a/backend/bot/payment_providers/wata.py +++ b/backend/bot/payment_providers/wata.py @@ -28,6 +28,7 @@ from .base import ( ServiceFactoryContext, WebAppPaymentContext, provider_env_file, + provider_runtime_enabled, ) from .shared import ( HttpClientMixin, @@ -181,7 +182,7 @@ class WataService(HttpClientMixin): @property def configured(self) -> bool: - return bool(self.config.ENABLED and self.api_token) + return bool(provider_runtime_enabled(self.config) and self.api_token) @property def base_url(self) -> str: @@ -234,9 +235,9 @@ class WataService(HttpClientMixin): return False, {"message": "service_not_configured"} session = await self._get_session() - expires_at = (datetime.now(timezone.utc) + timedelta(days=self.payment_link_ttl_days)).replace( - microsecond=0 - ) + expires_at = ( + datetime.now(timezone.utc) + timedelta(days=self.payment_link_ttl_days) + ).replace(microsecond=0) body: Dict[str, Any] = { "amount": float(format_decimal_amount(amount)), "currency": (currency or self.settings.DEFAULT_CURRENCY_SYMBOL or "RUB").upper(), @@ -744,6 +745,14 @@ async def pay_wata_callback_handler( await notify_callback_parse_error(callback, translator) return + if not SPEC.is_available_to_user( + settings, + user_id=callback.from_user.id, + require_configured=False, + ): + await notify_service_unavailable(callback, translator) + return + if not wata_service or not wata_service.configured: logging.error("Wata service is not configured or unavailable.") await notify_service_unavailable(callback, translator) diff --git a/backend/bot/payment_providers/yookassa.py b/backend/bot/payment_providers/yookassa.py index dfff647..ffd7de5 100644 --- a/backend/bot/payment_providers/yookassa.py +++ b/backend/bot/payment_providers/yookassa.py @@ -49,6 +49,7 @@ from .base import ( ServiceFactoryContext, WebAppPaymentContext, provider_env_file, + provider_runtime_enabled, ) from .shared import ( SuccessMessage, @@ -151,7 +152,7 @@ class YooKassaService: ) if not self.configured: - if not self.config.ENABLED: + if not provider_runtime_enabled(self.config): logging.warning( "YooKassa is disabled via YOOKASSA_ENABLED flag. Payment functionality will be DISABLED." # noqa: E501 ) @@ -164,7 +165,11 @@ class YooKassaService: @property def configured(self) -> bool: - if not (self.config.ENABLED and self.config.SHOP_ID and self.config.SECRET_KEY): + if not ( + provider_runtime_enabled(self.config) + and self.config.SHOP_ID + and self.config.SECRET_KEY + ): return False self._ensure_sdk_configured() return self._sdk_configured_for is not None @@ -1554,6 +1559,29 @@ async def _initiate_yk_payment( return False +async def _yookassa_available_to_callback_user( + callback: types.CallbackQuery, + settings: Settings, + get_text, +) -> bool: + if SPEC.is_available_to_user( + settings, + user_id=callback.from_user.id, + require_configured=False, + ): + return True + try: + await callback.answer(get_text("payment_service_unavailable_alert"), show_alert=True) + except Exception: + pass + if callback.message: + try: + await callback.message.edit_text(get_text("payment_service_unavailable")) + except Exception: + pass + return False + + @router.callback_query(F.data.startswith("pay_yk:")) async def pay_yk_callback_handler( callback: types.CallbackQuery, @@ -1573,6 +1601,9 @@ async def pay_yk_callback_handler( pass return + if not await _yookassa_available_to_callback_user(callback, settings, get_text): + return + if not yookassa_service or not yookassa_service.configured: logging.error("YooKassa service is not configured or unavailable.") target_msg_edit = callback.message @@ -1705,6 +1736,9 @@ async def pay_yk_new_card_handler( pass return + if not await _yookassa_available_to_callback_user(callback, settings, get_text): + return + if not yookassa_service or not yookassa_service.configured: logging.error("YooKassa service unavailable for pay_yk_new.") try: @@ -1789,6 +1823,9 @@ async def pay_yk_saved_list_handler( pass return + if not await _yookassa_available_to_callback_user(callback, settings, get_text): + return + try: _, data_payload = callback.data.split(":", 1) except ValueError: @@ -1949,6 +1986,9 @@ async def pay_yk_use_saved_handler( pass return + if not await _yookassa_available_to_callback_user(callback, settings, get_text): + return + if not yookassa_service or not yookassa_service.configured: logging.error("YooKassa service unavailable for pay_yk_use_saved.") try: diff --git a/backend/bot/services/settings_override_service.py b/backend/bot/services/settings_override_service.py index ee74d24..9f2e650 100644 --- a/backend/bot/services/settings_override_service.py +++ b/backend/bot/services/settings_override_service.py @@ -127,6 +127,33 @@ def apply_overrides(settings: Settings, overrides: Dict[str, Any]) -> int: return applied +def _normalize_exclusive_provider_toggles( + updates: Dict[str, Any], + deletes: list, +) -> tuple[Dict[str, Any], list]: + """When a provider is enabled for admins only, turn off its public toggle.""" + + from bot.payment_providers import provider_admin_only_pairs + + exclusive_map = { + key: opposite + for public_key, admin_key in provider_admin_only_pairs() + for key, opposite in ((public_key, admin_key), (admin_key, public_key)) + } + if not exclusive_map: + return updates, deletes + + normalized = dict(updates) + normalized_deletes = list(deletes) + for key, value in updates.items(): + if value is not True or key not in exclusive_map: + continue + opposite = exclusive_map[key] + normalized[opposite] = False + normalized_deletes = [item for item in normalized_deletes if item != opposite] + return normalized, normalized_deletes + + def _appearance_snapshot(settings: Settings) -> Dict[str, Any]: snapshot: Dict[str, Any] = {} logo_url = getattr(settings, "WEBAPP_LOGO_URL", None) @@ -270,6 +297,11 @@ async def update_overrides( if errors: return {"ok": False, "errors": errors} + coerced_updates, valid_deletes = _normalize_exclusive_provider_toggles( + coerced_updates, + valid_deletes, + ) + async with async_session_factory() as session: # type: AsyncSession async with session.begin(): for key, value in coerced_updates.items(): diff --git a/backend/config/settings.py b/backend/config/settings.py index 74192b8..e0042e2 100644 --- a/backend/config/settings.py +++ b/backend/config/settings.py @@ -164,6 +164,7 @@ class Settings(BaseSettings): ) STARS_ENABLED: bool = Field(default=True) + STARS_ADMIN_ONLY_ENABLED: bool = Field(default=False) PAYMENT_METHODS_ORDER: Optional[str] = Field( default=None, description="Comma-separated list of payment methods to show (e.g., severpay,wata,freekassa,yookassa,platega,stars,cryptopay)", # noqa: E501 @@ -612,13 +613,14 @@ class Settings(BaseSettings): @property def stars_subscription_options(self) -> Dict[int, int]: options: Dict[int, int] = {} - if self.STARS_ENABLED and self.MONTH_1_ENABLED and self.STARS_PRICE_1_MONTH is not None: + stars_enabled = self.STARS_ENABLED or self.STARS_ADMIN_ONLY_ENABLED + if stars_enabled and self.MONTH_1_ENABLED and self.STARS_PRICE_1_MONTH is not None: options[1] = self.STARS_PRICE_1_MONTH - if self.STARS_ENABLED and self.MONTH_3_ENABLED and self.STARS_PRICE_3_MONTHS is not None: + if stars_enabled and self.MONTH_3_ENABLED and self.STARS_PRICE_3_MONTHS is not None: options[3] = self.STARS_PRICE_3_MONTHS - if self.STARS_ENABLED and self.MONTH_6_ENABLED and self.STARS_PRICE_6_MONTHS is not None: + if stars_enabled and self.MONTH_6_ENABLED and self.STARS_PRICE_6_MONTHS is not None: options[6] = self.STARS_PRICE_6_MONTHS - if self.STARS_ENABLED and self.MONTH_12_ENABLED and self.STARS_PRICE_12_MONTHS is not None: + if stars_enabled and self.MONTH_12_ENABLED and self.STARS_PRICE_12_MONTHS is not None: options[12] = self.STARS_PRICE_12_MONTHS return options diff --git a/frontend/src/admin/sections/SettingsSection.svelte b/frontend/src/admin/sections/SettingsSection.svelte index 9524ecf..499ba5f 100644 --- a/frontend/src/admin/sections/SettingsSection.svelte +++ b/frontend/src/admin/sections/SettingsSection.svelte @@ -42,8 +42,16 @@ let copiedWebhookKey = ""; let copiedWebhookTimer = null; - const PLATEGA_SBP_KEYS = new Set(["PLATEGA_SBP_ENABLED", "PLATEGA_SBP_METHOD"]); - const PLATEGA_CRYPTO_KEYS = new Set(["PLATEGA_CRYPTO_ENABLED", "PLATEGA_CRYPTO_METHOD"]); + const PLATEGA_SBP_KEYS = new Set([ + "PLATEGA_SBP_ENABLED", + "PLATEGA_SBP_ADMIN_ONLY_ENABLED", + "PLATEGA_SBP_METHOD", + ]); + const PLATEGA_CRYPTO_KEYS = new Set([ + "PLATEGA_CRYPTO_ENABLED", + "PLATEGA_CRYPTO_ADMIN_ONLY_ENABLED", + "PLATEGA_CRYPTO_METHOD", + ]); const PLATEGA_LEGACY_KEYS = new Set(["PLATEGA_PAYMENT_METHOD"]); const SEMANTIC_FIELD_GROUP_ORDER = { platega_common: 1, @@ -389,6 +397,13 @@ : choice.label, })); } + + function setBoolField(field, checked) { + settingsStore.markDirty(field.key, checked); + if (checked && field.mutually_exclusive_key) { + settingsStore.markDirty(field.mutually_exclusive_key, false); + } + } {#snippet renderWebhookHint(webhook)} @@ -484,7 +499,7 @@
settingsStore.markDirty(field.key, checked)} + onCheckedChange={(checked) => setBoolField(field, checked)} class="admin-switch-root" > diff --git a/locales/en.json b/locales/en.json index 5c75a2c..b510a3a 100644 --- a/locales/en.json +++ b/locales/en.json @@ -1007,6 +1007,8 @@ "admin_settings_provider_webhook_url": "Webhook URL", "admin_settings_provider_webhook_url_hint": "Use this URL in the provider webhook settings.", "admin_settings_provider_webhook_base_missing": "Set WEBHOOK_BASE_URL in .env to show the full URL for {path}.", + "admin_settings_provider_admin_only_label": "Only for admins", + "admin_settings_provider_admin_only_description": "Shows this provider only to admins. Webhooks and payment status handling remain active for test payments.", "admin_copy": "Copy", "admin_copied": "Copied", "admin_settings_validation_errors": "Errors: {errors}", diff --git a/locales/ru.json b/locales/ru.json index 78ea13c..10d5347 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -1007,6 +1007,8 @@ "admin_settings_provider_webhook_url": "Webhook URL", "admin_settings_provider_webhook_url_hint": "Укажите этот адрес в настройках вебхуков провайдера.", "admin_settings_provider_webhook_base_missing": "Укажите WEBHOOK_BASE_URL в .env, чтобы увидеть полный адрес для {path}.", + "admin_settings_provider_admin_only_label": "Только для админов", + "admin_settings_provider_admin_only_description": "Показывает провайдер только администраторам. Вебхуки и обработка статусов остаются активными для тестовых платежей.", "admin_copy": "Копировать", "admin_copied": "Скопировано", "admin_settings_validation_errors": "Ошибки: {errors}", diff --git a/tests/test_admin_settings_manifest_i18n.py b/tests/test_admin_settings_manifest_i18n.py index 2aee5b7..246412b 100644 --- a/tests/test_admin_settings_manifest_i18n.py +++ b/tests/test_admin_settings_manifest_i18n.py @@ -176,6 +176,20 @@ def test_payment_provider_settings_include_webhook_metadata(): assert "webhook_path" not in manifest["PAYMENT_STARS_WEBAPP_LABEL_RU"] +def test_payment_provider_admin_only_toggles_are_mutually_exclusive(): + manifest = _manifest_by_key() + + assert manifest["WATA_ADMIN_ONLY_ENABLED"]["mutually_exclusive_key"] == "WATA_ENABLED" + assert manifest["WATA_ENABLED"]["mutually_exclusive_key"] == "WATA_ADMIN_ONLY_ENABLED" + assert ( + manifest["PLATEGA_CRYPTO_ADMIN_ONLY_ENABLED"]["mutually_exclusive_key"] + == "PLATEGA_CRYPTO_ENABLED" + ) + assert ( + manifest["STARS_ADMIN_ONLY_ENABLED"]["mutually_exclusive_key"] == "STARS_ENABLED" + ) + + def test_legacy_tariff_settings_are_separated_from_payment_settings(): manifest = _manifest_by_key() payment_method_fields = [ diff --git a/tests/test_payment_provider_registry.py b/tests/test_payment_provider_registry.py index 50eca55..6ea37e4 100644 --- a/tests/test_payment_provider_registry.py +++ b/tests/test_payment_provider_registry.py @@ -4,10 +4,12 @@ from types import SimpleNamespace from bot.keyboards.inline.user_keyboards import get_payment_method_keyboard from bot.payment_providers import ( + build_provider_configs, get_provider_spec, iter_provider_specs, iter_service_keys, pending_statuses, + provider_admin_only_pairs, provider_emoji_map, provider_label_map, provider_telegram_button_text, @@ -193,8 +195,6 @@ def test_provider_presentation_ignores_cross_language_override(): def test_payment_method_keyboard_uses_custom_telegram_text_without_changing_callback(monkeypatch): - from bot.payment_providers import build_provider_configs - monkeypatch.setenv("WATA_ENABLED", "True") monkeypatch.setenv("PAYMENT_WATA_TELEGRAM_LABEL_EN", "Wata custom") monkeypatch.setenv("PAYMENT_WATA_TELEGRAM_EMOJI", "💸") @@ -225,6 +225,84 @@ def test_payment_method_keyboard_uses_custom_telegram_text_without_changing_call assert button.callback_data == "pay_wata:1:150:subscription" +def test_admin_only_provider_is_visible_only_to_admins(monkeypatch): + from bot.app.web.webapp.serializers import _serialize_payment_methods + + monkeypatch.setenv("WATA_ENABLED", "False") + monkeypatch.setenv("WATA_ADMIN_ONLY_ENABLED", "True") + build_provider_configs(force=True) + + settings = Settings( + _env_file=None, + BOT_TOKEN="token", + POSTGRES_USER="app_user", + POSTGRES_PASSWORD="app_password", + TARIFFS_CONFIG_PATH="missing-tariffs.json", + PAYMENT_METHODS_ORDER="wata", + ADMIN_IDS="42", + STARS_ENABLED=False, + ) + i18n = SimpleNamespace(gettext=lambda _lang, key, **_kwargs: key) + app = {"wata_service": SimpleNamespace(configured=True)} + spec = get_provider_spec("wata") + + regular_markup = get_payment_method_keyboard( + months=1, + price=150, + stars_price=None, + currency_symbol_val="RUB", + lang="en", + i18n_instance=i18n, + settings=settings, + user_id=7, + ) + admin_markup = get_payment_method_keyboard( + months=1, + price=150, + stars_price=None, + currency_symbol_val="RUB", + lang="en", + i18n_instance=i18n, + settings=settings, + user_id=42, + ) + + assert spec is not None + assert not spec.is_visible(settings, app) + assert not spec.is_visible_for_user(settings, app, is_admin=False) + assert spec.is_visible_for_user(settings, app, is_admin=True) + assert all( + not button.callback_data.startswith("pay_wata:") + for row in regular_markup.inline_keyboard + for button in row + ) + assert admin_markup.inline_keyboard[0][0].callback_data == "pay_wata:1:150:subscription" + assert _serialize_payment_methods(settings, app, "en", is_admin=False) == [] + assert _serialize_payment_methods(settings, app, "en", is_admin=True)[0]["id"] == "wata" + + +def test_admin_only_provider_toggle_pairs_are_declared(): + pairs = set(provider_admin_only_pairs()) + + assert ("WATA_ENABLED", "WATA_ADMIN_ONLY_ENABLED") in pairs + assert ("PLATEGA_SBP_ENABLED", "PLATEGA_SBP_ADMIN_ONLY_ENABLED") in pairs + assert ("PLATEGA_CRYPTO_ENABLED", "PLATEGA_CRYPTO_ADMIN_ONLY_ENABLED") in pairs + assert ("STARS_ENABLED", "STARS_ADMIN_ONLY_ENABLED") in pairs + + +def test_admin_only_provider_toggle_normalization_disables_public_pair(): + from bot.services.settings_override_service import _normalize_exclusive_provider_toggles + + updates, deletes = _normalize_exclusive_provider_toggles( + {"WATA_ADMIN_ONLY_ENABLED": True}, + ["WATA_ENABLED"], + ) + + assert updates["WATA_ADMIN_ONLY_ENABLED"] is True + assert updates["WATA_ENABLED"] is False + assert deletes == [] + + def test_provider_callbacks_are_built_from_specs(): wata = get_provider_spec("wata") stars = get_provider_spec("stars") @@ -260,7 +338,11 @@ def test_provider_callbacks_are_built_from_specs(): ) -def test_provider_visibility_uses_service_configuration(): +def test_provider_visibility_uses_service_configuration(monkeypatch): + monkeypatch.setenv("WATA_ENABLED", "True") + monkeypatch.delenv("WATA_ADMIN_ONLY_ENABLED", raising=False) + build_provider_configs(force=True) + spec = get_provider_spec("wata") assert spec is not None