feat: enable payment provider for admin only
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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(
|
||||
{
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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():
|
||||
|
||||
Reference in New Issue
Block a user