From 80e5f0c80d3fd1afd56bddba14aa67695484eeae Mon Sep 17 00:00:00 2001 From: 3252a8 <3252a8@proton.me> Date: Sun, 31 May 2026 22:17:28 +0300 Subject: [PATCH] feat: add multicurrency tariff payments --- .../bot/app/web/admin_api_impl/_runtime.py | 2 +- backend/bot/app/web/admin_api_impl/stats.py | 2 +- backend/bot/app/web/admin_api_impl/tariffs.py | 49 +++- backend/bot/app/web/webapp/_runtime.py | 5 + backend/bot/app/web/webapp/billing.py | 80 +++++-- backend/bot/app/web/webapp/serializers.py | 67 ++++-- backend/bot/handlers/admin/statistics.py | 10 +- backend/bot/handlers/admin/user_management.py | 7 +- .../bot/handlers/user/subscription/core.py | 107 ++++++--- .../bot/keyboards/inline/user_keyboards.py | 33 ++- backend/bot/payment_providers/base.py | 68 ++++++ backend/bot/payment_providers/cryptopay.py | 60 ++++- backend/bot/payment_providers/freekassa.py | 28 ++- backend/bot/payment_providers/heleket.py | 45 +++- backend/bot/payment_providers/platega.py | 45 +++- backend/bot/payment_providers/severpay.py | 40 +++- backend/bot/payment_providers/stars.py | 2 + backend/bot/payment_providers/wata.py | 27 ++- backend/bot/payment_providers/yookassa.py | 30 ++- .../subscription_service_impl/_runtime.py | 6 +- .../subscription_service_impl/devices.py | 10 +- .../subscription_service_impl/lifecycle.py | 3 +- .../subscription_service_impl/payments.py | 2 +- .../subscription_service_impl/renewal.py | 18 +- .../subscription_service_impl/tariffs.py | 19 +- backend/config/tariffs_config.py | 225 +++++++++++++++--- data/tariffs.example.json | 1 + docs/features/tariffs.md | 53 ++++- .../admin/sections/TariffEditorModal.svelte | 70 ++++-- .../src/admin/sections/TariffsSection.svelte | 94 +++++++- frontend/src/lib/admin/stores/tariffsStore.js | 36 ++- frontend/src/lib/admin/tariffDraft.js | 78 ++++-- frontend/src/styles/admin.css | 64 +++++ tests/test_payment_provider_registry.py | 60 +++++ tests/test_tariffs_config.py | 23 +- 35 files changed, 1242 insertions(+), 227 deletions(-) diff --git a/backend/bot/app/web/admin_api_impl/_runtime.py b/backend/bot/app/web/admin_api_impl/_runtime.py index 5506556..041eab0 100644 --- a/backend/bot/app/web/admin_api_impl/_runtime.py +++ b/backend/bot/app/web/admin_api_impl/_runtime.py @@ -37,7 +37,7 @@ from bot.services.settings_override_service import ( from bot.utils import MessageContent, send_message_via_queue from bot.utils.message_queue import get_queue_manager from config.settings import Settings -from config.tariffs_config import TariffsConfig +from config.tariffs_config import TariffsConfig, default_payment_currency_code_for_settings from db.dal import ( ad_dal, app_settings_dal, diff --git a/backend/bot/app/web/admin_api_impl/stats.py b/backend/bot/app/web/admin_api_impl/stats.py index 2072786..3b41451 100644 --- a/backend/bot/app/web/admin_api_impl/stats.py +++ b/backend/bot/app/web/admin_api_impl/stats.py @@ -34,7 +34,7 @@ async def admin_stats_route(request: web.Request) -> web.Response: except Exception: # pragma: no cover - defensive payload["queue"] = None - payload["currency_symbol"] = settings.DEFAULT_CURRENCY_SYMBOL or "RUB" + payload["currency_symbol"] = default_payment_currency_code_for_settings(settings) return _ok(payload) diff --git a/backend/bot/app/web/admin_api_impl/tariffs.py b/backend/bot/app/web/admin_api_impl/tariffs.py index f2b4578..f3238aa 100644 --- a/backend/bot/app/web/admin_api_impl/tariffs.py +++ b/backend/bot/app/web/admin_api_impl/tariffs.py @@ -21,9 +21,11 @@ async def admin_tariffs_get_route(request: web.Request) -> web.Response: "path": str(path), "catalog": { "default_tariff": "", + "default_currency": "rub", "topup_packages_default": {"rub": [], "stars": []}, "tariffs": [], }, + "provider_currency_support": _provider_currency_support_payload(settings, request.app), } ) @@ -32,6 +34,7 @@ async def admin_tariffs_get_route(request: web.Request) -> web.Response: "exists": True, "path": str(path), "catalog": _tariffs_config_payload(config), + "provider_currency_support": _provider_currency_support_payload(settings, request.app), } ) @@ -58,4 +61,48 @@ async def admin_tariffs_save_route(request: web.Request) -> web.Response: await refresh_webapp_runtime_after_settings_change(request, updates={}, deletes=[]) - return _ok({"exists": True, "path": str(path), "catalog": _tariffs_config_payload(config)}) + return _ok( + { + "exists": True, + "path": str(path), + "catalog": _tariffs_config_payload(config), + "provider_currency_support": _provider_currency_support_payload(settings, request.app), + } + ) + + +def _provider_currency_support_payload(settings: Settings, app: web.Application) -> List[Dict[str, Any]]: + from bot.payment_providers import iter_provider_specs, resolve_provider_presentation + + default_currency = default_payment_currency_code_for_settings(settings) + providers: List[Dict[str, Any]] = [] + for spec in iter_provider_specs(): + presentation = resolve_provider_presentation(spec, settings) + supported = spec.supported_currency_codes(settings) + providers.append( + { + "id": spec.id, + "provider_key": spec.provider_key, + "label": presentation.webapp_label or spec.label, + "telegram_label": presentation.telegram_label, + "icon": presentation.webapp_icon, + "enabled": spec.is_effectively_enabled(settings), + "configured": spec.is_service_configured(app), + "admin_only": spec.is_admin_only_enabled(settings), + "price_source": spec.price_source, + "currencies": list(supported) if supported is not None else None, + "accepts_any_currency": supported is None, + "supports_default_currency": spec.is_usable_for_payment_currency( + settings, + default_currency, + ), + "directly_supports_default_currency": spec.supports_currency( + settings, + default_currency, + ), + "default_currency": default_currency, + "note": spec.currency_support_note, + "docs_url": spec.currency_support_url, + } + ) + return providers diff --git a/backend/bot/app/web/webapp/_runtime.py b/backend/bot/app/web/webapp/_runtime.py index e223de2..4da0199 100644 --- a/backend/bot/app/web/webapp/_runtime.py +++ b/backend/bot/app/web/webapp/_runtime.py @@ -55,6 +55,11 @@ from bot.utils.text_sanitizer import ( sanitize_username, ) from config.settings import Settings +from config.tariffs_config import ( + default_currency_key_for_settings, + default_payment_currency_code_for_settings, + payment_currency_code, +) from db.dal import payment_dal, security_dal, subscription_dal, support_dal, user_dal from db.dal.user_dal import UserMergeConflictError from db.models import Payment, User, UserTelegramAvatar diff --git a/backend/bot/app/web/webapp/billing.py b/backend/bot/app/web/webapp/billing.py index 081501a..e625320 100644 --- a/backend/bot/app/web/webapp/billing.py +++ b/backend/bot/app/web/webapp/billing.py @@ -103,6 +103,8 @@ async def create_payment_route(request: web.Request) -> web.Response: subscription_service: SubscriptionService = request.app["subscription_service"] cached = _get_cached_webapp_settings(request) tariffs_config = settings.tariffs_config + default_currency = default_currency_key_for_settings(settings) + default_currency_code = payment_currency_code(default_currency) traffic_mode = bool(settings.traffic_sale_mode) sale_mode = "subscription" traffic_gb_for_payment: Optional[float] = None @@ -155,17 +157,17 @@ async def create_payment_route(request: web.Request) -> web.Response: if requested_sale_mode == "premium_topup" else tariffs_config.topup_packages_for(tariff) ) - rub_packages = { + currency_packages = { float(package.gb): float(package.price) - for package in (packages.rub if packages else []) + for package in (packages.for_currency(default_currency) if packages else []) } stars_packages = { float(package.gb): int(float(package.price)) for package in (packages.stars if packages else []) } - package_key = _resolve_numeric_option_key(rub_packages, traffic_gb) + package_key = _resolve_numeric_option_key(currency_packages, traffic_gb) stars_package_key = _resolve_numeric_option_key(stars_packages, traffic_gb) - price = rub_packages.get(package_key) if package_key is not None else None + price = currency_packages.get(package_key) if package_key is not None else None stars_price = ( stars_packages.get(stars_package_key) if stars_package_key is not None else None ) @@ -196,17 +198,21 @@ async def create_payment_route(request: web.Request) -> web.Response: return _json_error(400, "invalid_plan", "Invalid traffic package") if traffic_gb <= 0: return _json_error(400, "invalid_plan", "Invalid traffic package") - rub_packages = { + currency_packages = { float(package.gb): float(package.price) - for package in (tariff.traffic_packages.rub if tariff.traffic_packages else []) + for package in ( + tariff.traffic_packages.for_currency(default_currency) + if tariff.traffic_packages + else [] + ) } stars_packages = { float(package.gb): int(float(package.price)) for package in (tariff.traffic_packages.stars if tariff.traffic_packages else []) } - package_key = _resolve_numeric_option_key(rub_packages, traffic_gb) + package_key = _resolve_numeric_option_key(currency_packages, traffic_gb) stars_package_key = _resolve_numeric_option_key(stars_packages, traffic_gb) - price = rub_packages.get(package_key) if package_key is not None else None + price = currency_packages.get(package_key) if package_key is not None else None stars_price = ( stars_packages.get(stars_package_key) if stars_package_key is not None else None ) @@ -224,7 +230,7 @@ async def create_payment_route(request: web.Request) -> web.Response: return _json_error(400, "invalid_plan", "Invalid subscription period") if months not in tariff.enabled_periods: return _json_error(400, "invalid_plan", "Subscription period is not available") - price = tariff.period_price(months, "rub") + price = tariff.period_price(months, default_currency) stars_price_raw = tariff.period_price(months, "stars") stars_price = int(stars_price_raw) if stars_price_raw and stars_price_raw > 0 else None if price is None and method != "stars": @@ -296,7 +302,7 @@ async def create_payment_route(request: web.Request) -> web.Response: active_tariff = None if not active_tariff or active_tariff.billing_model != "period": return _json_error(400, "invalid_plan", "Device top-up is not available") - currency = "stars" if method == "stars" else "rub" + currency = "stars" if method == "stars" else default_currency hwid_quote = await subscription_service.quote_hwid_device_topup( session, user_id=user_id, @@ -325,6 +331,7 @@ async def create_payment_route(request: web.Request) -> web.Response: months=payment_units, price=float(price or 0), stars_price=stars_price, + currency=default_currency_code, lang=lang, sale_mode=sale_mode, traffic_gb=traffic_gb_for_payment, @@ -583,6 +590,7 @@ async def tariff_change_payment_route(request: web.Request) -> web.Response: tariff_key = str(payment_payload.tariff_key or "").strip() settings: Settings = request.app["settings"] config = settings.tariffs_config + default_currency_code = default_payment_currency_code_for_settings(settings) if not config: return _json_error(404, "tariffs_unavailable", "Tariffs are not configured") if not tariff_key: @@ -618,6 +626,7 @@ async def tariff_change_payment_route(request: web.Request) -> web.Response: months=1, price=price, stars_price=None, + currency=default_currency_code, lang=db_user.language_code or settings.DEFAULT_LANGUAGE, sale_mode=f"tariff_upgrade@{target.key}", ) @@ -656,20 +665,29 @@ async def device_topup_options_route(request: web.Request) -> web.Response: active.get("extra_hwid_devices_valid_until_text") if active else None ) or _billing_datetime_text(extra_hwid_valid_until) packages = tariff.hwid_device_packages - rub_counts = {int(package.count) for package in (packages.rub if packages else [])} + default_currency = default_currency_key_for_settings(settings) + default_currency_code = payment_currency_code(default_currency) + if packages and hasattr(packages, "for_currency"): + default_packages = packages.for_currency(default_currency) + else: + default_packages = getattr(packages, default_currency, []) if packages else [] + currency_counts = { + int(package.count) + for package in default_packages + } stars_counts = {int(package.count) for package in (packages.stars if packages else [])} plans = [] - for count in sorted(rub_counts | stars_counts): - rub_quote = ( + for count in sorted(currency_counts | stars_counts): + currency_quote = ( await subscription_service.quote_hwid_device_topup( session, user_id=user_id, device_count=count, tariff_key=tariff.key, renewal=renewal_available, - currency="rub", + currency=default_currency, ) - if count in rub_counts + if count in currency_counts else None ) stars_quote = ( @@ -684,7 +702,7 @@ async def device_topup_options_route(request: web.Request) -> web.Response: if count in stars_counts else None ) - if not rub_quote and not stars_quote: + if not currency_quote and not stars_quote: continue sale_mode_for_plan = "hwid_devices_renewal" if renewal_available else "hwid_devices" plan = { @@ -695,13 +713,19 @@ async def device_topup_options_route(request: web.Request) -> web.Response: "sale_mode": sale_mode_for_plan, "months": count, "device_count": count, - "price": float(rub_quote.get("price") if rub_quote else 0), - "currency": settings.DEFAULT_CURRENCY_SYMBOL or "RUB", + "price": float(currency_quote.get("price") if currency_quote else 0), + "currency": default_currency_code, "title": f"+{count}", "subtitle": tariff.name(lang), - "valid_from": _billing_iso_datetime((rub_quote or stars_quote).get("valid_from")), - "valid_until": _billing_iso_datetime((rub_quote or stars_quote).get("valid_until")), - "proration_ratio": float((rub_quote or stars_quote).get("proration_ratio") or 0), + "valid_from": _billing_iso_datetime( + (currency_quote or stars_quote).get("valid_from") + ), + "valid_until": _billing_iso_datetime( + (currency_quote or stars_quote).get("valid_until") + ), + "proration_ratio": float( + (currency_quote or stars_quote).get("proration_ratio") or 0 + ), } if stars_quote and int(stars_quote.get("price") or 0) > 0: plan["stars_price"] = int(stars_quote["price"]) @@ -929,12 +953,14 @@ async def _create_subscription_payment( price: float, stars_price: Optional[int], lang: str, + currency: Optional[str] = None, sale_mode: str = "subscription", traffic_gb: Optional[float] = None, is_admin: bool = False, hwid_quote: Optional[Dict[str, Any]] = None, ) -> web.Response: settings: Settings = request.app["settings"] + payment_currency = (currency or default_payment_currency_code_for_settings(settings)).upper() sale_mode = str(sale_mode or "subscription") traffic_sale = _sale_mode_is_traffic(sale_mode) hwid_devices_sale = _sale_mode_is_hwid_devices(sale_mode) @@ -958,6 +984,17 @@ async def _create_subscription_payment( provider_spec.is_service_configured(request.app), ) return _json_error(400, "payment_unavailable", "Payment method unavailable") + if not provider_spec.is_usable_for_payment_currency(settings, payment_currency): + logger.warning( + "WebApp payment method does not support currency: method=%s currency=%s", + method, + payment_currency, + ) + return _json_error( + 400, + "unsupported_currency", + "Payment method does not support this currency", + ) return await provider_spec.create_webapp_payment( WebAppPaymentContext( request=request, @@ -967,6 +1004,7 @@ async def _create_subscription_payment( months=months, price=price, stars_price=stars_price, + currency=payment_currency, description=description, sale_mode=sale_mode, traffic_gb=traffic_gb, diff --git a/backend/bot/app/web/webapp/serializers.py b/backend/bot/app/web/webapp/serializers.py index 391b9ef..b08a98d 100644 --- a/backend/bot/app/web/webapp/serializers.py +++ b/backend/bot/app/web/webapp/serializers.py @@ -472,6 +472,8 @@ def _serialize_plans( ) -> List[Dict[str, Any]]: tariffs_config = settings.tariffs_config if tariffs_config: + default_currency = default_currency_key_for_settings(settings) + default_currency_code = payment_currency_code(default_currency) plans: List[Dict[str, Any]] = [] for tariff in tariffs_config.enabled_tariffs: common = { @@ -481,7 +483,7 @@ def _serialize_plans( "billing_model": tariff.billing_model, "description": tariff.description(lang), "squad_uuids": tariff.squad_uuids, - "currency": settings.DEFAULT_CURRENCY_SYMBOL or "RUB", + "currency": default_currency_code, "hwid_device_limit": tariff.hwid_device_limit, "hwid_device_packages": _serialize_hwid_device_packages( settings, @@ -494,7 +496,7 @@ def _serialize_plans( } if tariff.billing_model == "period": for months in sorted(tariff.enabled_periods): - price = tariff.period_price(int(months), "rub") + price = tariff.period_price(int(months), default_currency) stars_price = tariff.period_price(int(months), "stars") if price is None and (stars_price is None or int(stars_price) <= 0): continue @@ -512,9 +514,13 @@ def _serialize_plans( plan["stars_price"] = int(stars_price) plans.append(plan) else: - rub_packages = { + currency_packages = { float(package.gb): float(package.price) - for package in (tariff.traffic_packages.rub if tariff.traffic_packages else []) + for package in ( + tariff.traffic_packages.for_currency(default_currency) + if tariff.traffic_packages + else [] + ) } stars_packages = { float(package.gb): int(float(package.price)) @@ -522,8 +528,8 @@ def _serialize_plans( tariff.traffic_packages.stars if tariff.traffic_packages else [] ) } - for traffic_gb in sorted(set(rub_packages) | set(stars_packages)): - price = rub_packages.get(traffic_gb) + for traffic_gb in sorted(set(currency_packages) | set(stars_packages)): + price = currency_packages.get(traffic_gb) stars_price = stars_packages.get(traffic_gb) if price is None and (stars_price is None or int(stars_price) <= 0): continue @@ -609,16 +615,19 @@ def _serialize_topup_packages( sale_mode: str = "topup", title_prefix: str = "", ) -> List[Dict[str, Any]]: - rub_packages = { - float(package.gb): float(package.price) for package in (packages.rub if packages else []) + default_currency = default_currency_key_for_settings(settings) + default_currency_code = payment_currency_code(default_currency) + currency_packages = { + float(package.gb): float(package.price) + for package in (packages.for_currency(default_currency) if packages else []) } stars_packages = { float(package.gb): int(float(package.price)) for package in (packages.stars if packages else []) } plans: List[Dict[str, Any]] = [] - for traffic_gb in sorted(set(rub_packages) | set(stars_packages)): - price = rub_packages.get(traffic_gb) + for traffic_gb in sorted(set(currency_packages) | set(stars_packages)): + price = currency_packages.get(traffic_gb) stars_price = stars_packages.get(traffic_gb) if price is None and (stars_price is None or int(stars_price) <= 0): continue @@ -632,7 +641,7 @@ def _serialize_topup_packages( "months": int(traffic_value) if traffic_value.is_integer() else traffic_value, "traffic_gb": traffic_value, "price": float(price or 0), - "currency": settings.DEFAULT_CURRENCY_SYMBOL or "RUB", + "currency": default_currency_code, "title": f"{title_prefix}{_format_traffic_title(traffic_value, lang)}", "subtitle": tariff.premium_name(lang) if sale_mode == "premium_topup" @@ -650,16 +659,19 @@ def _serialize_hwid_device_packages( packages: Optional[Any], lang: str, ) -> List[Dict[str, Any]]: - rub_packages = { - int(package.count): float(package.price) for package in (packages.rub if packages else []) + default_currency = default_currency_key_for_settings(settings) + default_currency_code = payment_currency_code(default_currency) + currency_packages = { + int(package.count): float(package.price) + for package in (packages.for_currency(default_currency) if packages else []) } stars_packages = { int(package.count): int(float(package.price)) for package in (packages.stars if packages else []) } plans: List[Dict[str, Any]] = [] - for count in sorted(set(rub_packages) | set(stars_packages)): - price = rub_packages.get(count) + for count in sorted(set(currency_packages) | set(stars_packages)): + price = currency_packages.get(count) stars_price = stars_packages.get(count) if price is None and (stars_price is None or int(stars_price) <= 0): continue @@ -672,7 +684,7 @@ def _serialize_hwid_device_packages( "months": int(count), "device_count": int(count), "price": float(price or 0), - "currency": settings.DEFAULT_CURRENCY_SYMBOL or "RUB", + "currency": default_currency_code, "title": f"+{count}", "subtitle": tariff.name(lang), } @@ -689,6 +701,8 @@ def _serialize_tariff_change_target( options: Dict[str, Any], lang: str, ) -> Dict[str, Any]: + default_currency = default_currency_key_for_settings(settings) + default_currency_code = payment_currency_code(default_currency) actions: List[Dict[str, Any]] = [] mode = str(options.get("mode") or "") if mode == "period_to_period": @@ -711,7 +725,7 @@ def _serialize_tariff_change_target( "kind": "payment", "title": "paid_diff", "price": paid_diff, - "currency": settings.DEFAULT_CURRENCY_SYMBOL or "RUB", + "currency": default_currency_code, } ) elif mode == "period_to_traffic": @@ -733,13 +747,17 @@ def _serialize_tariff_change_target( "title": f"+{package.gb:g} GB", "traffic_gb": float(package.gb), "price": float(package.price), - "currency": settings.DEFAULT_CURRENCY_SYMBOL or "RUB", + "currency": default_currency_code, } - for package in (tariff.traffic_packages.rub if tariff.traffic_packages else []) + for package in ( + tariff.traffic_packages.for_currency(default_currency) + if tariff.traffic_packages + else [] + ) ) else: for months in tariff.enabled_periods: - price = tariff.period_price(int(months), "rub") + price = tariff.period_price(int(months), default_currency) if price: actions.append( { @@ -748,7 +766,7 @@ def _serialize_tariff_change_target( "months": int(months), "title": _format_months_title(int(months), lang), "price": float(price), - "currency": settings.DEFAULT_CURRENCY_SYMBOL or "RUB", + "currency": default_currency_code, } ) return { @@ -772,10 +790,15 @@ def _serialize_payment_methods( from bot.payment_providers import get_provider_spec, resolve_provider_presentation methods: List[Dict[str, Any]] = [] + payment_currency = default_payment_currency_code_for_settings(settings) for method in settings.payment_methods_order: method = method.lower() spec = get_provider_spec(method) - if spec and spec.is_visible_for_user(settings, app, is_admin=is_admin): + if ( + spec + and spec.is_visible_for_user(settings, app, is_admin=is_admin) + and spec.is_usable_for_payment_currency(settings, payment_currency) + ): presentation = resolve_provider_presentation(spec, settings, language=lang) methods.append( { diff --git a/backend/bot/handlers/admin/statistics.py b/backend/bot/handlers/admin/statistics.py index 5e34c8c..9374abf 100644 --- a/backend/bot/handlers/admin/statistics.py +++ b/backend/bot/handlers/admin/statistics.py @@ -13,6 +13,7 @@ from bot.middlewares.i18n import JsonI18n from bot.payment_providers import pending_statuses from bot.services.panel_api_service import PanelApiService from config.settings import Settings +from config.tariffs_config import default_payment_currency_code_for_settings from db.dal import panel_sync_dal, payment_dal, user_dal from db.models import PanelSyncStatus, Payment @@ -195,19 +196,20 @@ async def show_statistics_handler( # Financial statistics financial_stats = await payment_dal.get_financial_statistics(session) + currency = default_payment_currency_code_for_settings(settings) stats_text_parts.append(f"\n💰 {_('admin_financial_stats_header')}") stats_text_parts.append( - f"📅 {_('admin_financial_today_label')}: {financial_stats['today_revenue']:.2f} RUB ({financial_stats['today_payments_count']} {_('admin_financial_payments_label')})" # noqa: E501 + f"📅 {_('admin_financial_today_label')}: {financial_stats['today_revenue']:.2f} {currency} ({financial_stats['today_payments_count']} {_('admin_financial_payments_label')})" # noqa: E501 ) stats_text_parts.append( - f"📅 {_('admin_financial_week_label')}: {financial_stats['week_revenue']:.2f} RUB" + f"📅 {_('admin_financial_week_label')}: {financial_stats['week_revenue']:.2f} {currency}" # noqa: E501 ) stats_text_parts.append( - f"📅 {_('admin_financial_month_label')}: {financial_stats['month_revenue']:.2f} RUB" + f"📅 {_('admin_financial_month_label')}: {financial_stats['month_revenue']:.2f} {currency}" # noqa: E501 ) stats_text_parts.append( - f"🏆 {_('admin_financial_all_time_label')}: {financial_stats['all_time_revenue']:.2f} RUB" # noqa: E501 + f"🏆 {_('admin_financial_all_time_label')}: {financial_stats['all_time_revenue']:.2f} {currency}" # noqa: E501 ) last_payments_models: List[Payment] = await payment_dal.get_recent_payment_logs_with_user( diff --git a/backend/bot/handlers/admin/user_management.py b/backend/bot/handlers/admin/user_management.py index 786fbd2..6354f3b 100644 --- a/backend/bot/handlers/admin/user_management.py +++ b/backend/bot/handlers/admin/user_management.py @@ -27,6 +27,7 @@ from bot.utils.text_sanitizer import ( username_for_display, ) from config.settings import Settings +from config.tariffs_config import default_payment_currency_code_for_settings from db.dal import message_log_dal, subscription_dal, user_dal from db.models import User @@ -407,16 +408,18 @@ async def format_user_card( try: from db.dal import payment_dal + currency = default_payment_currency_code_for_settings(settings) + # Total amount paid by this user total_paid = await payment_dal.get_user_total_paid(session, user.user_id) card_parts.append( - f"{_('admin_user_total_paid_label')} {hcode(f'{total_paid:.2f} RUB')}" + f"{_('admin_user_total_paid_label')} {hcode(f'{total_paid:.2f} {currency}')}" ) # Total revenue from referrals referral_revenue = await payment_dal.get_referral_revenue(session, user.user_id) card_parts.append( - f"{_('admin_user_referral_revenue_label')} {hcode(f'{referral_revenue:.2f} RUB')}" + f"{_('admin_user_referral_revenue_label')} {hcode(f'{referral_revenue:.2f} {currency}')}" ) except Exception as e_fin: logging.error( diff --git a/backend/bot/handlers/user/subscription/core.py b/backend/bot/handlers/user/subscription/core.py index bcb2fa2..d9b3e2f 100644 --- a/backend/bot/handlers/user/subscription/core.py +++ b/backend/bot/handlers/user/subscription/core.py @@ -32,6 +32,10 @@ from bot.utils.install_links import ( ensure_user_install_guide_links, ) from config.settings import Settings +from config.tariffs_config import ( + default_currency_key_for_settings, + default_payment_currency_code_for_settings, +) from db.dal import subscription_dal, user_billing_dal from db.models import Subscription @@ -80,11 +84,13 @@ def _tariff_purchase_markup( back_callback=back_callback, callback_context=callback_context, ) + default_currency = default_currency_key_for_settings(settings) return get_tariff_packages_keyboard( tariff, - tariff.traffic_packages.rub, + tariff.traffic_packages.for_currency(default_currency), current_lang, i18n, + currency_symbol=default_payment_currency_code_for_settings(settings), back_callback=back_callback, callback_context=callback_context, ) @@ -185,6 +191,7 @@ async def display_subscription_options( enabled_tariffs, current_lang, i18n, + settings=settings, back_callback=back_callback, callback_context=callback_context, ) @@ -329,7 +336,9 @@ async def select_tariff_period_callback( callback_context = parts[4] if len(parts) > 4 else None tariff = config.require(tariff_key) months = int(months_raw) - price_rub = tariff.period_price(months, "rub") + default_currency = default_currency_key_for_settings(settings) + currency_code = default_payment_currency_code_for_settings(settings) + price_rub = tariff.period_price(months, default_currency) stars_price = tariff.period_price(months, "stars") if price_rub is None: await callback.answer(get_text("error_try_again"), show_alert=True) @@ -338,7 +347,7 @@ async def select_tariff_period_callback( months, price_rub, int(stars_price) if stars_price else None, - settings.DEFAULT_CURRENCY_SYMBOL, + currency_code, current_lang, i18n, settings, @@ -369,10 +378,16 @@ async def select_tariff_package_callback( callback_context = parts[4] if len(parts) > 4 else None tariff = config.require(tariff_key) gb = float(gb_raw) + default_currency = default_currency_key_for_settings(settings) + currency_code = default_payment_currency_code_for_settings(settings) packages = ( - tariff.traffic_packages.rub + tariff.traffic_packages.for_currency(default_currency) if tariff.billing_model == "traffic" - else (config.topup_packages_for(tariff).rub if config.topup_packages_for(tariff) else []) + else ( + config.topup_packages_for(tariff).for_currency(default_currency) + if config.topup_packages_for(tariff) + else [] + ) ) package = next((pkg for pkg in packages if float(pkg.gb) == gb), None) if not package: @@ -391,7 +406,7 @@ async def select_tariff_package_callback( gb, package.price, None, - settings.DEFAULT_CURRENCY_SYMBOL, + currency_code, current_lang, i18n, settings, @@ -423,14 +438,19 @@ async def tariff_topup_list_callback( return tariff = config.require(active["tariff_key"]) packages = config.topup_packages_for(tariff) - rub_packages = packages.rub if packages else [] - premium_packages = tariff.premium_topup_packages.rub if tariff.premium_topup_packages else [] - if not rub_packages and not premium_packages: + default_currency = default_currency_key_for_settings(settings) + currency = default_payment_currency_code_for_settings(settings) + currency_packages = packages.for_currency(default_currency) if packages else [] + premium_packages = ( + tariff.premium_topup_packages.for_currency(default_currency) + if tariff.premium_topup_packages + else [] + ) + if not currency_packages and not premium_packages: await callback.answer(get_text("no_subscription_options_available"), show_alert=True) return builder = InlineKeyboardBuilder() - currency = settings.DEFAULT_CURRENCY_SYMBOL - for package in rub_packages: + for package in currency_packages: builder.row( InlineKeyboardButton( text=f"Обычный трафик +{package.gb:g} GB — {package.price:g} {currency}", @@ -452,7 +472,7 @@ async def tariff_topup_list_callback( premium_lines = [] carryover_lines = [] - if rub_packages or premium_packages: + if currency_packages or premium_packages: carryover_lines.append( "Докупленный трафик не сгорает: сначала расходуется месячный лимит, затем докупленный остаток." # noqa: E501 ) @@ -495,7 +515,13 @@ async def select_tariff_premium_package_callback( _, _, tariff_key, gb_raw = callback.data.split(":", 3) tariff = config.require(tariff_key) gb = float(gb_raw) - packages = tariff.premium_topup_packages.rub if tariff.premium_topup_packages else [] + default_currency = default_currency_key_for_settings(settings) + currency_code = default_payment_currency_code_for_settings(settings) + packages = ( + tariff.premium_topup_packages.for_currency(default_currency) + if tariff.premium_topup_packages + else [] + ) package = next((pkg for pkg in packages if float(pkg.gb) == gb), None) if not package: await callback.answer(get_text("error_try_again"), show_alert=True) @@ -504,7 +530,7 @@ async def select_tariff_premium_package_callback( gb, package.price, None, - settings.DEFAULT_CURRENCY_SYMBOL, + currency_code, current_lang, i18n, settings, @@ -542,7 +568,12 @@ async def hwid_devices_list_callback( if tariff.billing_model != "period": await callback.answer(get_text("no_hwid_device_packages_available"), show_alert=True) return - packages = tariff.hwid_device_packages.rub if tariff.hwid_device_packages else [] + default_currency = default_currency_key_for_settings(settings) + packages = ( + tariff.hwid_device_packages.for_currency(default_currency) + if tariff.hwid_device_packages + else [] + ) if not packages: await callback.answer(get_text("no_hwid_device_packages_available"), show_alert=True) return @@ -594,7 +625,13 @@ async def hwid_devices_package_callback( package = next( ( pkg - for pkg in (tariff.hwid_device_packages.rub if tariff.hwid_device_packages else []) + for pkg in ( + tariff.hwid_device_packages.for_currency( + default_currency_key_for_settings(settings) + ) + if tariff.hwid_device_packages + else [] + ) if int(pkg.count) == count ), None, @@ -603,13 +640,15 @@ async def hwid_devices_package_callback( await callback.answer(get_text("error_try_again"), show_alert=True) return sale_mode_base = "hwid_devices_renewal" if action == "renewal_package" else "hwid_devices" - rub_quote = await subscription_service.quote_hwid_device_topup( + default_currency = default_currency_key_for_settings(settings) + currency_code = default_payment_currency_code_for_settings(settings) + currency_quote = await subscription_service.quote_hwid_device_topup( session, user_id=callback.from_user.id, device_count=count, tariff_key=tariff.key, renewal=action == "renewal_package", - currency="rub", + currency=default_currency, ) stars_quote = await subscription_service.quote_hwid_device_topup( session, @@ -619,16 +658,16 @@ async def hwid_devices_package_callback( renewal=action == "renewal_package", currency="stars", ) - if not rub_quote and not stars_quote: + if not currency_quote and not stars_quote: await callback.answer(get_text("error_try_again"), show_alert=True) return markup = get_payment_method_keyboard( count, - float(rub_quote.get("price") if rub_quote else 0), + float(currency_quote.get("price") if currency_quote else 0), int(stars_quote["price"]) if stars_quote and int(stars_quote.get("price") or 0) > 0 else None, - settings.DEFAULT_CURRENCY_SYMBOL, + currency_code, current_lang, i18n, settings, @@ -715,6 +754,8 @@ async def tariff_change_select_callback( options = await subscription_service.calculate_tariff_switch_options_with_hwid( session, db_sub, target ) + default_currency = default_currency_key_for_settings(settings) + currency_code = default_payment_currency_code_for_settings(settings) rows = [] if options["mode"] == "period_to_period": rows.append( @@ -729,7 +770,7 @@ async def tariff_change_select_callback( rows.append( [ InlineKeyboardButton( - text=f"Доплатить {options['paid_diff_rub']} RUB", + text=f"Доплатить {options['paid_diff_rub']} {currency_code}", callback_data=f"tariff_change:confirm_pay:{target.key}:{options['paid_diff_rub']}", ) ] @@ -743,23 +784,23 @@ async def tariff_change_select_callback( ) ] ) - for package in target.traffic_packages.rub: + for package in target.traffic_packages.for_currency(default_currency): rows.append( [ InlineKeyboardButton( - text=f"+ {package.gb:g} GB за {package.price:g} RUB", + text=f"+ {package.gb:g} GB за {package.price:g} {currency_code}", callback_data=f"tariff:package:{target.key}:{package.gb:g}", ) ] ) else: for months in target.enabled_periods: - price = target.period_price(months, "rub") + price = target.period_price(months, default_currency) if price: rows.append( [ InlineKeyboardButton( - text=f"{months} мес. за {price:g} RUB", + text=f"{months} мес. за {price:g} {currency_code}", callback_data=f"tariff:period:{target.key}:{months}", ) ] @@ -842,6 +883,7 @@ async def tariff_change_confirm_pay_callback( return _, _, tariff_key, amount_raw = callback.data.split(":", 3) target = config.require(tariff_key) + currency_code = default_payment_currency_code_for_settings(settings) rows = [ [ InlineKeyboardButton( @@ -857,7 +899,7 @@ async def tariff_change_confirm_pay_callback( ], ] await callback.message.edit_text( - f"Подтвердите смену тарифа\n\nНовый тариф: {target.name(current_lang)}\nБудет создана оплата на {amount_raw} RUB.", # noqa: E501 + f"Подтвердите смену тарифа\n\nНовый тариф: {target.name(current_lang)}\nБудет создана оплата на {amount_raw} {currency_code}.", # noqa: E501 reply_markup=InlineKeyboardMarkup(inline_keyboard=rows), ) await callback.answer() @@ -899,11 +941,12 @@ async def tariff_change_pay_callback( i18n: JsonI18n = i18n_data.get("i18n_instance") _, _, tariff_key, amount_raw = callback.data.split(":", 3) amount = float(amount_raw) + currency_code = default_payment_currency_code_for_settings(settings) markup = get_payment_method_keyboard( 1, amount, None, - settings.DEFAULT_CURRENCY_SYMBOL, + currency_code, current_lang, i18n, settings, @@ -1226,7 +1269,9 @@ async def my_subscription_command_handler( if ( tariff_for_devices.billing_model == "period" and tariff_for_devices.hwid_device_packages - and tariff_for_devices.hwid_device_packages.rub + and tariff_for_devices.hwid_device_packages.for_currency( + default_currency_key_for_settings(settings) + ) ): prepend_rows.append( [ @@ -1443,7 +1488,9 @@ async def my_devices_command_handler( if ( tariff_for_devices.billing_model == "period" and tariff_for_devices.hwid_device_packages - and tariff_for_devices.hwid_device_packages.rub + and tariff_for_devices.hwid_device_packages.for_currency( + default_currency_key_for_settings(settings) + ) ): devices_kb.append( [ diff --git a/backend/bot/keyboards/inline/user_keyboards.py b/backend/bot/keyboards/inline/user_keyboards.py index bbd45df..6bd5fde 100644 --- a/backend/bot/keyboards/inline/user_keyboards.py +++ b/backend/bot/keyboards/inline/user_keyboards.py @@ -7,6 +7,10 @@ from bot.middlewares.i18n import locale_language_options from bot.utils.install_links import bot_install_guide_url from bot.utils.mini_app_url import subscription_mini_app_trial_url from config.settings import Settings +from config.tariffs_config import ( + default_currency_key_for_settings, + default_payment_currency_code_for_settings, +) BOT_MENU_CONTEXT = "bot" @@ -328,19 +332,31 @@ def get_tariff_catalog_keyboard( tariffs: List[Any], lang: str, i18n_instance, + settings: Optional[Settings] = None, back_callback: str = "main_action:back_to_main", callback_context: Optional[str] = None, ) -> InlineKeyboardMarkup: builder = InlineKeyboardBuilder() callback_context = callback_context or callback_context_from_back_callback(back_callback) + default_currency = default_currency_key_for_settings(settings) if settings else "rub" for tariff in tariffs: label = tariff.name(lang) if tariff.billing_model == "period": - min_price = tariff.min_period_price_rub() + if hasattr(tariff, "min_period_price"): + min_price = tariff.min_period_price(default_currency) + elif default_currency == "rub" and hasattr(tariff, "min_period_price_rub"): + min_price = tariff.min_period_price_rub() + else: + min_price = None if min_price is not None: label = f"{label} от {min_price:g}" else: - package = tariff.min_traffic_package_rub() + if hasattr(tariff, "min_traffic_package"): + package = tariff.min_traffic_package(default_currency) + elif default_currency == "rub" and hasattr(tariff, "min_traffic_package_rub"): + package = tariff.min_traffic_package_rub() + else: + package = None if package: label = f"{label} от {package.price:g} / {package.gb:g} GB" builder.row( @@ -368,8 +384,10 @@ def get_tariff_periods_keyboard( builder = InlineKeyboardBuilder() callback_context = callback_context or callback_context_from_back_callback(back_callback) _ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs) + default_currency = default_currency_key_for_settings(settings) + currency_code = default_payment_currency_code_for_settings(settings) for months in tariff.enabled_periods: - rub_price = tariff.period_price(months, "rub") + rub_price = tariff.period_price(months, default_currency) if rub_price and rub_price > 0: builder.row( InlineKeyboardButton( @@ -377,7 +395,7 @@ def get_tariff_periods_keyboard( "subscribe_for_months_button", months=months, price=rub_price, - currency_symbol=settings.DEFAULT_CURRENCY_SYMBOL, + currency_symbol=currency_code, ), callback_data=f"tariff:period:{tariff.key}:{months}" f"{callback_suffix_for_context(callback_context)}", @@ -394,6 +412,7 @@ def get_tariff_packages_keyboard( packages: List[Any], lang: str, i18n_instance, + currency_symbol: str = "RUB", back_callback: str = "main_action:subscribe", callback_context: Optional[str] = None, ) -> InlineKeyboardMarkup: @@ -407,7 +426,7 @@ def get_tariff_packages_keyboard( "buy_traffic_package_button", traffic_gb=f"{package.gb:g}", price=package.price, - currency_symbol="RUB", + currency_symbol=currency_symbol, ), callback_data=f"tariff:package:{tariff.key}:{package.gb:g}" f"{callback_suffix_for_context(callback_context)}", @@ -430,6 +449,7 @@ def get_hwid_device_packages_keyboard( ) -> InlineKeyboardMarkup: builder = InlineKeyboardBuilder() _ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs) + currency_code = default_payment_currency_code_for_settings(settings) for package in packages: builder.row( InlineKeyboardButton( @@ -437,7 +457,7 @@ def get_hwid_device_packages_keyboard( "buy_hwid_devices_button", count=package.count, price=package.price, - currency_symbol=settings.DEFAULT_CURRENCY_SYMBOL, + currency_symbol=currency_code, ), callback_data=( f"hwid_devices:{'renewal_package' if renewal else 'package'}:" @@ -484,6 +504,7 @@ def get_payment_method_keyboard( if ( not spec or not spec.callback_prefix + or not spec.is_usable_for_payment_currency(settings, currency_symbol_val) or not spec.is_available_to_user( settings, user_id=user_id, diff --git a/backend/bot/payment_providers/base.py b/backend/bot/payment_providers/base.py index cc407e3..88d7f4f 100644 --- a/backend/bot/payment_providers/base.py +++ b/backend/bot/payment_providers/base.py @@ -112,6 +112,7 @@ class WebAppPaymentContext: stars_price: Optional[int] description: str sale_mode: str + currency: str = "RUB" traffic_gb: Optional[float] = None hwid_valid_from: Optional[Any] = None hwid_valid_until: Optional[Any] = None @@ -125,6 +126,36 @@ ServiceFactory = Callable[[ServiceFactoryContext], Any] WebhookPathGetter = Callable[[Any], str] WebhookRoute = Callable[[Any], Awaitable[Any]] WebAppPaymentFactory = Callable[[WebAppPaymentContext], Awaitable[Any]] +CurrencySupportResolver = Callable[[Any], Optional[Sequence[str]]] + + +def normalize_payment_currency_code(value: Any, default: str = "RUB") -> str: + text = str(value or "").strip().upper() + if not text: + text = str(default).strip().upper() if default is not None else "" + if not text: + return "" + aliases = {"RUR": "RUB", "STARS": "XTR", "STAR": "XTR"} + normalized = aliases.get(text, text) + return "".join(ch for ch in normalized if ch.isalnum() or ch in {"_", "-"}).strip("_-") + + +def parse_supported_currency_codes(value: Any) -> tuple[str, ...]: + if value is None: + return () + if isinstance(value, str): + raw_items = value.replace(";", ",").split(",") + else: + raw_items = list(value) + currencies: list[str] = [] + seen: set[str] = set() + for item in raw_items: + code = normalize_payment_currency_code(item, default="") + if not code or code in seen: + continue + seen.add(code) + currencies.append(code) + return tuple(currencies) @dataclass(frozen=True) @@ -158,6 +189,10 @@ class PaymentProviderSpec: admin_only_manifest_key: Optional[str] = None admin_only_config_attr: str = "ADMIN_ONLY_ENABLED" admin_only_enabled: Optional[EnabledPredicate] = None + supported_currencies: Optional[Sequence[str]] = ("RUB",) + supported_currencies_resolver: Optional[CurrencySupportResolver] = None + currency_support_note: str = "" + currency_support_url: Optional[str] = None @property def settings_key(self) -> str: @@ -237,6 +272,39 @@ class PaymentProviderSpec: service = app.get(self.service_key) if hasattr(app, "get") else None return bool(service and getattr(service, "configured", False)) + def _currency_source(self, source: Any) -> Any: + 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 bundle.config + return source + + def supported_currency_codes(self, source: Any = None) -> Optional[tuple[str, ...]]: + if self.price_source == "stars": + return ("XTR",) + source_for_currency = self._currency_source(source) + if self.supported_currencies_resolver is not None: + resolved = self.supported_currencies_resolver(source_for_currency) + if resolved is None: + return None + return parse_supported_currency_codes(resolved) + if self.supported_currencies is None: + return None + return parse_supported_currency_codes(self.supported_currencies) + + def supports_currency(self, source: Any, currency: Any) -> bool: + supported = self.supported_currency_codes(source) + if supported is None: + return True + return normalize_payment_currency_code(currency) in supported + + def is_usable_for_payment_currency(self, source: Any, currency: Any) -> bool: + if self.price_source == "stars": + return True + return self.supports_currency(source, currency) + def is_visible(self, source: Any, app: Any) -> bool: return self.is_enabled(source) and self.is_service_configured(app) diff --git a/backend/bot/payment_providers/cryptopay.py b/backend/bot/payment_providers/cryptopay.py index 0aa5e62..3da8c23 100644 --- a/backend/bot/payment_providers/cryptopay.py +++ b/backend/bot/payment_providers/cryptopay.py @@ -17,6 +17,10 @@ from bot.middlewares.i18n import JsonI18n from bot.services.referral_service import ReferralService from bot.services.subscription_service import SubscriptionService from config.settings import Settings +from config.tariffs_config import ( + default_currency_key_for_settings, + default_payment_currency_code_for_settings, +) from db.dal import payment_dal from .base import ( @@ -25,6 +29,7 @@ from .base import ( ProviderManifestField, ServiceFactoryContext, WebAppPaymentContext, + normalize_payment_currency_code, provider_env_file, provider_runtime_enabled, ) @@ -49,6 +54,34 @@ from .shared import ( logger = logging.getLogger(__name__) _LOG = "cryptopay" +CRYPTOPAY_FIAT_CURRENCIES = ( + "USD", + "EUR", + "RUB", + "BYN", + "UAH", + "GBP", + "CNY", + "KZT", + "UZS", + "GEL", + "TRY", + "AMD", + "THB", + "INR", + "BRL", + "IDR", + "AZN", + "AED", + "PLN", + "ILS", +) +CRYPTOPAY_CRYPTO_ASSETS = ("USDT", "TON", "BTC", "ETH", "LTC", "BNB", "TRX", "USDC") + + +def _cryptopay_supported_currencies(config) -> tuple[str, ...]: + currency_type = str(getattr(config, "CURRENCY_TYPE", "fiat") or "fiat").strip().lower() + return CRYPTOPAY_CRYPTO_ASSETS if currency_type == "crypto" else CRYPTOPAY_FIAT_CURRENCIES class CryptoPayConfig(ProviderEnvConfig): @@ -159,11 +192,23 @@ class CryptoPayService: sale_mode: str = "subscription", url_kind: str = "bot", hwid_quote: Optional[dict] = None, + currency: Optional[str] = None, ) -> Optional[str]: if not self.configured or not self.client: logging.error("CryptoPayService not configured") return None + currency_code = normalize_payment_currency_code(currency or self.config.ASSET) + currency_type = str(self.config.CURRENCY_TYPE or "fiat").strip().lower() + supported = _cryptopay_supported_currencies(self.config) + if currency_code not in supported: + logging.error( + "CryptoPay currency %s is not supported for currency_type=%s", + currency_code, + currency_type, + ) + return None + sale_base = sale_mode_base(sale_mode) amounts = payment_record_amounts(months=months, sale_mode=sale_mode) try: @@ -172,7 +217,7 @@ class CryptoPayService: { "user_id": user_id, "amount": float(amount), - "currency": self.config.ASSET, + "currency": currency_code, "status": "pending_cryptopay", "description": description, "subscription_duration_months": ( @@ -212,9 +257,9 @@ class CryptoPayService: try: invoice = await self.client.create_invoice( amount=amount, - currency_type=self.config.CURRENCY_TYPE, - fiat=self.config.ASSET if self.config.CURRENCY_TYPE == "fiat" else None, - asset=self.config.ASSET if self.config.CURRENCY_TYPE == "crypto" else None, + currency_type=currency_type, + fiat=currency_code if currency_type == "fiat" else None, + asset=currency_code if currency_type == "crypto" else None, description=description, payload=payload, ) @@ -393,7 +438,7 @@ async def pay_crypto_callback_handler( user_id=callback.from_user.id, parts=parts, subscription_service=cryptopay_service.subscription_service, - currency="rub", + currency=default_currency_key_for_settings(settings), ) if not parts: await notify_callback_parse_error(callback, translator) @@ -408,6 +453,7 @@ async def pay_crypto_callback_handler( description=payment_description, sale_mode=parts.sale_mode, hwid_quote=hwid_quote, + currency=default_payment_currency_code_for_settings(settings), ) if invoice_url: @@ -457,6 +503,7 @@ async def create_webapp_payment(ctx: WebAppPaymentContext) -> web.Response: description=ctx.description, sale_mode=ctx.sale_mode, url_kind="web", + currency=ctx.currency, hwid_quote={ "valid_from": ctx.hwid_valid_from, "valid_until": ctx.hwid_valid_until, @@ -592,4 +639,7 @@ SPEC = PaymentProviderSpec( config_class=CryptoPayConfig, presentation_class=CryptoPayPresentation, manifest_fields=_CONFIG_MANIFEST + _PRESENTATION_MANIFEST, + supported_currencies_resolver=_cryptopay_supported_currencies, + currency_support_note="Crypto Pay supports different sets for fiat invoices and crypto invoices; CURRENCY_TYPE selects which set is active.", + currency_support_url="https://help.crypt.bot/crypto-pay-api/", ) diff --git a/backend/bot/payment_providers/freekassa.py b/backend/bot/payment_providers/freekassa.py index 5bdaf4b..f5163a2 100644 --- a/backend/bot/payment_providers/freekassa.py +++ b/backend/bot/payment_providers/freekassa.py @@ -20,6 +20,10 @@ from bot.services.referral_service import ReferralService from bot.services.subscription_service import SubscriptionService from bot.utils.request_security import ip_in_allowlist, request_client_ip from config.settings import Settings +from config.tariffs_config import ( + default_currency_key_for_settings, + default_payment_currency_code_for_settings, +) from db.dal import payment_dal from .base import ( @@ -28,6 +32,7 @@ from .base import ( ProviderManifestField, ServiceFactoryContext, WebAppPaymentContext, + normalize_payment_currency_code, provider_env_file, provider_runtime_enabled, ) @@ -56,6 +61,7 @@ from .shared import ( ) _LOG = "freekassa" +FREEKASSA_SUPPORTED_CURRENCIES = ("RUB", "USD", "EUR", "UAH", "KZT") class FreeKassaConfig(ProviderEnvConfig): @@ -144,7 +150,7 @@ class FreeKassaService(HttpClientMixin): self.subscription_service = subscription_service self.referral_service = referral_service - self.default_currency: str = (settings.DEFAULT_CURRENCY_SYMBOL or "RUB").upper() + self.default_currency: str = default_payment_currency_code_for_settings(settings).upper() self.api_base_url: str = "https://api.fk.life/v1" self._init_http_client(total_timeout=15) @@ -207,7 +213,13 @@ class FreeKassaService(HttpClientMixin): return False, {"message": "missing_ip"} email = email or f"{user_id}@telegram.org" - currency_code = (currency or self.default_currency or "RUB").upper() + currency_code = normalize_payment_currency_code(currency or self.default_currency or "RUB") + if currency_code not in FREEKASSA_SUPPORTED_CURRENCIES: + return False, { + "message": "unsupported_currency", + "currency": currency_code, + "supported_currencies": list(FREEKASSA_SUPPORTED_CURRENCIES), + } payload: Dict[str, Any] = { "shopId": int(self.shop_id), @@ -477,7 +489,7 @@ async def pay_fk_callback_handler( user_id=callback.from_user.id, parts=parts, subscription_service=freekassa_service.subscription_service, - currency="rub", + currency=default_currency_key_for_settings(settings), ) if not parts: await notify_callback_parse_error(callback, translator) @@ -485,7 +497,7 @@ async def pay_fk_callback_handler( currency_code = ( getattr(freekassa_service, "default_currency", None) - or settings.DEFAULT_CURRENCY_SYMBOL + or default_payment_currency_code_for_settings(settings) or "RUB" ) payment_description = describe_payment(translator, parts) @@ -578,12 +590,13 @@ async def create_webapp_payment(ctx: WebAppPaymentContext) -> web.Response: service: FreeKassaService = ctx.request.app["freekassa_service"] if not service or not service.configured or not service.payment_method_id: return payment_unavailable() + currency = ctx.currency or service.default_currency try: payment = await create_webapp_payment_record( ctx, amount=ctx.price, - currency=service.default_currency, + currency=currency, status="pending_freekassa", provider="freekassa", ) @@ -592,7 +605,7 @@ async def create_webapp_payment(ctx: WebAppPaymentContext) -> web.Response: user_id=ctx.user_id, months=ctx.months, amount=ctx.price, - currency=service.default_currency, + currency=currency, payment_method_id=service.payment_method_id, ip_address=service.server_ip, extra_params={"us_method": service.payment_method_id}, @@ -762,4 +775,7 @@ SPEC = PaymentProviderSpec( config_class=FreeKassaConfig, presentation_class=FreeKassaPresentation, manifest_fields=_CONFIG_MANIFEST + _PRESENTATION_MANIFEST, + supported_currencies=FREEKASSA_SUPPORTED_CURRENCIES, + currency_support_note="FreeKassa SCI documents the payment currency parameter as RUB, USD, EUR, UAH or KZT.", + currency_support_url="https://docs.freekassa.net/", ) diff --git a/backend/bot/payment_providers/heleket.py b/backend/bot/payment_providers/heleket.py index 55b6fd7..36c009e 100644 --- a/backend/bot/payment_providers/heleket.py +++ b/backend/bot/payment_providers/heleket.py @@ -18,6 +18,10 @@ from bot.services.referral_service import ReferralService from bot.services.subscription_service import SubscriptionService from bot.utils.request_security import ip_in_allowlist, request_client_ip from config.settings import Settings +from config.tariffs_config import ( + default_currency_key_for_settings, + default_payment_currency_code_for_settings, +) from db.dal import payment_dal from .base import ( @@ -26,6 +30,8 @@ from .base import ( ProviderManifestField, ServiceFactoryContext, WebAppPaymentContext, + normalize_payment_currency_code, + parse_supported_currency_codes, provider_env_file, provider_runtime_enabled, ) @@ -59,6 +65,10 @@ _LOG = "heleket" _SUCCESS_STATUSES = {"paid", "paid_over"} _FAILED_STATUSES = {"fail", "wrong_amount", "cancel", "system_fail"} +HELEKET_DEFAULT_SUPPORTED_CURRENCIES = ( + "RUB,USD,EUR,USDT,USDC,BTC,ETH,LTC,TON,TRX,BNB,BCH,DASH,DAI,DOGE," + "MATIC,SHIB,SOL,XMR,AVAX,BUSD,VERSE" +) class HeleketConfig(ProviderEnvConfig): @@ -83,6 +93,7 @@ class HeleketConfig(ProviderEnvConfig): LIFETIME_SECONDS: int = Field(default=3600) VERIFY_WEBHOOK_SIGNATURE: bool = Field(default=True) TRUSTED_IPS: str = Field(default="31.133.220.8") + SUPPORTED_CURRENCIES: str = Field(default=HELEKET_DEFAULT_SUPPORTED_CURRENCIES) @field_validator("LIFETIME_SECONDS", mode="before") @classmethod @@ -299,9 +310,18 @@ class HeleketService(HttpClientMixin): logging.error("HeleketService is not configured. Cannot create payment link.") return False, {"message": "service_not_configured"} + currency_code = normalize_payment_currency_code(currency or self.currency) + supported = parse_supported_currency_codes(self.config.SUPPORTED_CURRENCIES) + if supported and currency_code not in supported: + return False, { + "message": "unsupported_currency", + "currency": currency_code, + "supported_currencies": list(supported), + } + body: Dict[str, Any] = { "amount": str(format_decimal_amount(amount)), - "currency": (currency or self.currency).upper(), + "currency": currency_code, "order_id": str(payment_db_id), "url_return": self.return_url, "url_success": self.success_url, @@ -572,13 +592,13 @@ async def pay_heleket_callback_handler( user_id=callback.from_user.id, parts=parts, subscription_service=heleket_service.subscription_service, - currency="rub", + currency=default_currency_key_for_settings(settings), ) if not parts: await notify_callback_parse_error(callback, translator) return - currency_code = (heleket_service.currency or settings.DEFAULT_CURRENCY_SYMBOL or "RUB").upper() + currency_code = default_payment_currency_code_for_settings(settings) payment_description = describe_payment(translator, parts) record_payload = build_payment_record_payload( user_id=callback.from_user.id, @@ -632,7 +652,7 @@ async def create_webapp_payment(ctx: WebAppPaymentContext) -> web.Response: if not service or not service.configured: return payment_unavailable() - currency = (service.currency or settings.DEFAULT_CURRENCY_SYMBOL or "RUB").upper() + currency = ctx.currency or default_payment_currency_code_for_settings(settings) try: payment = await create_webapp_payment_record( ctx, @@ -787,6 +807,18 @@ _CONFIG_MANIFEST = ( subsection="Heleket", attr="CURRENCY", ), + ProviderManifestField( + "HELEKET_SUPPORTED_CURRENCIES", + "string", + "Supported currencies", + description=( + "Comma-separated invoice currencies allowed for Heleket in this shop. " + "Heleket can reject unsupported codes per account/service." + ), + placeholder=HELEKET_DEFAULT_SUPPORTED_CURRENCIES, + subsection="Heleket", + attr="SUPPORTED_CURRENCIES", + ), ProviderManifestField( "HELEKET_TO_CURRENCY", "string", @@ -859,4 +891,9 @@ SPEC = PaymentProviderSpec( config_class=HeleketConfig, presentation_class=HeleketPresentation, manifest_fields=_CONFIG_MANIFEST + _PRESENTATION_MANIFEST, + supported_currencies_resolver=lambda config: getattr( + config, "SUPPORTED_CURRENCIES", HELEKET_DEFAULT_SUPPORTED_CURRENCIES + ), + currency_support_note="Heleket supports crypto and fiat invoice currencies, but exact availability can depend on service/account settings.", + currency_support_url="https://doc.heleket.com/methods/payments/creating-invoice", ) diff --git a/backend/bot/payment_providers/platega.py b/backend/bot/payment_providers/platega.py index bb74dc1..4c6d583 100644 --- a/backend/bot/payment_providers/platega.py +++ b/backend/bot/payment_providers/platega.py @@ -14,6 +14,10 @@ from bot.middlewares.i18n import JsonI18n from bot.services.referral_service import ReferralService from bot.services.subscription_service import SubscriptionService from config.settings import Settings +from config.tariffs_config import ( + default_currency_key_for_settings, + default_payment_currency_code_for_settings, +) from db.dal import payment_dal from .base import ( @@ -22,6 +26,8 @@ from .base import ( ProviderManifestField, ServiceFactoryContext, WebAppPaymentContext, + normalize_payment_currency_code, + parse_supported_currency_codes, provider_env_file, provider_runtime_enabled, ) @@ -76,6 +82,7 @@ class PlategaConfig(ProviderEnvConfig): CRYPTO_METHOD: int = Field(default=13) RETURN_URL: Optional[str] = None FAILED_URL: Optional[str] = None + SUPPORTED_CURRENCIES: str = Field(default="RUB") @field_validator("MERCHANT_ID", "SECRET", "RETURN_URL", "FAILED_URL", mode="before") @classmethod @@ -229,9 +236,19 @@ class PlategaService(HttpClientMixin): logging.error("PlategaService is not configured. Cannot create transaction.") return False, {"message": "service_not_configured"} + currency_code = normalize_payment_currency_code( + currency or self.settings.DEFAULT_CURRENCY_SYMBOL or "RUB" + ) + supported = parse_supported_currency_codes(self.config.SUPPORTED_CURRENCIES) + if supported and currency_code not in supported: + return False, { + "message": "unsupported_currency", + "currency": currency_code, + "supported_currencies": list(supported), + } + session = await self._get_session() url = f"{self.base_url}/transaction/process" - currency_code = (currency or self.settings.DEFAULT_CURRENCY_SYMBOL or "RUB").upper() method_id = int(payment_method if payment_method is not None else self.payment_method) body: Dict[str, Any] = { @@ -482,13 +499,13 @@ async def pay_platega_callback_handler( user_id=callback.from_user.id, parts=parts, subscription_service=platega_service.subscription_service, - currency="rub", + currency=default_currency_key_for_settings(settings), ) if not parts: await notify_callback_parse_error(callback, translator) return - currency_code = settings.DEFAULT_CURRENCY_SYMBOL or "RUB" + currency_code = default_payment_currency_code_for_settings(settings) payment_description = describe_payment(translator, parts) record_payload = build_payment_record_payload( user_id=callback.from_user.id, @@ -596,7 +613,7 @@ async def _create_webapp_payment(ctx: WebAppPaymentContext, variant: str) -> web payment = await create_webapp_payment_record( ctx, amount=ctx.price, - currency=settings.DEFAULT_CURRENCY_SYMBOL or "RUB", + currency=ctx.currency or settings.DEFAULT_CURRENCY_SYMBOL or "RUB", status="pending_platega", provider="platega", ) @@ -616,7 +633,7 @@ async def _create_webapp_payment(ctx: WebAppPaymentContext, variant: str) -> web ) success, response_data = await service.create_transaction( amount=ctx.price, - currency=settings.DEFAULT_CURRENCY_SYMBOL or "RUB", + currency=ctx.currency or settings.DEFAULT_CURRENCY_SYMBOL or "RUB", description=ctx.description, payload=payload, payment_method=platega_method_id, @@ -757,6 +774,18 @@ _CONFIG_MANIFEST = ( subsection="Platega", attr="CRYPTO_METHOD", ), + ProviderManifestField( + "PLATEGA_SUPPORTED_CURRENCIES", + "string", + "Supported currencies", + description=( + "Comma-separated payment currencies enabled for your Platega merchant. " + "Public docs expose currency per method/limits but do not publish a fixed global list." + ), + placeholder="RUB", + subsection="Platega", + attr="SUPPORTED_CURRENCIES", + ), ProviderManifestField( "PLATEGA_RETURN_URL", "url", "Return URL", subsection="Platega", attr="RETURN_URL" ), @@ -793,6 +822,9 @@ SBP_SPEC = PaymentProviderSpec( presentation_class=PlategaSbpPresentation, manifest_fields=_CONFIG_MANIFEST + _platega_presentation_manifest("Platega", "CreditCard", "PLATEGA_SBP"), + supported_currencies_resolver=lambda config: getattr(config, "SUPPORTED_CURRENCIES", "RUB"), + currency_support_note="Platega currencies are merchant/method-specific; configure the codes enabled for your account.", + currency_support_url="https://docs.platega.io/", ) CRYPTO_SPEC = PaymentProviderSpec( @@ -818,6 +850,9 @@ CRYPTO_SPEC = PaymentProviderSpec( config_class=PlategaConfig, presentation_class=PlategaCryptoPresentation, manifest_fields=_platega_presentation_manifest("Platega", "Bitcoin", "PLATEGA_CRYPTO"), + supported_currencies_resolver=lambda config: getattr(config, "SUPPORTED_CURRENCIES", "RUB"), + currency_support_note="Platega currencies are merchant/method-specific; configure the codes enabled for your account.", + currency_support_url="https://docs.platega.io/", ) SPECS = (SBP_SPEC, CRYPTO_SPEC) diff --git a/backend/bot/payment_providers/severpay.py b/backend/bot/payment_providers/severpay.py index 0844fb2..fcfed99 100644 --- a/backend/bot/payment_providers/severpay.py +++ b/backend/bot/payment_providers/severpay.py @@ -16,6 +16,10 @@ from bot.middlewares.i18n import JsonI18n from bot.services.referral_service import ReferralService from bot.services.subscription_service import SubscriptionService from config.settings import Settings +from config.tariffs_config import ( + default_currency_key_for_settings, + default_payment_currency_code_for_settings, +) from db.dal import payment_dal from .base import ( @@ -24,6 +28,8 @@ from .base import ( ProviderManifestField, ServiceFactoryContext, WebAppPaymentContext, + normalize_payment_currency_code, + parse_supported_currency_codes, provider_env_file, provider_runtime_enabled, ) @@ -69,6 +75,7 @@ class SeverPayConfig(ProviderEnvConfig): RETURN_URL: Optional[str] = None BASE_URL: str = Field(default="https://severpay.io/api/merchant") LIFETIME_MINUTES: Optional[int] = None + SUPPORTED_CURRENCIES: str = Field(default="RUB,USD") @field_validator("MID", "LIFETIME_MINUTES", mode="before") @classmethod @@ -201,9 +208,19 @@ class SeverPayService(HttpClientMixin): logging.error("SeverPayService is not configured. Cannot create payment.") return False, {"message": "service_not_configured"} + currency_code = normalize_payment_currency_code( + currency or self.settings.DEFAULT_CURRENCY_SYMBOL or "RUB" + ) + supported = parse_supported_currency_codes(self.config.SUPPORTED_CURRENCIES) + if supported and currency_code not in supported: + return False, { + "message": "unsupported_currency", + "currency": currency_code, + "supported_currencies": list(supported), + } + session = await self._get_session() url = f"{self.base_url}/payin/create" - currency_code = (currency or self.settings.DEFAULT_CURRENCY_SYMBOL or "RUB").upper() body = { "order_id": str(payment_db_id), @@ -428,13 +445,13 @@ async def pay_severpay_callback_handler( user_id=callback.from_user.id, parts=parts, subscription_service=severpay_service.subscription_service, - currency="rub", + currency=default_currency_key_for_settings(settings), ) if not parts: await notify_callback_parse_error(callback, translator) return - currency_code = settings.DEFAULT_CURRENCY_SYMBOL or "RUB" + currency_code = default_payment_currency_code_for_settings(settings) payment_description = describe_payment(translator, parts) record_payload = build_payment_record_payload( user_id=callback.from_user.id, @@ -503,7 +520,7 @@ async def create_webapp_payment(ctx: WebAppPaymentContext) -> web.Response: if not service or not service.configured: return payment_unavailable() - currency = settings.DEFAULT_CURRENCY_SYMBOL or "RUB" + currency = ctx.currency or settings.DEFAULT_CURRENCY_SYMBOL or "RUB" try: payment = await create_webapp_payment_record( ctx, @@ -627,6 +644,18 @@ _CONFIG_MANIFEST = ( max=4320, attr="LIFETIME_MINUTES", ), + ProviderManifestField( + "SEVERPAY_SUPPORTED_CURRENCIES", + "string", + "Supported currencies", + description=( + "Comma-separated currencies enabled for your SeverPay merchant. " + "The public PayIn docs show USD examples but do not publish a fixed global list." + ), + placeholder="RUB,USD", + subsection="SeverPay", + attr="SUPPORTED_CURRENCIES", + ), ) @@ -651,4 +680,7 @@ SPEC = PaymentProviderSpec( config_class=SeverPayConfig, presentation_class=SeverPayPresentation, manifest_fields=_CONFIG_MANIFEST + _PRESENTATION_MANIFEST, + supported_currencies_resolver=lambda config: getattr(config, "SUPPORTED_CURRENCIES", "RUB,USD"), + currency_support_note="SeverPay PayIn requires a currency; keep this list aligned with your merchant account.", + currency_support_url="https://docs.severpay.io/ru/payin/create", ) diff --git a/backend/bot/payment_providers/stars.py b/backend/bot/payment_providers/stars.py index 743e7e7..0a69ad1 100644 --- a/backend/bot/payment_providers/stars.py +++ b/backend/bot/payment_providers/stars.py @@ -484,4 +484,6 @@ SPEC = PaymentProviderSpec( telegram_emoji="⭐", presentation_class=StarsPresentation, manifest_fields=_PRESENTATION_MANIFEST, + supported_currencies=("XTR",), + currency_support_note="Telegram Stars use Telegram's XTR currency and separate Stars prices.", ) diff --git a/backend/bot/payment_providers/wata.py b/backend/bot/payment_providers/wata.py index 7846cb6..d4734a2 100644 --- a/backend/bot/payment_providers/wata.py +++ b/backend/bot/payment_providers/wata.py @@ -19,6 +19,10 @@ from bot.services.referral_service import ReferralService from bot.services.subscription_service import SubscriptionService from bot.utils.request_security import ip_in_allowlist, request_client_ip from config.settings import Settings +from config.tariffs_config import ( + default_currency_key_for_settings, + default_payment_currency_code_for_settings, +) from db.dal import payment_dal from .base import ( @@ -27,6 +31,7 @@ from .base import ( ProviderManifestField, ServiceFactoryContext, WebAppPaymentContext, + normalize_payment_currency_code, provider_env_file, provider_runtime_enabled, ) @@ -63,6 +68,7 @@ from .shared import ( router = Router(name="user_subscription_payments_wata_router") _LOG = "wata" +WATA_SUPPORTED_CURRENCIES = ("RUB", "USD", "EUR") _WATA_IN_PROGRESS_STATUSES = {"created", "pending"} _WATA_LINK_OPENED_STATUSES = {"opened", "open"} _WATA_LINK_DEFAULT_TTL_MINUTES = 15 @@ -258,13 +264,23 @@ class WataService(HttpClientMixin): logging.error("WataService is not configured. Cannot create payment link.") return False, {"message": "service_not_configured"} + currency_code = normalize_payment_currency_code( + currency or self.settings.DEFAULT_CURRENCY_SYMBOL or "RUB" + ) + if currency_code not in WATA_SUPPORTED_CURRENCIES: + return False, { + "message": "unsupported_currency", + "currency": currency_code, + "supported_currencies": list(WATA_SUPPORTED_CURRENCIES), + } + session = await self._get_session() expires_at = ( datetime.now(timezone.utc) + timedelta(minutes=self.payment_link_ttl_minutes) ).replace(microsecond=0) body: Dict[str, Any] = { "amount": float(format_decimal_amount(amount)), - "currency": (currency or self.settings.DEFAULT_CURRENCY_SYMBOL or "RUB").upper(), + "currency": currency_code, "description": description, "orderId": str(payment_db_id), "successRedirectUrl": self.return_url, @@ -863,13 +879,13 @@ async def pay_wata_callback_handler( user_id=callback.from_user.id, parts=parts, subscription_service=wata_service.subscription_service, - currency="rub", + currency=default_currency_key_for_settings(settings), ) if not parts: await notify_callback_parse_error(callback, translator) return - currency_code = settings.DEFAULT_CURRENCY_SYMBOL or "RUB" + currency_code = default_payment_currency_code_for_settings(settings) payment_description = describe_payment(translator, parts) reuse_amounts = payment_record_amounts(months=parts.months, sale_mode=parts.sale_mode) @@ -956,7 +972,7 @@ async def create_webapp_payment(ctx: WebAppPaymentContext) -> web.Response: if not service or not service.configured: return payment_unavailable() - currency = settings.DEFAULT_CURRENCY_SYMBOL or "RUB" + currency = ctx.currency or settings.DEFAULT_CURRENCY_SYMBOL or "RUB" reuse_amounts = payment_record_amounts( months=ctx.months, @@ -1190,4 +1206,7 @@ SPEC = PaymentProviderSpec( config_class=WataConfig, presentation_class=WataPresentation, manifest_fields=_CONFIG_MANIFEST + _PRESENTATION_MANIFEST, + supported_currencies=WATA_SUPPORTED_CURRENCIES, + currency_support_note="WATA H2H payment links and widget document RUB, USD and EUR as payment currencies.", + currency_support_url="https://wata.pro/api", ) diff --git a/backend/bot/payment_providers/yookassa.py b/backend/bot/payment_providers/yookassa.py index 575cfab..2e46788 100644 --- a/backend/bot/payment_providers/yookassa.py +++ b/backend/bot/payment_providers/yookassa.py @@ -40,6 +40,10 @@ from bot.utils.config_link import prepare_config_links from bot.utils.install_links import ensure_user_install_guide_links from bot.utils.request_security import ip_in_allowlist, request_client_ip from config.settings import Settings +from config.tariffs_config import ( + default_currency_key_for_settings, + default_payment_currency_code_for_settings, +) from db.dal import payment_dal, user_billing_dal, user_dal from db.models import Payment @@ -49,6 +53,7 @@ from .base import ( ProviderManifestField, ServiceFactoryContext, WebAppPaymentContext, + normalize_payment_currency_code, provider_env_file, provider_runtime_enabled, ) @@ -234,6 +239,11 @@ class YooKassaService: "internal_message": "Service settings (Settings object) not initialized.", } + currency = normalize_payment_currency_code(currency) + if currency != "RUB": + logging.error("YooKassa currency %s is not supported by this integration", currency) + return None + customer_contact_for_receipt = {} if receipt_email: customer_contact_for_receipt["email"] = receipt_email @@ -885,7 +895,7 @@ async def process_successful_payment( i18n=i18n, user_id=user_id, amount=payment_value, - currency=settings.DEFAULT_CURRENCY_SYMBOL, + currency=amount_data.get("currency", default_payment_currency_code_for_settings(settings)), months_for_admin=int(subscription_months) if sale_mode_base == "subscription" else 0, traffic_gb_for_admin=( traffic_amount_gb if is_traffic_sale_base(sale_mode_base) else None @@ -1702,7 +1712,7 @@ async def pay_yk_callback_handler( user_id=callback.from_user.id, parts=PaymentCallbackParts(months=months, price=price_rub, sale_mode=sale_mode), subscription_service=yookassa_service.subscription_service, - currency="rub", + currency=default_currency_key_for_settings(settings), ) if not quoted_parts: try: @@ -1713,7 +1723,7 @@ async def pay_yk_callback_handler( months = quoted_parts.months price_rub = quoted_parts.price user_id = callback.from_user.id - currency_code_for_yk = "RUB" + currency_code_for_yk = default_payment_currency_code_for_settings(settings) autopay_enabled = bool( settings.yookassa_autopayments_active and _sale_mode_base(sale_mode) == "subscription" @@ -1851,7 +1861,7 @@ async def pay_yk_new_card_handler( months, price_rub, sale_mode = parsed user_id = callback.from_user.id - currency_code_for_yk = "RUB" + currency_code_for_yk = default_payment_currency_code_for_settings(settings) autopay_enabled = bool( settings.yookassa_autopayments_active and _sale_mode_base(sale_mode) == "subscription" @@ -2154,7 +2164,7 @@ async def pay_yk_use_saved_handler( pass return - currency_code_for_yk = "RUB" + currency_code_for_yk = default_payment_currency_code_for_settings(settings) await _initiate_yk_payment( callback, @@ -2260,7 +2270,7 @@ async def payment_method_bind( metadata = {"user_id": str(callback.from_user.id), "bind_only": "1"} resp = await yookassa_service.create_payment( amount=1.00, - currency="RUB", + currency=default_payment_currency_code_for_settings(settings), description="Bind card", metadata=metadata, receipt_email=yookassa_service.config.DEFAULT_RECEIPT_EMAIL, @@ -2734,6 +2744,7 @@ async def create_webapp_payment(ctx: WebAppPaymentContext) -> web.Response: service: YooKassaService = ctx.request.app["yookassa_service"] if not service or not service.configured: return payment_unavailable() + currency = (ctx.currency or "RUB").upper() try: amounts = payment_record_amounts( @@ -2744,7 +2755,7 @@ async def create_webapp_payment(ctx: WebAppPaymentContext) -> web.Response: payment = await create_webapp_payment_record( ctx, amount=ctx.price, - currency="RUB", + currency=currency, status="pending_yookassa", provider="yookassa", ) @@ -2767,7 +2778,7 @@ async def create_webapp_payment(ctx: WebAppPaymentContext) -> web.Response: metadata["tariff_key"] = amounts.tariff_key response = await service.create_payment( amount=ctx.price, - currency="RUB", + currency=currency, description=ctx.description, metadata=metadata, receipt_email=service.config.DEFAULT_RECEIPT_EMAIL, @@ -2929,4 +2940,7 @@ SPEC = PaymentProviderSpec( config_class=YooKassaConfig, presentation_class=YooKassaPresentation, manifest_fields=_CONFIG_MANIFEST + _PRESENTATION_MANIFEST, + supported_currencies=("RUB",), + currency_support_note="YooKassa public payment API examples and limits are RUB-based; treat non-RUB as unsupported unless your YooKassa contract confirms otherwise.", + currency_support_url="https://yookassa.ru/developers/payment-acceptance/integration-scenarios/smart-payment", ) diff --git a/backend/bot/services/subscription_service_impl/_runtime.py b/backend/bot/services/subscription_service_impl/_runtime.py index 32b26c4..b0b0404 100644 --- a/backend/bot/services/subscription_service_impl/_runtime.py +++ b/backend/bot/services/subscription_service_impl/_runtime.py @@ -11,7 +11,11 @@ from bot.middlewares.i18n import JsonI18n from bot.utils.config_link import prepare_config_links from bot.utils.date_utils import add_months, month_start from config.settings import Settings -from config.tariffs_config import Tariff +from config.tariffs_config import ( + Tariff, + default_currency_key_for_settings, + default_payment_currency_code_for_settings, +) from db.dal import ( payment_dal, promo_code_dal, diff --git a/backend/bot/services/subscription_service_impl/devices.py b/backend/bot/services/subscription_service_impl/devices.py index b0d0beb..5c48e07 100644 --- a/backend/bot/services/subscription_service_impl/devices.py +++ b/backend/bot/services/subscription_service_impl/devices.py @@ -70,7 +70,7 @@ class HwidDeviceMixin: package_set = tariff.hwid_device_packages if not package_set: return None - packages = package_set.for_currency("stars" if currency == "stars" else "rub") + packages = package_set.for_currency(currency) return next((pkg for pkg in packages if int(pkg.count) == int(device_count)), None) def _quote_hwid_package_price( @@ -173,7 +173,7 @@ class HwidDeviceMixin: valid_from=valid_from, valid_until=valid_until, now=now, - currency="stars" if currency == "stars" else "rub", + currency=currency, ) quote.update( { @@ -227,7 +227,11 @@ class HwidDeviceMixin: ) return None packages = ( - [*tariff.hwid_device_packages.rub, *tariff.hwid_device_packages.stars] + [ + package + for currency_packages in tariff.hwid_device_packages.root.values() + for package in currency_packages + ] if tariff.hwid_device_packages else [] ) diff --git a/backend/bot/services/subscription_service_impl/lifecycle.py b/backend/bot/services/subscription_service_impl/lifecycle.py index 66a4434..bff6851 100644 --- a/backend/bot/services/subscription_service_impl/lifecycle.py +++ b/backend/bot/services/subscription_service_impl/lifecycle.py @@ -249,7 +249,8 @@ class SubscriptionLifecycleMixin: ) update_data["period_start_at"] = None update_data["effective_monthly_price_rub"] = ( - target.period_price(1, "rub") or target.min_period_price_rub() + target.period_price(1, default_currency_key_for_settings(self.settings)) + or target.min_period_price(default_currency_key_for_settings(self.settings)) ) if mode == "recalc_days" and options.get("recalc_days") is not None: update_data["end_date"] = now + timedelta(days=int(options["recalc_days"])) diff --git a/backend/bot/services/subscription_service_impl/payments.py b/backend/bot/services/subscription_service_impl/payments.py index 1916afc..2e48814 100644 --- a/backend/bot/services/subscription_service_impl/payments.py +++ b/backend/bot/services/subscription_service_impl/payments.py @@ -120,7 +120,7 @@ class PaymentContextMixin: months=int(months or 0), traffic_gb=traffic_gb, amount=float(payment_amount or 0), - currency=self.settings.DEFAULT_CURRENCY_SYMBOL, + currency=default_payment_currency_code_for_settings(self.settings), end_date_text=end_date_text, dashboard_url=dashboard_url, provider_label=provider_label, diff --git a/backend/bot/services/subscription_service_impl/renewal.py b/backend/bot/services/subscription_service_impl/renewal.py index 8d9cc5b..bb4bc1f 100644 --- a/backend/bot/services/subscription_service_impl/renewal.py +++ b/backend/bot/services/subscription_service_impl/renewal.py @@ -41,7 +41,21 @@ class RenewalMixin: return False months = sub.duration_months or 1 - amount = self.settings.subscription_options.get(months) + currency = default_payment_currency_code_for_settings(self.settings) + amount = None + tariffs_config = self._tariffs_config() if callable(getattr(self, "_tariffs_config", None)) else None + if tariffs_config and callable(getattr(self, "_resolve_tariff", None)): + try: + tariff = self._resolve_tariff(getattr(sub, "tariff_key", None)) + except Exception: + tariff = None + if tariff and tariff.billing_model == "period": + amount = tariff.period_price( + months, + default_currency_key_for_settings(self.settings), + ) + if amount is None: + amount = self.settings.subscription_options.get(months) if not amount: logging.error(f"Auto-renew price missing for {months} months") return False @@ -53,7 +67,7 @@ class RenewalMixin: } resp = await yk.create_payment( amount=float(amount), - currency="RUB", + currency=currency, description=f"Auto-renewal for {months} months", metadata=metadata, payment_method_id=default_pm.provider_payment_method_id, diff --git a/backend/bot/services/subscription_service_impl/tariffs.py b/backend/bot/services/subscription_service_impl/tariffs.py index 58f4c06..5237cc6 100644 --- a/backend/bot/services/subscription_service_impl/tariffs.py +++ b/backend/bot/services/subscription_service_impl/tariffs.py @@ -331,11 +331,12 @@ class TariffMixin: remaining_days = max(0, (sub.end_date - now).days) if sub.end_date else 0 effective = float(sub.effective_monthly_price_rub or 0) current_model = current_tariff.billing_model if current_tariff else "period" + default_currency = default_currency_key_for_settings(self.settings) if current_model == "period" and target_tariff.billing_model == "period": target_monthly = ( - target_tariff.period_price(1, "rub") - or target_tariff.min_period_price_rub() + target_tariff.period_price(1, default_currency) + or target_tariff.min_period_price(default_currency) or effective or 1 ) @@ -355,11 +356,14 @@ class TariffMixin: "remaining_days": remaining_days, "recalc_days": max(0, days_after), "paid_diff_rub": paid_diff, + "paid_diff": paid_diff, "target_monthly_rub": float(target_monthly), + "target_monthly_price": float(target_monthly), + "currency": default_currency, } if current_model == "period" and target_tariff.billing_model == "traffic": - rub_per_gb = target_tariff.rub_per_gb_for_conversion() + rub_per_gb = target_tariff.currency_per_gb_for_conversion(default_currency) remaining_value = remaining_days * (effective / 30) if effective else 0 converted_gb = math.floor(remaining_value / rub_per_gb) if rub_per_gb else 0 return { @@ -367,9 +371,15 @@ class TariffMixin: "remaining_days": remaining_days, "converted_gb": max(0, converted_gb), "rub_per_gb": rub_per_gb, + "currency_per_gb": rub_per_gb, + "currency": default_currency, } - return {"mode": "traffic_to_period", "remaining_days": remaining_days} + return { + "mode": "traffic_to_period", + "remaining_days": remaining_days, + "currency": default_currency, + } @staticmethod def _aware_utc(value: Optional[datetime]) -> Optional[datetime]: @@ -434,6 +444,7 @@ class TariffMixin: credit = await self._hwid_conversion_credit(session, sub, at=now) value_rub = float(credit.get("value_rub") or 0) options["converted_hwid_value_rub"] = round(value_rub, 2) + options["converted_hwid_value"] = round(value_rub, 2) options["convertible_hwid_purchase_ids"] = list(credit.get("purchase_ids") or []) options["nonconverted_hwid_devices"] = int(credit.get("skipped_devices") or 0) if value_rub <= 0: diff --git a/backend/config/tariffs_config.py b/backend/config/tariffs_config.py index d691313..f9af1d4 100644 --- a/backend/config/tariffs_config.py +++ b/backend/config/tariffs_config.py @@ -1,14 +1,53 @@ import json import logging from pathlib import Path -from typing import Dict, List, Literal, Optional +from typing import Any, Dict, List, Literal, Optional -from pydantic import BaseModel, Field, ValidationError, model_validator +from pydantic import BaseModel, Field, RootModel, ValidationError, model_validator -Currency = Literal["rub", "stars"] +DEFAULT_TARIFF_CURRENCY = "rub" +STARS_TARIFF_CURRENCY = "stars" + +Currency = str BillingModel = Literal["period", "traffic"] +def normalize_currency_key(value: Any, default: str = DEFAULT_TARIFF_CURRENCY) -> str: + text = str(value or "").strip().lower() + if not text: + return default + aliases = { + "rur": "rub", + "xtr": STARS_TARIFF_CURRENCY, + "star": STARS_TARIFF_CURRENCY, + "stars": STARS_TARIFF_CURRENCY, + } + normalized = aliases.get(text, text) + cleaned = "".join(ch for ch in normalized if ch.isalnum() or ch in {"_", "-"}).strip("_-") + return cleaned or default + + +def payment_currency_code(currency: Any, default: str = "RUB") -> str: + key = normalize_currency_key(currency, default=normalize_currency_key(default)) + if key == STARS_TARIFF_CURRENCY: + return "XTR" + return key.upper() + + +def default_currency_key_for_settings(settings: Any) -> str: + try: + config = getattr(settings, "tariffs_config", None) + except Exception: + config = None + if config is not None and getattr(config, "default_currency", None): + return normalize_currency_key(config.default_currency) + return normalize_currency_key(getattr(settings, "DEFAULT_CURRENCY_SYMBOL", None)) + + +def default_payment_currency_code_for_settings(settings: Any) -> str: + return payment_currency_code(default_currency_key_for_settings(settings)) + + class TrafficPackage(BaseModel): gb: float price: float @@ -61,26 +100,74 @@ class HwidDevicePackage(BaseModel): return float(self.price) * months_int -class PackageSet(BaseModel): - rub: List[TrafficPackage] = Field(default_factory=list) - stars: List[TrafficPackage] = Field(default_factory=list) +class PackageSet(RootModel[Dict[str, List[TrafficPackage]]]): + root: Dict[str, List[TrafficPackage]] = Field(default_factory=dict) + + @model_validator(mode="before") + @classmethod + def normalize_input(cls, data: Any) -> Any: + if data is None: + return {} + if not isinstance(data, dict): + return data + normalized: Dict[str, Any] = {} + for currency, packages in data.items(): + key = normalize_currency_key(currency, default="") + if not key: + raise ValueError("package currency must not be empty") + normalized[key] = packages or [] + return normalized def for_currency(self, currency: Currency) -> List[TrafficPackage]: - return list(getattr(self, currency) or []) + return list(self.root.get(normalize_currency_key(currency), []) or []) + + @property + def rub(self) -> List[TrafficPackage]: + return self.for_currency("rub") + + @property + def stars(self) -> List[TrafficPackage]: + return self.for_currency("stars") + + @property + def non_stars_currencies(self) -> List[str]: + return [currency for currency, packages in self.root.items() if currency != "stars" and packages] def has_any(self) -> bool: - return bool(self.rub or self.stars) + return any(bool(packages) for packages in self.root.values()) -class HwidDevicePackageSet(BaseModel): - rub: List[HwidDevicePackage] = Field(default_factory=list) - stars: List[HwidDevicePackage] = Field(default_factory=list) +class HwidDevicePackageSet(RootModel[Dict[str, List[HwidDevicePackage]]]): + root: Dict[str, List[HwidDevicePackage]] = Field(default_factory=dict) + + @model_validator(mode="before") + @classmethod + def normalize_input(cls, data: Any) -> Any: + if data is None: + return {} + if not isinstance(data, dict): + return data + normalized: Dict[str, Any] = {} + for currency, packages in data.items(): + key = normalize_currency_key(currency, default="") + if not key: + raise ValueError("device package currency must not be empty") + normalized[key] = packages or [] + return normalized def for_currency(self, currency: Currency) -> List[HwidDevicePackage]: - return list(getattr(self, currency) or []) + return list(self.root.get(normalize_currency_key(currency), []) or []) + + @property + def rub(self) -> List[HwidDevicePackage]: + return self.for_currency("rub") + + @property + def stars(self) -> List[HwidDevicePackage]: + return self.for_currency("stars") def has_any(self) -> bool: - return bool(self.rub or self.stars) + return any(bool(packages) for packages in self.root.values()) class Tariff(BaseModel): @@ -93,6 +180,7 @@ class Tariff(BaseModel): enabled: bool = True monthly_gb: Optional[float] = None + prices: Dict[str, Dict[str, float]] = Field(default_factory=dict) prices_rub: Dict[str, float] = Field(default_factory=dict) prices_stars: Dict[str, float] = Field(default_factory=dict) referral_bonus_days_inviter: Dict[str, int] = Field(default_factory=dict) @@ -101,6 +189,7 @@ class Tariff(BaseModel): topup_packages: Optional[PackageSet] = None traffic_packages: Optional[PackageSet] = None + conversion_rate_per_gb: Optional[float] = None conversion_rate_rub_per_gb: Optional[float] = None hwid_device_limit: Optional[int] = None hwid_device_packages: Optional[HwidDevicePackageSet] = None @@ -128,6 +217,26 @@ class Tariff(BaseModel): if self.premium_monthly_gb and self.premium_monthly_gb > 0 and not self.premium_squad_uuids: raise ValueError(f"tariff {self.key}: premium_monthly_gb requires premium_squad_uuids") + self.prices = self._normalize_prices_by_currency(self.prices) + self.prices_rub = self._normalize_period_price_map(self.prices_rub, "prices_rub") + self.prices_stars = self._normalize_period_price_map( + self.prices_stars, + "prices_stars", + ) + if self.prices_rub: + self.prices["rub"] = dict(self.prices_rub) + elif self.prices.get("rub"): + self.prices_rub = dict(self.prices["rub"]) + if self.prices_stars: + self.prices["stars"] = dict(self.prices_stars) + elif self.prices.get("stars"): + self.prices_stars = dict(self.prices["stars"]) + + if self.conversion_rate_per_gb is None and self.conversion_rate_rub_per_gb is not None: + self.conversion_rate_per_gb = float(self.conversion_rate_rub_per_gb) + if self.conversion_rate_per_gb is not None and self.conversion_rate_per_gb <= 0: + raise ValueError(f"traffic tariff {self.key}: conversion_rate_per_gb must be > 0") + if self.billing_model == "period": if self.monthly_gb is None or self.monthly_gb < 0: raise ValueError(f"period tariff {self.key}: monthly_gb must be >= 0") @@ -144,24 +253,54 @@ class Tariff(BaseModel): for months in self.enabled_periods: if months <= 0: raise ValueError(f"period tariff {self.key}: enabled periods must be positive") - rub_price = self.prices_rub.get(str(months), 0) or 0 - stars_price = self.prices_stars.get(str(months), 0) or 0 - if rub_price <= 0 and stars_price <= 0: + period_prices = [ + float(prices.get(str(months), 0) or 0) for prices in self.prices.values() + ] + if not any(price > 0 for price in period_prices): raise ValueError( - f"period tariff {self.key}: period {months} needs a non-zero rub or stars price" # noqa: E501 + f"period tariff {self.key}: period {months} needs a non-zero price" ) return self if not self.traffic_packages or not self.traffic_packages.has_any(): raise ValueError(f"traffic tariff {self.key}: traffic_packages is required") - if self.conversion_rate_rub_per_gb is not None and self.conversion_rate_rub_per_gb <= 0: - raise ValueError(f"traffic tariff {self.key}: conversion_rate_rub_per_gb must be > 0") - if not self.traffic_packages.rub and self.conversion_rate_rub_per_gb is None: + if not self.traffic_packages.non_stars_currencies and self.conversion_rate_per_gb is None: raise ValueError( - f"traffic tariff {self.key}: conversion_rate_rub_per_gb is required without RUB packages" # noqa: E501 + f"traffic tariff {self.key}: conversion_rate_per_gb is required without fiat packages" # noqa: E501 ) return self + def _normalize_period_price_map( + self, + values: Dict[str, float], + field_name: str, + ) -> Dict[str, float]: + normalized: Dict[str, float] = {} + for period, value in (values or {}).items(): + try: + months = int(float(str(period).strip())) + price = float(value) + except (TypeError, ValueError) as exc: + raise ValueError(f"tariff {self.key}: {field_name} contains invalid entry") from exc + if months <= 0: + raise ValueError(f"tariff {self.key}: {field_name} periods must be positive") + if price < 0: + raise ValueError(f"tariff {self.key}: {field_name} prices must be >= 0") + normalized[str(months)] = price + return normalized + + def _normalize_prices_by_currency( + self, + values: Dict[str, Dict[str, float]], + ) -> Dict[str, Dict[str, float]]: + normalized: Dict[str, Dict[str, float]] = {} + for currency, price_map in (values or {}).items(): + key = normalize_currency_key(currency, default="") + if not key: + raise ValueError(f"tariff {self.key}: price currency must not be empty") + normalized[key] = self._normalize_period_price_map(price_map or {}, f"prices.{key}") + return normalized + def _normalize_referral_bonus_map( self, values: Dict[str, int], field_name: str ) -> Dict[str, int]: @@ -196,7 +335,7 @@ class Tariff(BaseModel): return int(float(self.monthly_gb) * (1024**3)) def period_price(self, months: int, currency: Currency = "rub") -> Optional[float]: - source = self.prices_rub if currency == "rub" else self.prices_stars + source = self.prices.get(normalize_currency_key(currency), {}) value = source.get(str(months)) return float(value) if value is not None else None @@ -208,24 +347,40 @@ class Tariff(BaseModel): value = self.referral_bonus_days_referee.get(str(int(months))) return int(value) if value is not None else None - def min_period_price_rub(self) -> Optional[float]: + def min_period_price(self, currency: Currency = "rub") -> Optional[float]: + key = normalize_currency_key(currency) + source = self.prices.get(key, {}) prices = [ - float(self.prices_rub[str(months)]) + float(source[str(months)]) for months in self.enabled_periods - if self.prices_rub.get(str(months), 0) and self.prices_rub.get(str(months), 0) > 0 + if source.get(str(months), 0) and source.get(str(months), 0) > 0 ] return min(prices) if prices else None - def min_traffic_package_rub(self) -> Optional[TrafficPackage]: - packages = self.traffic_packages.rub if self.traffic_packages else [] + def min_period_price_rub(self) -> Optional[float]: + return self.min_period_price("rub") + + def min_traffic_package(self, currency: Currency = "rub") -> Optional[TrafficPackage]: + packages = self.traffic_packages.for_currency(currency) if self.traffic_packages else [] return min(packages, key=lambda pkg: pkg.price) if packages else None - def rub_per_gb_for_conversion(self) -> float: - if self.conversion_rate_rub_per_gb: - return float(self.conversion_rate_rub_per_gb) - packages = self.traffic_packages.rub if self.traffic_packages else [] + def min_traffic_package_rub(self) -> Optional[TrafficPackage]: + return self.min_traffic_package("rub") + + def currency_per_gb_for_conversion(self, currency: Currency = "rub") -> float: + if self.conversion_rate_per_gb: + return float(self.conversion_rate_per_gb) + packages = self.traffic_packages.for_currency(currency) if self.traffic_packages else [] + if not packages and self.traffic_packages: + for key in self.traffic_packages.non_stars_currencies: + packages = self.traffic_packages.for_currency(key) + if packages: + break return min(float(pkg.price) / float(pkg.gb) for pkg in packages) + def rub_per_gb_for_conversion(self) -> float: + return self.currency_per_gb_for_conversion("rub") + def has_hwid_device_packages(self) -> bool: return bool(self.hwid_device_packages and self.hwid_device_packages.has_any()) @@ -244,11 +399,15 @@ class Tariff(BaseModel): class TariffsConfig(BaseModel): default_tariff: str + default_currency: str = DEFAULT_TARIFF_CURRENCY topup_packages_default: Optional[PackageSet] = None tariffs: List[Tariff] @model_validator(mode="after") def validate_config(self) -> "TariffsConfig": + self.default_currency = normalize_currency_key(self.default_currency) + if self.default_currency == STARS_TARIFF_CURRENCY: + raise ValueError("default_currency must be a non-Stars payment currency") keys = [tariff.key for tariff in self.tariffs] if len(keys) != len(set(keys)): raise ValueError("tariff keys must be unique") @@ -277,6 +436,10 @@ class TariffsConfig(BaseModel): def default(self) -> Tariff: return self.require(self.default_tariff) + @property + def default_payment_currency_code(self) -> str: + return payment_currency_code(self.default_currency) + def topup_packages_for(self, tariff: Tariff) -> Optional[PackageSet]: if tariff.billing_model == "traffic": return tariff.traffic_packages diff --git a/data/tariffs.example.json b/data/tariffs.example.json index 03262a3..c40ea0a 100644 --- a/data/tariffs.example.json +++ b/data/tariffs.example.json @@ -1,5 +1,6 @@ { "default_tariff": "standard", + "default_currency": "rub", "tariffs": [ { "key": "standard", diff --git a/docs/features/tariffs.md b/docs/features/tariffs.md index 8a618bc..6fcf5cb 100644 --- a/docs/features/tariffs.md +++ b/docs/features/tariffs.md @@ -22,10 +22,10 @@ JSON-каталог может содержать несколько тариф - добавление, редактирование и удаление тарифов; - включение и выключение тарифа на витрине; - выбор тарифа по умолчанию; -- настройка тарифов на срок (`period`): месячный лимит, периоды, RUB/Stars цены, реферальные бонусы и пакеты докупки трафика; -- настройка тарифов по трафику (`traffic`): пакеты GB, RUB/Stars цены, курс конвертации; +- настройка тарифов на срок (`period`): месячный лимит, периоды, цены в платежной валюте/Stars, реферальные бонусы и пакеты докупки трафика; +- настройка тарифов по трафику (`traffic`): пакеты GB, цены в платежной валюте/Stars, курс конвертации; - настройка базовых Internal Squads из списка Remnawave; -- настройка premium-раздела: названия RU/EN, premium Internal Squads, месячный premium-лимит и RUB/Stars пакеты докупки premium-трафика; +- настройка premium-раздела: названия RU/EN, premium Internal Squads, месячный premium-лимит и пакеты докупки premium-трафика в платежной валюте/Stars; - настройка базового HWID-лимита и пакетов докупки устройств. После сохранения изменения применяются к новым запросам Web App сразу, потому что конфиг тарифов загружается из JSON при обращении. Уже созданные подписки сохраняют свой `tariff_key`; при удалении или отключении тарифа проверьте, что активные подписки с этим ключом не требуют дальнейшего продления или смены. @@ -44,6 +44,31 @@ JSON-каталог может содержать несколько тариф В режиме без JSON-каталога наличие `TRAFFIC_PACKAGES` или `STARS_TRAFFIC_PACKAGES` переключает витрину на продажу трафика вместо подписок на срок. +## Валюта каталога и ограничения провайдеров + +JSON-каталог поддерживает `default_currency`. По умолчанию используется `rub`, поэтому существующие каталоги с `prices_rub`, `rub`-пакетами и `.env`-ценами продолжают работать без изменений. Для другой валюты укажите код в нижнем регистре, например `usd`, `eur` или `usdt`, и задайте цены в generic-полях: + +- `prices`: `{ "usd": { "1": 4.99, "3": 12.99 } }`; +- `traffic_packages`, `topup_packages`, `premium_topup_packages`, `hwid_device_packages`: ключ валюты вместо `rub`, например `{ "usd": [{ "gb": 50, "price": 2.5 }] }`; +- `conversion_rate_per_gb`: курс конвертации оплаченной стоимости в GB для выбранной валюты. + +Legacy-поля остаются алиасами: `prices_rub`, `conversion_rate_rub_per_gb` и ключ `rub` автоматически попадают в generic-модель. Telegram Stars остаются отдельной валютой `stars`/`XTR` и не могут быть `default_currency`. + +Платежные провайдеры не принимают произвольный код валюты одинаково. Бот фильтрует способы оплаты и блокирует создание платежа, если текущая валюта каталога не поддерживается провайдером: + +| Провайдер | Валюты по умолчанию | +| --- | --- | +| YooKassa | `RUB` | +| WATA | `RUB`, `USD`, `EUR` | +| FreeKassa | `RUB`, `USD`, `EUR`, `UAH`, `KZT` | +| CryptoPay | fiat: `USD`, `EUR`, `RUB`, `BYN`, `UAH`, `GBP`, `CNY`, `KZT`, `UZS`, `GEL`, `TRY`, `AMD`, `THB`, `INR`, `BRL`, `IDR`, `AZN`, `AED`, `PLN`, `ILS`; crypto: `USDT`, `TON`, `BTC`, `ETH`, `LTC`, `BNB`, `TRX`, `USDC` | +| Heleket | настраиваемый список `HELEKET_SUPPORTED_CURRENCIES` | +| Platega | настраиваемый список `PLATEGA_SUPPORTED_CURRENCIES` | +| SeverPay | настраиваемый список `SEVERPAY_SUPPORTED_CURRENCIES` | +| Telegram Stars | `XTR`, отдельные Stars-цены | + +В админке раздел **Система → Тарифы** показывает текущую платежную валюту и матрицу провайдеров: включен ли метод, настроен ли сервис и будет ли он доступен при выбранной валюте. Для Platega, SeverPay и Heleket список валют нужно держать в соответствии с условиями вашего мерчанта. + ## Структура JSON-каталога Минимальная структура: @@ -51,6 +76,7 @@ JSON-каталог может содержать несколько тариф ```json { "default_tariff": "standard", + "default_currency": "rub", "tariffs": [ { "key": "standard", @@ -90,6 +116,7 @@ JSON-каталог может содержать несколько тариф | Поле | Назначение | | --- | --- | | `default_tariff` | Тариф по умолчанию для первичного выбора и привязки активных подписок без `tariff_key`. | +| `default_currency` | Валюта цен по умолчанию для JSON-каталога. По умолчанию `rub`; `stars` запрещен, потому что Stars используют отдельные цены. | | `tariffs[].key` | Стабильный ключ тарифа. Используется в платежах, подписках и смене тарифа. | | `tariffs[].names` | Названия тарифа по языкам. | | `tariffs[].descriptions` | Описания тарифа по языкам. | @@ -108,7 +135,8 @@ JSON-каталог может содержать несколько тариф | Поле | Назначение | | --- | --- | | `monthly_gb` | Базовый месячный лимит трафика тарифа. `0` означает безлимит. | -| `prices_rub` | Цены периодов в рублях, ключ - количество месяцев. | +| `prices` | Generic-цены периодов по валютам, например `{ "usd": { "1": 4.99 } }`. | +| `prices_rub` | Legacy-цены периодов в рублях, ключ - количество месяцев. Эквивалент `prices.rub`. | | `prices_stars` | Цены периодов в Telegram Stars. | | `referral_bonus_days_inviter` | Бонус пригласившему в днях для каждого периода. Ключ - количество месяцев, как в `enabled_periods`. | | `referral_bonus_days_referee` | Бонус приглашенному в днях для каждого периода. Ключ - количество месяцев, как в `enabled_periods`. | @@ -119,10 +147,11 @@ JSON-каталог может содержать несколько тариф | Поле | Назначение | | --- | --- | -| `traffic_packages` | Пакеты трафика в GB для рублей и Telegram Stars. | -| `conversion_rate_rub_per_gb` | Курс для конвертации оставшихся дней period-тарифа в GB при смене на traffic-тариф. | +| `traffic_packages` | Пакеты трафика в GB по валютам каталога и Telegram Stars. | +| `conversion_rate_per_gb` | Курс для конвертации оставшихся дней period-тарифа в GB при смене на traffic-тариф в валюте каталога. | +| `conversion_rate_rub_per_gb` | Legacy-алиас для рублевых каталогов. | -Если у traffic-тарифа нет RUB-пакетов, `conversion_rate_rub_per_gb` обязателен. +Если у traffic-тарифа нет пакетов в `default_currency`, `conversion_rate_per_gb` обязателен. ## Тарифы на срок (`period`) @@ -248,10 +277,10 @@ limit_after = current_used + balance_after - при безлимитном базовом лимите докупка устройств не применяется; - полная цена HWID-пакета берется из `prices[duration_months]`; если периода нет, используется fallback `price * duration_months`; - фактическая цена докупки считается пропорционально оплачиваемому окну `valid_from -> valid_until` относительно периода подписки и фиксируется в платежe; -- для Telegram Stars цена округляется вверх до целого Stars, для RUB — вверх до копеек; `min_price` защищает от микроплатежей в конце периода; +- для Telegram Stars цена округляется вверх до целого Stars, для платежной валюты — вверх до копеек; `min_price` защищает от микроплатежей в конце периода; - при продлении подписки докупленные устройства не продлеваются автоматически: старая докупка действует до прежнего `end_date`, а для нового срока создается отдельная `hwid_devices_renewal`-покупка; - `traffic`-тарифы не показывают и не принимают докупку HWID-устройств, потому что у них нет срока подписки; -- при смене тарифа базовый лимит берется из целевого тарифа, а неиспользованная RUB-стоимость HWID-докупок конвертируется в дни нового period-тарифа или GB traffic-тарифа; XTR/Stars-докупки не конвертируются без явного курса и продолжают жить по своему `valid_until`; +- при смене тарифа базовый лимит берется из целевого тарифа, а неиспользованная стоимость HWID-докупок в платежной валюте конвертируется в дни нового period-тарифа или GB traffic-тарифа; XTR/Stars-докупки не конвертируются без явного курса и продолжают жить по своему `valid_until`; - история докупок пишется в `hwid_device_purchases`; - платеж хранит количество устройств в `payments.purchased_hwid_devices`. @@ -265,9 +294,9 @@ limit_after = current_used + balance_after | Переход | Поведение | | --- | --- | -| `period -> period` | Остаток оплаченных дней оценивается по `effective_monthly_price_rub`, затем пересчитывается в дни целевого тарифа через месячную цену целевого тарифа. Неиспользованная RUB-стоимость HWID-докупок добавляется к этому расчету как дополнительные дни. Количество дней округляется вниз. | -| `period -> period` с доплатой | Если целевой тариф дороже, может быть создан платеж `tariff_upgrade`; неиспользованная RUB-стоимость HWID-докупок уменьшает сумму доплаты. После оплаты применяется целевой тариф, а конвертированные HWID-окна закрываются. | -| `period -> traffic` | Остаток оплаченных дней и неиспользованная RUB-стоимость HWID-докупок конвертируются в GB по `conversion_rate_rub_per_gb` или минимальной RUB-цене GB из пакетов целевого тарифа. | +| `period -> period` | Остаток оплаченных дней оценивается по legacy-полю `effective_monthly_price_rub`, где хранится месячная цена в платежной валюте каталога, затем пересчитывается в дни целевого тарифа через месячную цену целевого тарифа. Неиспользованная стоимость HWID-докупок в платежной валюте добавляется к этому расчету как дополнительные дни. Количество дней округляется вниз. | +| `period -> period` с доплатой | Если целевой тариф дороже, может быть создан платеж `tariff_upgrade`; неиспользованная стоимость HWID-докупок в платежной валюте уменьшает сумму доплаты. После оплаты применяется целевой тариф, а конвертированные HWID-окна закрываются. | +| `period -> traffic` | Остаток оплаченных дней и неиспользованная стоимость HWID-докупок в платежной валюте конвертируются в GB по `conversion_rate_per_gb` или минимальной цене GB из пакетов целевого тарифа. | | `traffic -> period` | Пользователь выбирает и оплачивает период целевого тарифа; остаток GB сохраняется как `topup_balance_bytes` поверх лимита period-тарифа. | При смене тарифа бот меняет: diff --git a/frontend/src/admin/sections/TariffEditorModal.svelte b/frontend/src/admin/sections/TariffEditorModal.svelte index b3bc527..1e1e1c9 100644 --- a/frontend/src/admin/sections/TariffEditorModal.svelte +++ b/frontend/src/admin/sections/TariffEditorModal.svelte @@ -5,7 +5,7 @@ import { Plus, Save, Trash2, X } from "$components/ui/icons.js"; import { AdminButton, AdminSelect } from "$components/patterns/admin/index.js"; import { getContext } from "svelte"; - import { normalizeUuidList } from "../../lib/admin/tariffDraft.js"; + import { normalizeCurrencyKey, normalizeUuidList } from "../../lib/admin/tariffDraft.js"; export let at; const tariffsStore = getContext("tariffsStore"); @@ -19,6 +19,7 @@ tariffDeleteTarget, panelSquadsLoading, panelSquads, + tariffsCatalog, } = $tariffsStore); $: billingModelOptions = [ @@ -29,6 +30,33 @@ value: squad.uuid, label: squad.name, })); + $: defaultCurrencyKey = normalizeCurrencyKey(tariffsCatalog?.default_currency || "rub"); + $: defaultCurrencyCode = defaultCurrencyKey.toUpperCase(); + $: currencyPackageLabel = at( + "tariff_btn_package_currency", + { currency: defaultCurrencyCode }, + `Пакет ${defaultCurrencyCode}` + ); + $: currencyPaymentLabel = at( + "payment_default_currency", + { currency: defaultCurrencyCode }, + `Оплата ${defaultCurrencyCode}` + ); + $: currencyPriceColumnLabel = at( + "tariff_col_price_currency", + { currency: defaultCurrencyCode }, + `Цена, ${defaultCurrencyCode}` + ); + $: currencyPriceAriaLabel = at( + "tariff_label_price_currency", + { currency: defaultCurrencyCode }, + `Цена в ${defaultCurrencyCode}` + ); + $: conversionCurrencyLabel = at( + "tariff_label_conversion_currency", + { currency: defaultCurrencyCode }, + `Курс конвертации, ${defaultCurrencyCode} за 1 GB` + ); {:else} - {at("tariff_label_conversion", {}, "Курс конвертации, ₽ за 1 GB")} + {conversionCurrencyLabel} {at( "tariff_hint_conversion", @@ -388,7 +416,7 @@ tariffsStore.addDraftRow("premiumTopupRubRows", { gb: 10, price: "" })} - > {at("tariff_btn_package_rub", {}, "Пакет ₽")} {currencyPackageLabel}
- {at("payment_rub", {}, "Оплата рублями")} + {currencyPaymentLabel} {#if tariffDraft.premiumTopupRubRows.length}
{at("tariff_col_volume_gb", {}, "Объём, GB")} - {at("tariff_col_price_rub", {}, "Цена, ₽")} + {currencyPriceColumnLabel}
{/if} @@ -426,7 +454,7 @@ step="0.01" placeholder="199" bind:value={row.price} - aria-label={at("tariff_label_price_rub", {}, "Цена premium-пакета в рублях")} + aria-label={currencyPriceAriaLabel} />
{at("tariff_col_period_months", {}, "Срок, мес.")} - {at("tariff_col_price_rub", {}, "Цена, ₽")} + {currencyPriceColumnLabel} {at("tariff_col_price_stars_full", {}, "Цена, ⭐ Stars")} {at("tariff_col_ref_inviter", {}, "Бонус приглашающему")} {at("tariff_col_ref_referee", {}, "Бонус приглашённому")} @@ -549,7 +577,7 @@ step="0.01" placeholder="299" bind:value={row.rub} - aria-label={at("tariff_label_price_rub", {}, "Цена в рублях")} + aria-label={currencyPriceAriaLabel} /> tariffsStore.addDraftRow("trafficRubRows", { gb: 10, price: "" })} - > {at("tariff_btn_package_rub", {}, "Пакет ₽")} {currencyPackageLabel}
- {at("payment_rub", {}, "Оплата рублями")} + {currencyPaymentLabel} {#if tariffDraft.trafficRubRows.length}
{at("tariff_col_volume_gb", {}, "Объём, GB")} - {at("tariff_col_price_rub", {}, "Цена, ₽")} + {currencyPriceColumnLabel}
{/if} @@ -646,7 +673,7 @@ step="0.01" placeholder="299" bind:value={row.price} - aria-label={at("tariff_label_price_rub", {}, "Цена пакета в рублях")} + aria-label={currencyPriceAriaLabel} /> tariffsStore.addDraftRow("topupRubRows", { gb: 10, price: "" })} - > {at("tariff_btn_package_rub", {}, "Пакет ₽")} {currencyPackageLabel}
- {at("payment_rub", {}, "Оплата рублями")} + {currencyPaymentLabel} {#if tariffDraft.topupRubRows.length}
{at("tariff_col_volume_gb", {}, "Объём, GB")} - {at("tariff_col_price_rub", {}, "Цена, ₽")} + {currencyPriceColumnLabel}
{/if} @@ -760,7 +786,7 @@ step="0.01" placeholder="149" bind:value={row.price} - aria-label={at("tariff_label_price_rub", {}, "Цена пакета в рублях")} + aria-label={currencyPriceAriaLabel} /> tariffsStore.addDraftRow("hwidRubRows", { count: 1, price: "" })} - > {at("tariff_btn_package_rub", {}, "Пакет ₽")} {currencyPackageLabel}
- {at("payment_rub", {}, "Оплата рублями")} + {currencyPaymentLabel} {#if tariffDraft.hwidRubRows.length}
{at("tariff_col_hwid_count", {}, "+ устройств")} - {at("tariff_col_price_rub", {}, "Цена, ₽")} + {currencyPriceColumnLabel}
{/if} @@ -888,7 +914,7 @@ step="0.01" placeholder="99" bind:value={row.price} - aria-label={at("tariff_label_price_rub", {}, "Цена пакета в рублях")} + aria-label={currencyPriceAriaLabel} /> a - b); return months .map((month) => { - const rub = tariff.prices_rub?.[String(month)]; + const rub = + (currency === "rub" ? tariff.prices_rub?.[String(month)] : undefined) ?? + tariff.prices?.[currency]?.[String(month)]; const stars = tariff.prices_stars?.[String(month)]; - if (rub) return `${month} ${at("months_short", {}, "мес.")} ${fmtMoney(rub, "RUB")}`; + if (rub) return `${month} ${at("months_short", {}, "мес.")} ${fmtMoney(rub, currencyCode)}`; if (stars) return `${month} ${at("months_short", {}, "мес.")} ${stars} ⭐`; return `${month} ${at("months_short", {}, "мес.")}`; }) @@ -203,6 +210,27 @@ trialSquadSelectKey += 1; } + $: catalogCurrencyKey = normalizeCurrencyKey(tariffsCatalog.default_currency || "rub"); + $: defaultCurrencyDraft = catalogCurrencyKey.toUpperCase(); + $: defaultCurrencyDraftKey = normalizeCurrencyKey(defaultCurrencyDraft || "rub"); + $: defaultCurrencyDirty = defaultCurrencyDraftKey !== catalogCurrencyKey; + + async function saveDefaultCurrency() { + await tariffsStore.setDefaultCurrency(defaultCurrencyDraft); + } + + function providerCurrencyLabel(provider) { + if (provider.accepts_any_currency) return at("tariff_provider_any_currency", {}, "Любая"); + return ( + (provider.currencies || []).join(", ") || at("tariff_provider_not_declared", {}, "Не задано") + ); + } + + function providerCurrencyVariant(provider) { + if (!provider.enabled || !provider.configured) return "muted"; + return provider.supports_default_currency ? "success" : "warning"; + } + function removeTrialSquad(uuid) { setCsvList( "TRIAL_SQUAD_UUIDS", @@ -653,6 +681,62 @@
+
+ + {catalogCurrencyKey.toUpperCase()} + {#if defaultCurrencyDirty} + + + {tariffsSaving + ? at("btn_saving", {}, "Сохранение...") + : at("btn_save", {}, "Сохранить")} + + {/if} +
+ {#if providerCurrencySupport?.length} +
+ {#each providerCurrencySupport as provider} +
+
+ {provider.label} + {providerCurrencyLabel(provider)} +
+ + {#if !provider.enabled} + {at("disabled", {}, "Отключен")} + {:else if !provider.configured} + {at("status_not_configured", {}, "Не настроен")} + {:else if provider.supports_default_currency} + {at("tariff_currency_supported", {}, "Доступен")} + {:else} + {at("tariff_currency_unsupported", {}, "Заблокирован")} + {/if} + +
+ {/each} +
+ {/if} {#if !tariffsCatalog.tariffs.length} {at( diff --git a/frontend/src/lib/admin/stores/tariffsStore.js b/frontend/src/lib/admin/stores/tariffsStore.js index 0696957..7a9297e 100644 --- a/frontend/src/lib/admin/stores/tariffsStore.js +++ b/frontend/src/lib/admin/stores/tariffsStore.js @@ -4,6 +4,7 @@ import { cloneCatalog, draftFromTariff, tariffFromDraft as tariffFromDraftFn, + normalizeCurrencyKey, normalizeUuidList, } from "../tariffDraft.js"; @@ -11,6 +12,7 @@ export function createTariffsStore({ api, onTariffsSaved, flash, at }) { const state = writable({ tariffsCatalog: { default_tariff: "", + default_currency: "rub", topup_packages_default: { rub: [], stars: [] }, tariffs: [], }, @@ -23,13 +25,15 @@ export function createTariffsStore({ api, onTariffsSaved, flash, at }) { tariffDeleteTarget: null, tariffDraft: emptyTariffDraft(), panelSquads: [], + providerCurrencySupport: [], panelSquadsLoading: false, selectedBaseSquad: "", selectedPremiumSquad: "", tariffEditorTab: "general", }); - const tariffFromDraft = (draft) => tariffFromDraftFn(draft); + const tariffFromDraft = (draft, defaultCurrency = "rub") => + tariffFromDraftFn(draft, defaultCurrency); async function loadTariffs() { state.update((s) => ({ ...s, tariffsLoading: true })); @@ -41,6 +45,7 @@ export function createTariffsStore({ api, onTariffsSaved, flash, at }) { ...s, tariffsCatalog: cloneCatalog(data.catalog), tariffsPath: data.path || "", + providerCurrencySupport: data.provider_currency_support || [], })); } else { flash(data?.message || data?.error || at("load_failed", {}, "Не удалось загрузить тарифы")); @@ -119,6 +124,7 @@ export function createTariffsStore({ api, onTariffsSaved, flash, at }) { ...s, tariffsCatalog: cloneCatalog(res.catalog), tariffsPath: res.path || currentPath, + providerCurrencySupport: res.provider_currency_support || s.providerCurrencySupport || [], tariffEditorOpen: false, tariffDeleteOpen: false, tariffDeleteTarget: null, @@ -139,7 +145,10 @@ export function createTariffsStore({ api, onTariffsSaved, flash, at }) { state.update((s) => ({ ...s, tariffEditingKey: "", - tariffDraft: emptyTariffDraft(), + tariffDraft: { + ...emptyTariffDraft(), + defaultCurrency: s.tariffsCatalog.default_currency || "rub", + }, tariffEditorTab: "general", selectedBaseSquad: "", selectedPremiumSquad: "", @@ -151,7 +160,7 @@ export function createTariffsStore({ api, onTariffsSaved, flash, at }) { state.update((s) => ({ ...s, tariffEditingKey: tariff.key, - tariffDraft: draftFromTariff(tariff), + tariffDraft: draftFromTariff(tariff, s.tariffsCatalog.default_currency || "rub"), tariffEditorTab: "general", selectedBaseSquad: "", selectedPremiumSquad: "", @@ -165,7 +174,7 @@ export function createTariffsStore({ api, onTariffsSaved, flash, at }) { s = st; return st; }); - const tariff = tariffFromDraft(s.tariffDraft); + const tariff = tariffFromDraft(s.tariffDraft, s.tariffsCatalog.default_currency || "rub"); if (!tariff.key) { flash(at("tariff_error_key_required", {}, "Укажите ключ тарифа")); return; @@ -233,6 +242,24 @@ export function createTariffsStore({ api, onTariffsSaved, flash, at }) { ); } + async function setDefaultCurrency(value) { + const currency = normalizeCurrencyKey(value || "rub"); + if (!currency || currency === "stars") { + flash(at("tariff_currency_invalid", {}, "Укажите фиатную или криптовалюту, но не Stars")); + return; + } + let s; + state.update((st) => { + s = st; + return st; + }); + if (currency === normalizeCurrencyKey(s.tariffsCatalog.default_currency || "rub")) return; + await persistTariffs( + { ...cloneCatalog(s.tariffsCatalog), default_currency: currency }, + at("tariff_currency_updated", {}, "Валюта оплаты обновлена") + ); + } + async function deleteTariff() { let s; state.update((st) => { @@ -295,6 +322,7 @@ export function createTariffsStore({ api, onTariffsSaved, flash, at }) { saveTariffDraft, toggleTariffEnabled, setDefaultTariff, + setDefaultCurrency, deleteTariff, addDraftRow, removeDraftRow, diff --git a/frontend/src/lib/admin/tariffDraft.js b/frontend/src/lib/admin/tariffDraft.js index 1430753..5f0df1d 100644 --- a/frontend/src/lib/admin/tariffDraft.js +++ b/frontend/src/lib/admin/tariffDraft.js @@ -2,6 +2,7 @@ import { structuredCloneSafe } from "./format.js"; export function emptyTariffDraft() { return { + defaultCurrency: "rub", key: "", nameRu: "", nameEn: "", @@ -40,11 +41,22 @@ export function emptyTariffDraft() { export function cloneCatalog(catalog) { return structuredCloneSafe({ default_tariff: catalog?.default_tariff || "", + default_currency: normalizeCurrencyKey(catalog?.default_currency || "rub"), topup_packages_default: catalog?.topup_packages_default || { rub: [], stars: [] }, tariffs: catalog?.tariffs || [], }); } +export function normalizeCurrencyKey(value, fallback = "rub") { + const text = String(value || "") + .trim() + .toLowerCase(); + if (!text) return fallback; + if (text === "rur") return "rub"; + if (["xtr", "star", "stars"].includes(text)) return "stars"; + return text.replace(/[^a-z0-9_-]/g, "") || fallback; +} + export function rowsFromPackages(packageSet, currency, valueKey) { return (packageSet?.[currency] || []).map((pkg) => ({ [valueKey]: pkg[valueKey], @@ -54,10 +66,13 @@ export function rowsFromPackages(packageSet, currency, valueKey) { })); } -export function draftFromTariff(tariff) { +export function draftFromTariff(tariff, defaultCurrency = "rub") { + const currency = normalizeCurrencyKey(defaultCurrency); + const defaultPrices = tariff.prices?.[currency] || {}; const months = new Set([ ...(tariff.enabled_periods || []), - ...Object.keys(tariff.prices_rub || {}).map(Number), + ...Object.keys(defaultPrices).map(Number), + ...(currency === "rub" ? Object.keys(tariff.prices_rub || {}).map(Number) : []), ...Object.keys(tariff.prices_stars || {}).map(Number), ]); const periodRows = [...months] @@ -65,7 +80,10 @@ export function draftFromTariff(tariff) { .sort((a, b) => a - b) .map((month) => ({ months: month, - rub: tariff.prices_rub?.[String(month)] ?? "", + rub: + (currency === "rub" ? tariff.prices_rub?.[String(month)] : undefined) ?? + defaultPrices?.[String(month)] ?? + "", stars: tariff.prices_stars?.[String(month)] ?? "", referral_inviter: tariff.referral_bonus_days_inviter?.[String(month)] ?? "", referral_referee: tariff.referral_bonus_days_referee?.[String(month)] ?? "", @@ -73,6 +91,7 @@ export function draftFromTariff(tariff) { return { ...emptyTariffDraft(), + defaultCurrency: currency, key: tariff.key || "", nameRu: tariff.names?.ru || "", nameEn: tariff.names?.en || "", @@ -89,13 +108,13 @@ export function draftFromTariff(tariff) { hwid_device_limit: tariff.hwid_device_limit ?? "", conversion_rate_rub_per_gb: tariff.conversion_rate_rub_per_gb ?? "", periodRows: periodRows.length ? periodRows : emptyTariffDraft().periodRows, - topupRubRows: rowsFromPackages(tariff.topup_packages, "rub", "gb"), + topupRubRows: rowsFromPackages(tariff.topup_packages, currency, "gb"), topupStarsRows: rowsFromPackages(tariff.topup_packages, "stars", "gb"), - premiumTopupRubRows: rowsFromPackages(tariff.premium_topup_packages, "rub", "gb"), + premiumTopupRubRows: rowsFromPackages(tariff.premium_topup_packages, currency, "gb"), premiumTopupStarsRows: rowsFromPackages(tariff.premium_topup_packages, "stars", "gb"), - trafficRubRows: rowsFromPackages(tariff.traffic_packages, "rub", "gb"), + trafficRubRows: rowsFromPackages(tariff.traffic_packages, currency, "gb"), trafficStarsRows: rowsFromPackages(tariff.traffic_packages, "stars", "gb"), - hwidRubRows: rowsFromPackages(tariff.hwid_device_packages, "rub", "count"), + hwidRubRows: rowsFromPackages(tariff.hwid_device_packages, currency, "count"), hwidStarsRows: rowsFromPackages(tariff.hwid_device_packages, "stars", "count"), }; } @@ -136,10 +155,15 @@ export function packagesFromRows(rows, valueKey) { .filter((row) => row[valueKey] > 0 && row.price !== null && row.price >= 0); } -export function packageSetFromRows(rubRows, starsRows, valueKey) { - const rub = packagesFromRows(rubRows, valueKey); +export function packageSetFromRows(rubRows, starsRows, valueKey, defaultCurrency = "rub") { + const currency = normalizeCurrencyKey(defaultCurrency); + const defaultCurrencyPackages = packagesFromRows(rubRows, valueKey); const stars = packagesFromRows(starsRows, valueKey); - return rub.length || stars.length ? { rub, stars } : null; + if (!defaultCurrencyPackages.length && !stars.length) return null; + return { + ...(defaultCurrencyPackages.length ? { [currency]: defaultCurrencyPackages } : {}), + ...(stars.length ? { stars } : {}), + }; } export function normalizeUuidList(value) { @@ -150,7 +174,8 @@ export function normalizeUuidList(value) { .filter(Boolean); } -export function tariffFromDraft(draft) { +export function tariffFromDraft(draft, fallbackCurrency = "rub") { + const defaultCurrency = normalizeCurrencyKey(draft.defaultCurrency || fallbackCurrency); const key = draft.key.trim(); const names = compactMap({ ru: draft.nameRu.trim(), en: draft.nameEn.trim() }); const descriptions = compactMap({ @@ -174,14 +199,20 @@ export function tariffFromDraft(draft) { const hwidLimit = parseIntNumber(draft.hwid_device_limit); if (hwidLimit !== null) tariff.hwid_device_limit = hwidLimit; - const hwidPackages = packageSetFromRows(draft.hwidRubRows, draft.hwidStarsRows, "count"); + const hwidPackages = packageSetFromRows( + draft.hwidRubRows, + draft.hwidStarsRows, + "count", + defaultCurrency + ); if (hwidPackages) tariff.hwid_device_packages = hwidPackages; const premiumMonthlyGb = parseNumber(draft.premium_monthly_gb); if (premiumMonthlyGb !== null) tariff.premium_monthly_gb = premiumMonthlyGb; const premiumTopupPackages = packageSetFromRows( draft.premiumTopupRubRows, draft.premiumTopupStarsRows, - "gb" + "gb", + defaultCurrency ); if (premiumTopupPackages) tariff.premium_topup_packages = premiumTopupPackages; @@ -204,7 +235,12 @@ export function tariffFromDraft(draft) { .sort((a, b) => a.months - b.months); tariff.monthly_gb = parseNumber(draft.monthly_gb, 0); tariff.enabled_periods = rows.map((row) => row.months); - tariff.prices_rub = Object.fromEntries(rows.map((row) => [String(row.months), row.rub || 0])); + const defaultPrices = Object.fromEntries(rows.map((row) => [String(row.months), row.rub || 0])); + if (defaultCurrency === "rub") { + tariff.prices_rub = defaultPrices; + } else { + tariff.prices = { [defaultCurrency]: defaultPrices }; + } tariff.prices_stars = Object.fromEntries( rows.map((row) => [String(row.months), row.stars || 0]) ); @@ -218,10 +254,20 @@ export function tariffFromDraft(draft) { .filter((row) => row.referral_referee !== null) .map((row) => [String(row.months), row.referral_referee]) ); - const topupPackages = packageSetFromRows(draft.topupRubRows, draft.topupStarsRows, "gb"); + const topupPackages = packageSetFromRows( + draft.topupRubRows, + draft.topupStarsRows, + "gb", + defaultCurrency + ); if (topupPackages) tariff.topup_packages = topupPackages; } else { - const trafficPackages = packageSetFromRows(draft.trafficRubRows, draft.trafficStarsRows, "gb"); + const trafficPackages = packageSetFromRows( + draft.trafficRubRows, + draft.trafficStarsRows, + "gb", + defaultCurrency + ); if (trafficPackages) tariff.traffic_packages = trafficPackages; const conversion = parseNumber(draft.conversion_rate_rub_per_gb); if (conversion !== null) tariff.conversion_rate_rub_per_gb = conversion; diff --git a/frontend/src/styles/admin.css b/frontend/src/styles/admin.css index 3dd00c4..298da7e 100644 --- a/frontend/src/styles/admin.css +++ b/frontend/src/styles/admin.css @@ -3222,6 +3222,70 @@ flex-wrap: wrap; } +.admin-tariff-catalog-bar { + display: flex; + align-items: end; + gap: 10px; + flex-wrap: wrap; + margin-bottom: 12px; +} + +.admin-currency-input { + width: 120px; + text-transform: uppercase; +} + +.admin-provider-currency-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + gap: 8px; + margin-bottom: 14px; +} + +.admin-provider-currency { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + min-width: 0; + padding: 9px 10px; + border: 1px solid var(--admin-border); + border-radius: 8px; + background: var(--admin-surface-2); +} + +.admin-provider-currency.is-supported { + border-color: color-mix(in srgb, var(--success-border) 70%, var(--admin-border)); +} + +.admin-provider-currency.is-unavailable { + opacity: 0.72; +} + +.admin-provider-currency div { + display: grid; + gap: 2px; + min-width: 0; +} + +.admin-provider-currency strong, +.admin-provider-currency small { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.admin-provider-currency strong { + color: var(--admin-text); + font-size: 12px; + font-weight: 700; +} + +.admin-provider-currency small { + color: var(--admin-muted); + font-size: 11px; +} + .admin-tariff-settings-card { margin-top: 14px; } diff --git a/tests/test_payment_provider_registry.py b/tests/test_payment_provider_registry.py index 85465b0..a8bc00a 100644 --- a/tests/test_payment_provider_registry.py +++ b/tests/test_payment_provider_registry.py @@ -232,6 +232,38 @@ def test_payment_method_keyboard_uses_custom_telegram_text_without_changing_call assert button.callback_data == "pay_wata:1:150:subscription" +def test_payment_method_keyboard_filters_providers_by_payment_currency(monkeypatch): + monkeypatch.setenv("YOOKASSA_ENABLED", "True") + monkeypatch.setenv("WATA_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="yookassa,wata", + ) + i18n = SimpleNamespace(gettext=lambda _lang, key, **_kwargs: key) + + markup = get_payment_method_keyboard( + months=1, + price=10, + stars_price=None, + currency_symbol_val="USD", + lang="en", + i18n_instance=i18n, + settings=settings, + ) + + callbacks = [ + button.callback_data for row in markup.inline_keyboard for button in row if button.callback_data + ] + assert "pay_wata:1:10:subscription" in callbacks + assert all(not callback.startswith("pay_yk:") for callback in callbacks) + + def test_admin_only_provider_is_visible_only_to_admins(monkeypatch): from bot.app.web.webapp.serializers import _serialize_payment_methods @@ -288,6 +320,34 @@ def test_admin_only_provider_is_visible_only_to_admins(monkeypatch): assert _serialize_payment_methods(settings, app, "en", is_admin=True)[0]["id"] == "wata" +def test_webapp_payment_methods_filter_by_default_currency(monkeypatch): + from bot.app.web.webapp.serializers import _serialize_payment_methods + + monkeypatch.setenv("YOOKASSA_ENABLED", "True") + monkeypatch.setenv("WATA_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="yookassa,wata", + DEFAULT_CURRENCY_SYMBOL="USD", + STARS_ENABLED=False, + ) + app = { + "yookassa_service": SimpleNamespace(configured=True), + "wata_service": SimpleNamespace(configured=True), + } + + methods = _serialize_payment_methods(settings, app, "en", is_admin=False) + + assert [method["id"] for method in methods] == ["wata"] + + + def test_admin_only_provider_toggle_pairs_are_declared(): pairs = set(provider_admin_only_pairs()) diff --git a/tests/test_tariffs_config.py b/tests/test_tariffs_config.py index a717f75..ad028cb 100644 --- a/tests/test_tariffs_config.py +++ b/tests/test_tariffs_config.py @@ -1,7 +1,7 @@ import json import unittest -from config.tariffs_config import TariffsConfig, load_tariffs_config +from config.tariffs_config import TariffsConfig, load_tariffs_config, normalize_currency_key def _valid_config(): @@ -128,6 +128,27 @@ class TariffsConfigTests(unittest.TestCase): with self.assertRaises(ValueError): TariffsConfig.model_validate(data) + def test_default_currency_prices_load_from_generic_map(self): + data = _valid_config() + data["default_currency"] = "USD" + data["tariffs"][0].pop("prices_rub") + data["tariffs"][0]["prices"] = {"usd": {"1": 4.99}} + data["tariffs"][1]["traffic_packages"] = {"usd": [{"gb": 10, "price": 2.5}]} + + config = TariffsConfig.model_validate(data) + period = config.require("standard") + traffic = config.require("traffic") + + self.assertEqual(config.default_currency, "usd") + self.assertEqual(config.default_payment_currency_code, "USD") + self.assertEqual(period.period_price(1, "usd"), 4.99) + self.assertIsNone(period.period_price(1, "rub")) + self.assertEqual(traffic.traffic_packages.for_currency("usd")[0].price, 2.5) + self.assertEqual(traffic.currency_per_gb_for_conversion("usd"), 0.25) + + def test_currency_symbol_falls_back_to_default_key(self): + self.assertEqual(normalize_currency_key("₽"), "rub") + def test_hwid_device_limit_and_packages_load(self): data = _valid_config() data["tariffs"][0]["hwid_device_limit"] = 5