feat: enable payment provider for admin only

This commit is contained in:
3252a8
2026-05-25 11:19:54 +03:00
parent d30b069876
commit de1763cfe1
24 changed files with 509 additions and 39 deletions
@@ -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:
+6 -2
View File
@@ -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")
+9 -2
View File
@@ -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:
+12 -1
View File
@@ -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",
+104 -3
View File
@@ -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
+10 -1
View File
@@ -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
+11 -2
View File
@@ -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)
+10 -1
View File
@@ -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)
+37 -5
View File
@@ -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,
+44
View File
@@ -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
+10 -1
View File
@@ -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)
+7 -4
View File
@@ -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",
+13 -4
View File
@@ -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)
+42 -2
View File
@@ -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():
+6 -4
View File
@@ -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