_TELEGRAM_EMOJI` | Необязательная мультиязычная кастомизация текста и эмодзи кнопки оплаты в Telegram-боте. |
| `YOOKASSA_ENABLED` | Включает YooKassa. |
| `YOOKASSA_SHOP_ID` / `YOOKASSA_SECRET_KEY` | Данные магазина YooKassa. |
| `YOOKASSA_RETURN_URL` | URL возврата пользователя после оплаты. |
diff --git a/frontend/src/admin/sections/SettingsSection.svelte b/frontend/src/admin/sections/SettingsSection.svelte
index a26d1a5..10cf3fa 100644
--- a/frontend/src/admin/sections/SettingsSection.svelte
+++ b/frontend/src/admin/sections/SettingsSection.svelte
@@ -1,6 +1,8 @@
{#each methods as method}
- {@const meta = methodMeta(method)}
+ {@const icon = methodIcon(method)}
{/each}
diff --git a/frontend/src/lib/components/ui/icons.js b/frontend/src/lib/components/ui/icons.js
index 21f1142..18ea026 100644
--- a/frontend/src/lib/components/ui/icons.js
+++ b/frontend/src/lib/components/ui/icons.js
@@ -42,6 +42,7 @@ export {
RefreshCw,
Repeat2,
Save,
+ Search,
Send,
Server,
Settings,
diff --git a/frontend/src/lib/webapp/previewMock.js b/frontend/src/lib/webapp/previewMock.js
index c9609b9..01d624a 100644
--- a/frontend/src/lib/webapp/previewMock.js
+++ b/frontend/src/lib/webapp/previewMock.js
@@ -162,10 +162,10 @@ export const DEV_MOCK = {
{ months: 12, price: 2690, currency: "RUB", title: "12 месяцев" },
],
payment_methods: [
- { id: "yookassa", name: "Карта" },
- { id: "platega_sbp", name: "Telegram Pay" },
- { id: "cryptopay", name: "Криптовалюта" },
- { id: "freekassa", name: "Другие способы" },
+ { id: "yookassa", name: "Карта", icon: "CreditCard" },
+ { id: "platega_sbp", name: "Telegram Pay", icon: "CreditCard" },
+ { id: "cryptopay", name: "Криптовалюта", icon: "Bitcoin" },
+ { id: "freekassa", name: "Другие способы", icon: "Smartphone" },
],
referral: {
code: "ABCD1234",
diff --git a/frontend/src/styles/admin.css b/frontend/src/styles/admin.css
index df35dd5..6353e47 100644
--- a/frontend/src/styles/admin.css
+++ b/frontend/src/styles/admin.css
@@ -1880,6 +1880,81 @@
min-width: 0;
}
+.admin-icon-picker-trigger {
+ min-width: 180px;
+ justify-content: flex-start;
+}
+
+.admin-icon-picker-trigger span {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.admin-icon-picker-dialog {
+ width: min(100%, 720px);
+}
+
+.admin-icon-picker-body {
+ display: grid;
+ gap: 14px;
+}
+
+.admin-icon-picker-search {
+ position: relative;
+ display: block;
+}
+
+.admin-icon-picker-search > svg {
+ position: absolute;
+ left: 12px;
+ top: 50%;
+ transform: translateY(-50%);
+ color: var(--admin-muted);
+ pointer-events: none;
+}
+
+.admin-icon-picker-search .input {
+ width: 100%;
+ padding-left: 36px;
+}
+
+.admin-icon-picker-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(128px, 1fr));
+ gap: 8px;
+ max-height: min(52vh, 460px);
+ overflow-y: auto;
+ padding-right: 4px;
+}
+
+.admin-icon-picker-option {
+ display: grid;
+ grid-template-columns: 20px minmax(0, 1fr);
+ align-items: center;
+ gap: 8px;
+ min-height: 42px;
+ border: 1px solid var(--admin-border);
+ border-radius: 8px;
+ background: var(--surface-muted);
+ color: var(--admin-text);
+ padding: 9px 10px;
+ text-align: left;
+ cursor: pointer;
+}
+
+.admin-icon-picker-option span {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.admin-icon-picker-option:hover,
+.admin-icon-picker-option.active {
+ border-color: color-mix(in srgb, var(--accent) 45%, var(--admin-border));
+ background: color-mix(in srgb, var(--accent) 12%, var(--surface-muted));
+}
+
.admin-setting-control .admin-color {
width: 36px;
height: 36px;
diff --git a/tests/test_auto_renew_wiring.py b/tests/test_auto_renew_wiring.py
index a875e1a..8cd98b4 100644
--- a/tests/test_auto_renew_wiring.py
+++ b/tests/test_auto_renew_wiring.py
@@ -9,7 +9,7 @@ Prior to the fix, ``charge_subscription_renewal`` did::
yk = None
There is no ``yookassa_service`` module inside ``subscription_service_impl``
-(the real one lives in ``bot.services.yookassa_service``), so the import
+(the real implementation lives in ``bot.payment_providers.yookassa``), so the import
always raised ``ModuleNotFoundError``, ``yk`` became ``None``, and every
auto-renew silently logged ``YooKassa unavailable for auto-renew`` and
returned False — even though ``build_core_services`` had wired a real
diff --git a/tests/test_build_services_wiring.py b/tests/test_build_services_wiring.py
index f7703ed..a987d69 100644
--- a/tests/test_build_services_wiring.py
+++ b/tests/test_build_services_wiring.py
@@ -20,9 +20,9 @@ from typing import Any
from unittest.mock import MagicMock
from bot.app.factories.build_services import build_core_services
+from bot.payment_providers.yookassa import YooKassaService
from bot.services.panel_webhook_service import PanelWebhookService
from bot.services.subscription_service import SubscriptionService
-from bot.services.yookassa_service import YooKassaService
from config.settings import Settings
diff --git a/tests/test_payment_provider_registry.py b/tests/test_payment_provider_registry.py
new file mode 100644
index 0000000..3e4aed2
--- /dev/null
+++ b/tests/test_payment_provider_registry.py
@@ -0,0 +1,291 @@
+import importlib
+from pathlib import Path
+from types import SimpleNamespace
+
+from bot.keyboards.inline.user_keyboards import get_payment_method_keyboard
+from bot.payment_providers import (
+ get_provider_spec,
+ iter_provider_specs,
+ iter_service_keys,
+ pending_statuses,
+ provider_emoji_map,
+ provider_label_map,
+ provider_telegram_button_text,
+ resolve_provider_presentation,
+)
+from bot.payment_providers.shared import (
+ format_number_for_payload,
+ payment_record_amounts,
+ sale_mode_base,
+ sale_mode_is_hwid_devices,
+ sale_mode_is_traffic,
+ sale_mode_tariff_key,
+)
+from config.settings import Settings
+
+_LEGACY_PROVIDER_FILES = [
+ "backend/bot/services/yookassa_service.py",
+ "backend/bot/services/freekassa_service.py",
+ "backend/bot/services/platega_service.py",
+ "backend/bot/services/severpay_service.py",
+ "backend/bot/services/crypto_pay_service.py",
+ "backend/bot/services/stars_service.py",
+ "backend/bot/services/wata_service.py",
+ "backend/bot/handlers/user/payment.py",
+ "backend/bot/handlers/user/subscription/payment_methods.py",
+ "backend/bot/handlers/user/subscription/payments_yookassa.py",
+ "backend/bot/handlers/user/subscription/payments_freekassa.py",
+ "backend/bot/handlers/user/subscription/payments_platega.py",
+ "backend/bot/handlers/user/subscription/payments_severpay.py",
+ "backend/bot/handlers/user/subscription/payments_crypto.py",
+ "backend/bot/handlers/user/subscription/payments_stars.py",
+ "backend/bot/handlers/user/subscription/payments_wata.py",
+]
+
+_PROVIDER_MODULES = {
+ "yookassa": "YooKassaService",
+ "freekassa": "FreeKassaService",
+ "platega": "PlategaService",
+ "severpay": "SeverPayService",
+ "cryptopay": "CryptoPayService",
+ "stars": "StarsService",
+ "wata": "WataService",
+}
+
+
+def test_legacy_provider_integration_files_are_removed():
+ repo_root = Path(__file__).resolve().parents[1]
+
+ for relative_path in _LEGACY_PROVIDER_FILES:
+ assert not (repo_root / relative_path).exists()
+
+
+def test_every_provider_module_owns_its_service_and_spec():
+ for module_name, service_class_name in _PROVIDER_MODULES.items():
+ module = importlib.import_module(f"bot.payment_providers.{module_name}")
+
+ assert hasattr(module, service_class_name)
+ assert hasattr(module, "SPEC") or hasattr(module, "SPECS")
+
+
+def test_wata_is_registered_as_single_provider_module():
+ spec = get_provider_spec("wata")
+
+ assert spec is not None
+ assert spec.service_key == "wata_service"
+ assert spec.pending_status == "pending_wata"
+ assert spec.button_text_key == "pay_with_wata_button"
+ assert spec.callback_prefix == "pay_wata"
+ assert spec.router is not None
+ assert spec.create_service is not None
+ assert spec.webhook_route is not None
+ assert spec.create_webapp_payment is not None
+
+
+def test_yookassa_provider_keeps_autorenew_entrypoints_local():
+ yookassa = importlib.import_module("bot.payment_providers.yookassa")
+ spec = get_provider_spec("yookassa")
+
+ assert spec is not None
+ assert spec.service_key == "yookassa_service"
+ assert spec.create_service is not None
+ assert spec.webhook_route is yookassa.yookassa_webhook_route
+ assert yookassa.payment_processing_lock is not None
+ assert callable(yookassa.process_successful_payment)
+ assert callable(yookassa.process_cancelled_payment)
+ assert callable(yookassa.yookassa_webhook_route)
+
+
+def test_every_payment_method_has_registry_driven_webapp_creator():
+ missing = [
+ spec.id
+ for spec in iter_provider_specs()
+ if spec.create_webapp_payment is None
+ ]
+
+ assert missing == []
+
+
+def test_service_keys_and_statuses_come_from_provider_specs():
+ assert set(iter_service_keys()) == {
+ "yookassa_service",
+ "freekassa_service",
+ "platega_service",
+ "severpay_service",
+ "wata_service",
+ "stars_service",
+ "cryptopay_service",
+ }
+ assert set(pending_statuses()) >= {
+ "pending",
+ "pending_yookassa",
+ "pending_freekassa",
+ "pending_platega",
+ "pending_severpay",
+ "pending_wata",
+ "pending_cryptopay",
+ "pending_stars",
+ }
+
+
+def test_provider_labels_and_emojis_include_storage_keys_and_method_aliases():
+ labels = provider_label_map()
+ emojis = provider_emoji_map()
+
+ assert labels["wata"] == "Wata"
+ assert labels["telegram_stars"] == "Telegram Stars"
+ assert labels["stars"] == "Telegram Stars"
+ assert labels["platega"] == "Platega"
+ assert labels["platega_sbp"] == "Platega"
+ assert labels["platega_crypto"] == "Platega"
+ assert emojis["stars"] == get_provider_spec("stars").default_telegram_emoji
+ assert emojis["telegram_stars"] == get_provider_spec("stars").default_telegram_emoji
+ assert emojis["cryptopay"] == get_provider_spec("cryptopay").default_telegram_emoji
+
+
+def test_provider_presentation_resolves_defaults_and_overrides():
+ spec = get_provider_spec("yookassa")
+ assert spec is not None
+
+ default = resolve_provider_presentation(spec)
+ assert default.webapp_label == spec.webapp_label
+ assert default.webapp_icon == "CreditCard"
+ assert default.telegram_label == spec.telegram_labels["ru"]
+ assert default.telegram_emoji == spec.default_telegram_emoji
+ assert not default.telegram_customized
+
+ settings = SimpleNamespace(
+ PAYMENT_YOOKASSA_WEBAPP_LABEL_EN="Card in app",
+ PAYMENT_YOOKASSA_WEBAPP_ICON="WalletCards",
+ PAYMENT_YOOKASSA_TELEGRAM_LABEL_EN="Card in bot",
+ PAYMENT_YOOKASSA_TELEGRAM_EMOJI="💸",
+ )
+ custom = resolve_provider_presentation(spec, settings, language="en")
+ assert custom.webapp_label == "Card in app"
+ assert custom.webapp_icon == "WalletCards"
+ assert custom.telegram_label == "Card in bot"
+ assert custom.telegram_emoji == "💸"
+ assert custom.telegram_customized
+
+
+def test_provider_telegram_button_text_uses_provider_defaults_until_customized():
+ spec = get_provider_spec("wata")
+ assert spec is not None
+
+ translate = lambda key: f"i18n:{key}"
+ assert (
+ provider_telegram_button_text(spec, SimpleNamespace(), translate, language="en")
+ == f"{spec.default_telegram_emoji} Wata"
+ )
+
+ settings = SimpleNamespace(PAYMENT_WATA_TELEGRAM_LABEL_EN="Pay Wata")
+ assert (
+ provider_telegram_button_text(spec, settings, translate, language="en")
+ == f"{spec.default_telegram_emoji} Pay Wata"
+ )
+
+
+def test_provider_presentation_ignores_cross_language_override():
+ spec = get_provider_spec("yookassa")
+ assert spec is not None
+
+ settings = SimpleNamespace(PAYMENT_YOOKASSA_WEBAPP_LABEL_RU="Карта")
+
+ assert (
+ resolve_provider_presentation(spec, settings, language="en").webapp_label
+ == "Bank card"
+ )
+
+
+def test_payment_method_keyboard_uses_custom_telegram_text_without_changing_callback():
+ 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",
+ WATA_ENABLED=True,
+ PAYMENT_WATA_TELEGRAM_LABEL_EN="Wata custom",
+ PAYMENT_WATA_TELEGRAM_EMOJI="💸",
+ )
+ i18n = SimpleNamespace(gettext=lambda _lang, key, **_kwargs: key)
+
+ markup = get_payment_method_keyboard(
+ months=1,
+ price=150,
+ stars_price=None,
+ currency_symbol_val="RUB",
+ lang="en",
+ i18n_instance=i18n,
+ settings=settings,
+ )
+
+ button = markup.inline_keyboard[0][0]
+ assert button.text == "💸 Wata custom"
+ assert button.callback_data == "pay_wata:1:150:subscription"
+
+
+def test_provider_callbacks_are_built_from_specs():
+ wata = get_provider_spec("wata")
+ stars = get_provider_spec("stars")
+
+ assert wata is not None
+ assert stars is not None
+ assert (
+ wata.callback_data(
+ value="1",
+ rub_price=150,
+ stars_price=None,
+ sale_mode="subscription",
+ )
+ == "pay_wata:1:150:subscription"
+ )
+ assert (
+ stars.callback_data(
+ value="1",
+ rub_price=150,
+ stars_price=42,
+ sale_mode="subscription",
+ )
+ == "pay_stars:1:42:subscription"
+ )
+ assert stars.callback_data(
+ value="1",
+ rub_price=150,
+ stars_price=None,
+ sale_mode="subscription",
+ ) is None
+
+
+def test_provider_visibility_uses_service_configuration():
+ spec = get_provider_spec("wata")
+ assert spec is not None
+
+ settings = SimpleNamespace(WATA_ENABLED=True)
+ assert spec.is_visible(settings, {"wata_service": SimpleNamespace(configured=True)})
+ assert not spec.is_visible(settings, {"wata_service": SimpleNamespace(configured=False)})
+ assert not spec.is_visible(SimpleNamespace(WATA_ENABLED=False), {})
+
+
+def test_common_sale_mode_helpers_cover_provider_payment_records():
+ assert sale_mode_base("traffic_package@premium|anything") == "traffic_package"
+ assert sale_mode_is_traffic("premium_topup@vip")
+ assert sale_mode_is_hwid_devices("hwid_devices@vip")
+ assert sale_mode_tariff_key("subscription@vip") == "vip"
+ assert format_number_for_payload(10.0) == "10"
+ assert format_number_for_payload(10.5) == "10.5"
+
+ traffic = payment_record_amounts(months=20, traffic_gb=20.5, sale_mode="topup@vip")
+ assert traffic.months == 20
+ assert traffic.purchased_gb == 20.5
+ assert traffic.purchased_hwid_devices is None
+ assert traffic.tariff_key == "vip"
+ assert traffic.traffic_sale
+
+ hwid = payment_record_amounts(months=3, sale_mode="hwid_devices@vip")
+ assert hwid.months == 3
+ assert hwid.purchased_gb is None
+ assert hwid.purchased_hwid_devices == 3
+ assert hwid.tariff_key == "vip"
+ assert hwid.hwid_devices_sale
diff --git a/tests/test_security.py b/tests/test_security.py
index 7459ad9..bd40076 100644
--- a/tests/test_security.py
+++ b/tests/test_security.py
@@ -15,9 +15,9 @@ from bot.app.web.webapp_auth import (
create_webapp_session_token,
verify_telegram_oauth_nonce,
)
-from bot.handlers.user.payment import yookassa_webhook_route
-from bot.services.crypto_pay_service import CryptoPayService
-from bot.services.freekassa_service import FreeKassaService
+from bot.payment_providers.cryptopay import CryptoPayService
+from bot.payment_providers.freekassa import FreeKassaService
+from bot.payment_providers.yookassa import yookassa_webhook_route
from bot.utils.request_security import request_client_ip
from config.settings import Settings
from db.database_setup import redacted_database_url
diff --git a/tests/test_settings.py b/tests/test_settings.py
index c093149..0b59f60 100644
--- a/tests/test_settings.py
+++ b/tests/test_settings.py
@@ -152,6 +152,27 @@ class SettingsTests(unittest.TestCase):
self.assertEqual(settings.TRIAL_TRAFFIC_STRATEGY, "WEEK")
+ def test_payment_button_presentation_env_values_are_available(self):
+ settings = Settings(
+ _env_file=None,
+ BOT_TOKEN="token",
+ POSTGRES_USER="app_user",
+ POSTGRES_PASSWORD="app_password",
+ PAYMENT_YOOKASSA_WEBAPP_LABEL_RU="Карта",
+ PAYMENT_YOOKASSA_WEBAPP_LABEL_EN="Card",
+ PAYMENT_YOOKASSA_WEBAPP_ICON="CreditCard",
+ PAYMENT_YOOKASSA_TELEGRAM_LABEL_RU="Банковская карта",
+ PAYMENT_YOOKASSA_TELEGRAM_LABEL_EN="Bank card",
+ PAYMENT_YOOKASSA_TELEGRAM_EMOJI="💳",
+ )
+
+ self.assertEqual(settings.PAYMENT_YOOKASSA_WEBAPP_LABEL_RU, "Карта")
+ self.assertEqual(settings.PAYMENT_YOOKASSA_WEBAPP_LABEL_EN, "Card")
+ self.assertEqual(settings.PAYMENT_YOOKASSA_WEBAPP_ICON, "CreditCard")
+ self.assertEqual(settings.PAYMENT_YOOKASSA_TELEGRAM_LABEL_RU, "Банковская карта")
+ self.assertEqual(settings.PAYMENT_YOOKASSA_TELEGRAM_LABEL_EN, "Bank card")
+ self.assertEqual(settings.PAYMENT_YOOKASSA_TELEGRAM_EMOJI, "💳")
+
def test_tariff_warning_levels_are_parsed(self):
settings = Settings(
_env_file=None,
diff --git a/tests/test_webapp_assets.py b/tests/test_webapp_assets.py
index b87bbf8..e3bc83e 100644
--- a/tests/test_webapp_assets.py
+++ b/tests/test_webapp_assets.py
@@ -358,6 +358,28 @@ class WebAppAssetTests(unittest.IsolatedAsyncioTestCase):
self.assertEqual(methods, [])
+ def test_serialize_payment_methods_includes_provider_presentation(self):
+ 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="yookassa",
+ YOOKASSA_ENABLED=True,
+ PAYMENT_YOOKASSA_WEBAPP_LABEL_RU="Карта",
+ PAYMENT_YOOKASSA_WEBAPP_LABEL_EN="Bank card",
+ PAYMENT_YOOKASSA_WEBAPP_ICON="WalletCards",
+ )
+ app = {"yookassa_service": SimpleNamespace(configured=True)}
+
+ methods = subscription_webapp._serialize_payment_methods(settings, app, "en")
+
+ self.assertEqual(
+ methods,
+ [{"id": "yookassa", "name": "Bank card", "icon": "WalletCards"}],
+ )
+
def test_serialize_plans_includes_stars_only_subscription_options(self):
settings = Settings(
_env_file=None,