feat: add multicurrency tariff payments

This commit is contained in:
3252a8
2026-05-31 22:17:28 +03:00
parent df8f2636d2
commit 80e5f0c80d
35 changed files with 1242 additions and 227 deletions
@@ -37,7 +37,7 @@ from bot.services.settings_override_service import (
from bot.utils import MessageContent, send_message_via_queue from bot.utils import MessageContent, send_message_via_queue
from bot.utils.message_queue import get_queue_manager from bot.utils.message_queue import get_queue_manager
from config.settings import Settings 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 ( from db.dal import (
ad_dal, ad_dal,
app_settings_dal, app_settings_dal,
+1 -1
View File
@@ -34,7 +34,7 @@ async def admin_stats_route(request: web.Request) -> web.Response:
except Exception: # pragma: no cover - defensive except Exception: # pragma: no cover - defensive
payload["queue"] = None 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) return _ok(payload)
+48 -1
View File
@@ -21,9 +21,11 @@ async def admin_tariffs_get_route(request: web.Request) -> web.Response:
"path": str(path), "path": str(path),
"catalog": { "catalog": {
"default_tariff": "", "default_tariff": "",
"default_currency": "rub",
"topup_packages_default": {"rub": [], "stars": []}, "topup_packages_default": {"rub": [], "stars": []},
"tariffs": [], "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, "exists": True,
"path": str(path), "path": str(path),
"catalog": _tariffs_config_payload(config), "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=[]) 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
+5
View File
@@ -55,6 +55,11 @@ from bot.utils.text_sanitizer import (
sanitize_username, sanitize_username,
) )
from config.settings import Settings 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 import payment_dal, security_dal, subscription_dal, support_dal, user_dal
from db.dal.user_dal import UserMergeConflictError from db.dal.user_dal import UserMergeConflictError
from db.models import Payment, User, UserTelegramAvatar from db.models import Payment, User, UserTelegramAvatar
+59 -21
View File
@@ -103,6 +103,8 @@ async def create_payment_route(request: web.Request) -> web.Response:
subscription_service: SubscriptionService = request.app["subscription_service"] subscription_service: SubscriptionService = request.app["subscription_service"]
cached = _get_cached_webapp_settings(request) cached = _get_cached_webapp_settings(request)
tariffs_config = settings.tariffs_config 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) traffic_mode = bool(settings.traffic_sale_mode)
sale_mode = "subscription" sale_mode = "subscription"
traffic_gb_for_payment: Optional[float] = None 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" if requested_sale_mode == "premium_topup"
else tariffs_config.topup_packages_for(tariff) else tariffs_config.topup_packages_for(tariff)
) )
rub_packages = { currency_packages = {
float(package.gb): float(package.price) 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 = { stars_packages = {
float(package.gb): int(float(package.price)) float(package.gb): int(float(package.price))
for package in (packages.stars if packages else []) 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) 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_price = (
stars_packages.get(stars_package_key) if stars_package_key is not None else None 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") return _json_error(400, "invalid_plan", "Invalid traffic package")
if traffic_gb <= 0: if traffic_gb <= 0:
return _json_error(400, "invalid_plan", "Invalid traffic package") return _json_error(400, "invalid_plan", "Invalid traffic package")
rub_packages = { currency_packages = {
float(package.gb): float(package.price) 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 = { stars_packages = {
float(package.gb): int(float(package.price)) float(package.gb): int(float(package.price))
for package in (tariff.traffic_packages.stars if tariff.traffic_packages else []) 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) 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_price = (
stars_packages.get(stars_package_key) if stars_package_key is not None else None 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") return _json_error(400, "invalid_plan", "Invalid subscription period")
if months not in tariff.enabled_periods: if months not in tariff.enabled_periods:
return _json_error(400, "invalid_plan", "Subscription period is not available") 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_raw = tariff.period_price(months, "stars")
stars_price = int(stars_price_raw) if stars_price_raw and stars_price_raw > 0 else None stars_price = int(stars_price_raw) if stars_price_raw and stars_price_raw > 0 else None
if price is None and method != "stars": if price is None and method != "stars":
@@ -296,7 +302,7 @@ async def create_payment_route(request: web.Request) -> web.Response:
active_tariff = None active_tariff = None
if not active_tariff or active_tariff.billing_model != "period": if not active_tariff or active_tariff.billing_model != "period":
return _json_error(400, "invalid_plan", "Device top-up is not available") 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( hwid_quote = await subscription_service.quote_hwid_device_topup(
session, session,
user_id=user_id, user_id=user_id,
@@ -325,6 +331,7 @@ async def create_payment_route(request: web.Request) -> web.Response:
months=payment_units, months=payment_units,
price=float(price or 0), price=float(price or 0),
stars_price=stars_price, stars_price=stars_price,
currency=default_currency_code,
lang=lang, lang=lang,
sale_mode=sale_mode, sale_mode=sale_mode,
traffic_gb=traffic_gb_for_payment, 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() tariff_key = str(payment_payload.tariff_key or "").strip()
settings: Settings = request.app["settings"] settings: Settings = request.app["settings"]
config = settings.tariffs_config config = settings.tariffs_config
default_currency_code = default_payment_currency_code_for_settings(settings)
if not config: if not config:
return _json_error(404, "tariffs_unavailable", "Tariffs are not configured") return _json_error(404, "tariffs_unavailable", "Tariffs are not configured")
if not tariff_key: if not tariff_key:
@@ -618,6 +626,7 @@ async def tariff_change_payment_route(request: web.Request) -> web.Response:
months=1, months=1,
price=price, price=price,
stars_price=None, stars_price=None,
currency=default_currency_code,
lang=db_user.language_code or settings.DEFAULT_LANGUAGE, lang=db_user.language_code or settings.DEFAULT_LANGUAGE,
sale_mode=f"tariff_upgrade@{target.key}", 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 active.get("extra_hwid_devices_valid_until_text") if active else None
) or _billing_datetime_text(extra_hwid_valid_until) ) or _billing_datetime_text(extra_hwid_valid_until)
packages = tariff.hwid_device_packages 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 [])} stars_counts = {int(package.count) for package in (packages.stars if packages else [])}
plans = [] plans = []
for count in sorted(rub_counts | stars_counts): for count in sorted(currency_counts | stars_counts):
rub_quote = ( currency_quote = (
await subscription_service.quote_hwid_device_topup( await subscription_service.quote_hwid_device_topup(
session, session,
user_id=user_id, user_id=user_id,
device_count=count, device_count=count,
tariff_key=tariff.key, tariff_key=tariff.key,
renewal=renewal_available, renewal=renewal_available,
currency="rub", currency=default_currency,
) )
if count in rub_counts if count in currency_counts
else None else None
) )
stars_quote = ( stars_quote = (
@@ -684,7 +702,7 @@ async def device_topup_options_route(request: web.Request) -> web.Response:
if count in stars_counts if count in stars_counts
else None else None
) )
if not rub_quote and not stars_quote: if not currency_quote and not stars_quote:
continue continue
sale_mode_for_plan = "hwid_devices_renewal" if renewal_available else "hwid_devices" sale_mode_for_plan = "hwid_devices_renewal" if renewal_available else "hwid_devices"
plan = { plan = {
@@ -695,13 +713,19 @@ async def device_topup_options_route(request: web.Request) -> web.Response:
"sale_mode": sale_mode_for_plan, "sale_mode": sale_mode_for_plan,
"months": count, "months": count,
"device_count": count, "device_count": count,
"price": float(rub_quote.get("price") if rub_quote else 0), "price": float(currency_quote.get("price") if currency_quote else 0),
"currency": settings.DEFAULT_CURRENCY_SYMBOL or "RUB", "currency": default_currency_code,
"title": f"+{count}", "title": f"+{count}",
"subtitle": tariff.name(lang), "subtitle": tariff.name(lang),
"valid_from": _billing_iso_datetime((rub_quote or stars_quote).get("valid_from")), "valid_from": _billing_iso_datetime(
"valid_until": _billing_iso_datetime((rub_quote or stars_quote).get("valid_until")), (currency_quote or stars_quote).get("valid_from")
"proration_ratio": float((rub_quote or stars_quote).get("proration_ratio") or 0), ),
"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: if stars_quote and int(stars_quote.get("price") or 0) > 0:
plan["stars_price"] = int(stars_quote["price"]) plan["stars_price"] = int(stars_quote["price"])
@@ -929,12 +953,14 @@ async def _create_subscription_payment(
price: float, price: float,
stars_price: Optional[int], stars_price: Optional[int],
lang: str, lang: str,
currency: Optional[str] = None,
sale_mode: str = "subscription", sale_mode: str = "subscription",
traffic_gb: Optional[float] = None, traffic_gb: Optional[float] = None,
is_admin: bool = False, is_admin: bool = False,
hwid_quote: Optional[Dict[str, Any]] = None, hwid_quote: Optional[Dict[str, Any]] = None,
) -> web.Response: ) -> web.Response:
settings: Settings = request.app["settings"] settings: Settings = request.app["settings"]
payment_currency = (currency or default_payment_currency_code_for_settings(settings)).upper()
sale_mode = str(sale_mode or "subscription") sale_mode = str(sale_mode or "subscription")
traffic_sale = _sale_mode_is_traffic(sale_mode) traffic_sale = _sale_mode_is_traffic(sale_mode)
hwid_devices_sale = _sale_mode_is_hwid_devices(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), provider_spec.is_service_configured(request.app),
) )
return _json_error(400, "payment_unavailable", "Payment method unavailable") 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( return await provider_spec.create_webapp_payment(
WebAppPaymentContext( WebAppPaymentContext(
request=request, request=request,
@@ -967,6 +1004,7 @@ async def _create_subscription_payment(
months=months, months=months,
price=price, price=price,
stars_price=stars_price, stars_price=stars_price,
currency=payment_currency,
description=description, description=description,
sale_mode=sale_mode, sale_mode=sale_mode,
traffic_gb=traffic_gb, traffic_gb=traffic_gb,
+45 -22
View File
@@ -472,6 +472,8 @@ def _serialize_plans(
) -> List[Dict[str, Any]]: ) -> List[Dict[str, Any]]:
tariffs_config = settings.tariffs_config tariffs_config = settings.tariffs_config
if 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]] = [] plans: List[Dict[str, Any]] = []
for tariff in tariffs_config.enabled_tariffs: for tariff in tariffs_config.enabled_tariffs:
common = { common = {
@@ -481,7 +483,7 @@ def _serialize_plans(
"billing_model": tariff.billing_model, "billing_model": tariff.billing_model,
"description": tariff.description(lang), "description": tariff.description(lang),
"squad_uuids": tariff.squad_uuids, "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_limit": tariff.hwid_device_limit,
"hwid_device_packages": _serialize_hwid_device_packages( "hwid_device_packages": _serialize_hwid_device_packages(
settings, settings,
@@ -494,7 +496,7 @@ def _serialize_plans(
} }
if tariff.billing_model == "period": if tariff.billing_model == "period":
for months in sorted(tariff.enabled_periods): 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") stars_price = tariff.period_price(int(months), "stars")
if price is None and (stars_price is None or int(stars_price) <= 0): if price is None and (stars_price is None or int(stars_price) <= 0):
continue continue
@@ -512,9 +514,13 @@ def _serialize_plans(
plan["stars_price"] = int(stars_price) plan["stars_price"] = int(stars_price)
plans.append(plan) plans.append(plan)
else: else:
rub_packages = { currency_packages = {
float(package.gb): float(package.price) 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 = { stars_packages = {
float(package.gb): int(float(package.price)) float(package.gb): int(float(package.price))
@@ -522,8 +528,8 @@ def _serialize_plans(
tariff.traffic_packages.stars if tariff.traffic_packages else [] tariff.traffic_packages.stars if tariff.traffic_packages else []
) )
} }
for traffic_gb in sorted(set(rub_packages) | set(stars_packages)): for traffic_gb in sorted(set(currency_packages) | set(stars_packages)):
price = rub_packages.get(traffic_gb) price = currency_packages.get(traffic_gb)
stars_price = stars_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): if price is None and (stars_price is None or int(stars_price) <= 0):
continue continue
@@ -609,16 +615,19 @@ def _serialize_topup_packages(
sale_mode: str = "topup", sale_mode: str = "topup",
title_prefix: str = "", title_prefix: str = "",
) -> List[Dict[str, Any]]: ) -> List[Dict[str, Any]]:
rub_packages = { default_currency = default_currency_key_for_settings(settings)
float(package.gb): float(package.price) for package in (packages.rub if packages else []) 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 = { stars_packages = {
float(package.gb): int(float(package.price)) float(package.gb): int(float(package.price))
for package in (packages.stars if packages else []) for package in (packages.stars if packages else [])
} }
plans: List[Dict[str, Any]] = [] plans: List[Dict[str, Any]] = []
for traffic_gb in sorted(set(rub_packages) | set(stars_packages)): for traffic_gb in sorted(set(currency_packages) | set(stars_packages)):
price = rub_packages.get(traffic_gb) price = currency_packages.get(traffic_gb)
stars_price = stars_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): if price is None and (stars_price is None or int(stars_price) <= 0):
continue continue
@@ -632,7 +641,7 @@ def _serialize_topup_packages(
"months": int(traffic_value) if traffic_value.is_integer() else traffic_value, "months": int(traffic_value) if traffic_value.is_integer() else traffic_value,
"traffic_gb": traffic_value, "traffic_gb": traffic_value,
"price": float(price or 0), "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)}", "title": f"{title_prefix}{_format_traffic_title(traffic_value, lang)}",
"subtitle": tariff.premium_name(lang) "subtitle": tariff.premium_name(lang)
if sale_mode == "premium_topup" if sale_mode == "premium_topup"
@@ -650,16 +659,19 @@ def _serialize_hwid_device_packages(
packages: Optional[Any], packages: Optional[Any],
lang: str, lang: str,
) -> List[Dict[str, Any]]: ) -> List[Dict[str, Any]]:
rub_packages = { default_currency = default_currency_key_for_settings(settings)
int(package.count): float(package.price) for package in (packages.rub if packages else []) 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 = { stars_packages = {
int(package.count): int(float(package.price)) int(package.count): int(float(package.price))
for package in (packages.stars if packages else []) for package in (packages.stars if packages else [])
} }
plans: List[Dict[str, Any]] = [] plans: List[Dict[str, Any]] = []
for count in sorted(set(rub_packages) | set(stars_packages)): for count in sorted(set(currency_packages) | set(stars_packages)):
price = rub_packages.get(count) price = currency_packages.get(count)
stars_price = stars_packages.get(count) stars_price = stars_packages.get(count)
if price is None and (stars_price is None or int(stars_price) <= 0): if price is None and (stars_price is None or int(stars_price) <= 0):
continue continue
@@ -672,7 +684,7 @@ def _serialize_hwid_device_packages(
"months": int(count), "months": int(count),
"device_count": int(count), "device_count": int(count),
"price": float(price or 0), "price": float(price or 0),
"currency": settings.DEFAULT_CURRENCY_SYMBOL or "RUB", "currency": default_currency_code,
"title": f"+{count}", "title": f"+{count}",
"subtitle": tariff.name(lang), "subtitle": tariff.name(lang),
} }
@@ -689,6 +701,8 @@ def _serialize_tariff_change_target(
options: Dict[str, Any], options: Dict[str, Any],
lang: str, lang: str,
) -> Dict[str, Any]: ) -> Dict[str, Any]:
default_currency = default_currency_key_for_settings(settings)
default_currency_code = payment_currency_code(default_currency)
actions: List[Dict[str, Any]] = [] actions: List[Dict[str, Any]] = []
mode = str(options.get("mode") or "") mode = str(options.get("mode") or "")
if mode == "period_to_period": if mode == "period_to_period":
@@ -711,7 +725,7 @@ def _serialize_tariff_change_target(
"kind": "payment", "kind": "payment",
"title": "paid_diff", "title": "paid_diff",
"price": paid_diff, "price": paid_diff,
"currency": settings.DEFAULT_CURRENCY_SYMBOL or "RUB", "currency": default_currency_code,
} }
) )
elif mode == "period_to_traffic": elif mode == "period_to_traffic":
@@ -733,13 +747,17 @@ def _serialize_tariff_change_target(
"title": f"+{package.gb:g} GB", "title": f"+{package.gb:g} GB",
"traffic_gb": float(package.gb), "traffic_gb": float(package.gb),
"price": float(package.price), "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: else:
for months in tariff.enabled_periods: for months in tariff.enabled_periods:
price = tariff.period_price(int(months), "rub") price = tariff.period_price(int(months), default_currency)
if price: if price:
actions.append( actions.append(
{ {
@@ -748,7 +766,7 @@ def _serialize_tariff_change_target(
"months": int(months), "months": int(months),
"title": _format_months_title(int(months), lang), "title": _format_months_title(int(months), lang),
"price": float(price), "price": float(price),
"currency": settings.DEFAULT_CURRENCY_SYMBOL or "RUB", "currency": default_currency_code,
} }
) )
return { return {
@@ -772,10 +790,15 @@ def _serialize_payment_methods(
from bot.payment_providers import get_provider_spec, resolve_provider_presentation from bot.payment_providers import get_provider_spec, resolve_provider_presentation
methods: List[Dict[str, Any]] = [] methods: List[Dict[str, Any]] = []
payment_currency = default_payment_currency_code_for_settings(settings)
for method in settings.payment_methods_order: for method in settings.payment_methods_order:
method = method.lower() method = method.lower()
spec = get_provider_spec(method) 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) presentation = resolve_provider_presentation(spec, settings, language=lang)
methods.append( methods.append(
{ {
+6 -4
View File
@@ -13,6 +13,7 @@ from bot.middlewares.i18n import JsonI18n
from bot.payment_providers import pending_statuses from bot.payment_providers import pending_statuses
from bot.services.panel_api_service import PanelApiService from bot.services.panel_api_service import PanelApiService
from config.settings import Settings 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.dal import panel_sync_dal, payment_dal, user_dal
from db.models import PanelSyncStatus, Payment from db.models import PanelSyncStatus, Payment
@@ -195,19 +196,20 @@ async def show_statistics_handler(
# Financial statistics # Financial statistics
financial_stats = await payment_dal.get_financial_statistics(session) financial_stats = await payment_dal.get_financial_statistics(session)
currency = default_payment_currency_code_for_settings(settings)
stats_text_parts.append(f"\n<b>💰 {_('admin_financial_stats_header')}</b>") stats_text_parts.append(f"\n<b>💰 {_('admin_financial_stats_header')}</b>")
stats_text_parts.append( stats_text_parts.append(
f"📅 {_('admin_financial_today_label')}: <b>{financial_stats['today_revenue']:.2f} RUB</b> ({financial_stats['today_payments_count']} {_('admin_financial_payments_label')})" # noqa: E501 f"📅 {_('admin_financial_today_label')}: <b>{financial_stats['today_revenue']:.2f} {currency}</b> ({financial_stats['today_payments_count']} {_('admin_financial_payments_label')})" # noqa: E501
) )
stats_text_parts.append( stats_text_parts.append(
f"📅 {_('admin_financial_week_label')}: <b>{financial_stats['week_revenue']:.2f} RUB</b>" f"📅 {_('admin_financial_week_label')}: <b>{financial_stats['week_revenue']:.2f} {currency}</b>" # noqa: E501
) )
stats_text_parts.append( stats_text_parts.append(
f"📅 {_('admin_financial_month_label')}: <b>{financial_stats['month_revenue']:.2f} RUB</b>" f"📅 {_('admin_financial_month_label')}: <b>{financial_stats['month_revenue']:.2f} {currency}</b>" # noqa: E501
) )
stats_text_parts.append( stats_text_parts.append(
f"🏆 {_('admin_financial_all_time_label')}: <b>{financial_stats['all_time_revenue']:.2f} RUB</b>" # noqa: E501 f"🏆 {_('admin_financial_all_time_label')}: <b>{financial_stats['all_time_revenue']:.2f} {currency}</b>" # noqa: E501
) )
last_payments_models: List[Payment] = await payment_dal.get_recent_payment_logs_with_user( last_payments_models: List[Payment] = await payment_dal.get_recent_payment_logs_with_user(
@@ -27,6 +27,7 @@ from bot.utils.text_sanitizer import (
username_for_display, username_for_display,
) )
from config.settings import Settings 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.dal import message_log_dal, subscription_dal, user_dal
from db.models import User from db.models import User
@@ -407,16 +408,18 @@ async def format_user_card(
try: try:
from db.dal import payment_dal from db.dal import payment_dal
currency = default_payment_currency_code_for_settings(settings)
# Total amount paid by this user # Total amount paid by this user
total_paid = await payment_dal.get_user_total_paid(session, user.user_id) total_paid = await payment_dal.get_user_total_paid(session, user.user_id)
card_parts.append( 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 # Total revenue from referrals
referral_revenue = await payment_dal.get_referral_revenue(session, user.user_id) referral_revenue = await payment_dal.get_referral_revenue(session, user.user_id)
card_parts.append( 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: except Exception as e_fin:
logging.error( logging.error(
+77 -30
View File
@@ -32,6 +32,10 @@ from bot.utils.install_links import (
ensure_user_install_guide_links, ensure_user_install_guide_links,
) )
from config.settings import Settings 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.dal import subscription_dal, user_billing_dal
from db.models import Subscription from db.models import Subscription
@@ -80,11 +84,13 @@ def _tariff_purchase_markup(
back_callback=back_callback, back_callback=back_callback,
callback_context=callback_context, callback_context=callback_context,
) )
default_currency = default_currency_key_for_settings(settings)
return get_tariff_packages_keyboard( return get_tariff_packages_keyboard(
tariff, tariff,
tariff.traffic_packages.rub, tariff.traffic_packages.for_currency(default_currency),
current_lang, current_lang,
i18n, i18n,
currency_symbol=default_payment_currency_code_for_settings(settings),
back_callback=back_callback, back_callback=back_callback,
callback_context=callback_context, callback_context=callback_context,
) )
@@ -185,6 +191,7 @@ async def display_subscription_options(
enabled_tariffs, enabled_tariffs,
current_lang, current_lang,
i18n, i18n,
settings=settings,
back_callback=back_callback, back_callback=back_callback,
callback_context=callback_context, callback_context=callback_context,
) )
@@ -329,7 +336,9 @@ async def select_tariff_period_callback(
callback_context = parts[4] if len(parts) > 4 else None callback_context = parts[4] if len(parts) > 4 else None
tariff = config.require(tariff_key) tariff = config.require(tariff_key)
months = int(months_raw) 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") stars_price = tariff.period_price(months, "stars")
if price_rub is None: if price_rub is None:
await callback.answer(get_text("error_try_again"), show_alert=True) await callback.answer(get_text("error_try_again"), show_alert=True)
@@ -338,7 +347,7 @@ async def select_tariff_period_callback(
months, months,
price_rub, price_rub,
int(stars_price) if stars_price else None, int(stars_price) if stars_price else None,
settings.DEFAULT_CURRENCY_SYMBOL, currency_code,
current_lang, current_lang,
i18n, i18n,
settings, settings,
@@ -369,10 +378,16 @@ async def select_tariff_package_callback(
callback_context = parts[4] if len(parts) > 4 else None callback_context = parts[4] if len(parts) > 4 else None
tariff = config.require(tariff_key) tariff = config.require(tariff_key)
gb = float(gb_raw) gb = float(gb_raw)
default_currency = default_currency_key_for_settings(settings)
currency_code = default_payment_currency_code_for_settings(settings)
packages = ( packages = (
tariff.traffic_packages.rub tariff.traffic_packages.for_currency(default_currency)
if tariff.billing_model == "traffic" 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) package = next((pkg for pkg in packages if float(pkg.gb) == gb), None)
if not package: if not package:
@@ -391,7 +406,7 @@ async def select_tariff_package_callback(
gb, gb,
package.price, package.price,
None, None,
settings.DEFAULT_CURRENCY_SYMBOL, currency_code,
current_lang, current_lang,
i18n, i18n,
settings, settings,
@@ -423,14 +438,19 @@ async def tariff_topup_list_callback(
return return
tariff = config.require(active["tariff_key"]) tariff = config.require(active["tariff_key"])
packages = config.topup_packages_for(tariff) packages = config.topup_packages_for(tariff)
rub_packages = packages.rub if packages else [] default_currency = default_currency_key_for_settings(settings)
premium_packages = tariff.premium_topup_packages.rub if tariff.premium_topup_packages else [] currency = default_payment_currency_code_for_settings(settings)
if not rub_packages and not premium_packages: 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) await callback.answer(get_text("no_subscription_options_available"), show_alert=True)
return return
builder = InlineKeyboardBuilder() builder = InlineKeyboardBuilder()
currency = settings.DEFAULT_CURRENCY_SYMBOL for package in currency_packages:
for package in rub_packages:
builder.row( builder.row(
InlineKeyboardButton( InlineKeyboardButton(
text=f"Обычный трафик +{package.gb:g} GB — {package.price:g} {currency}", text=f"Обычный трафик +{package.gb:g} GB — {package.price:g} {currency}",
@@ -452,7 +472,7 @@ async def tariff_topup_list_callback(
premium_lines = [] premium_lines = []
carryover_lines = [] carryover_lines = []
if rub_packages or premium_packages: if currency_packages or premium_packages:
carryover_lines.append( carryover_lines.append(
"Докупленный трафик не сгорает: сначала расходуется месячный лимит, затем докупленный остаток." # noqa: E501 "Докупленный трафик не сгорает: сначала расходуется месячный лимит, затем докупленный остаток." # noqa: E501
) )
@@ -495,7 +515,13 @@ async def select_tariff_premium_package_callback(
_, _, tariff_key, gb_raw = callback.data.split(":", 3) _, _, tariff_key, gb_raw = callback.data.split(":", 3)
tariff = config.require(tariff_key) tariff = config.require(tariff_key)
gb = float(gb_raw) 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) package = next((pkg for pkg in packages if float(pkg.gb) == gb), None)
if not package: if not package:
await callback.answer(get_text("error_try_again"), show_alert=True) await callback.answer(get_text("error_try_again"), show_alert=True)
@@ -504,7 +530,7 @@ async def select_tariff_premium_package_callback(
gb, gb,
package.price, package.price,
None, None,
settings.DEFAULT_CURRENCY_SYMBOL, currency_code,
current_lang, current_lang,
i18n, i18n,
settings, settings,
@@ -542,7 +568,12 @@ async def hwid_devices_list_callback(
if tariff.billing_model != "period": if tariff.billing_model != "period":
await callback.answer(get_text("no_hwid_device_packages_available"), show_alert=True) await callback.answer(get_text("no_hwid_device_packages_available"), show_alert=True)
return 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: if not packages:
await callback.answer(get_text("no_hwid_device_packages_available"), show_alert=True) await callback.answer(get_text("no_hwid_device_packages_available"), show_alert=True)
return return
@@ -594,7 +625,13 @@ async def hwid_devices_package_callback(
package = next( package = next(
( (
pkg 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 if int(pkg.count) == count
), ),
None, None,
@@ -603,13 +640,15 @@ async def hwid_devices_package_callback(
await callback.answer(get_text("error_try_again"), show_alert=True) await callback.answer(get_text("error_try_again"), show_alert=True)
return return
sale_mode_base = "hwid_devices_renewal" if action == "renewal_package" else "hwid_devices" 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, session,
user_id=callback.from_user.id, user_id=callback.from_user.id,
device_count=count, device_count=count,
tariff_key=tariff.key, tariff_key=tariff.key,
renewal=action == "renewal_package", renewal=action == "renewal_package",
currency="rub", currency=default_currency,
) )
stars_quote = await subscription_service.quote_hwid_device_topup( stars_quote = await subscription_service.quote_hwid_device_topup(
session, session,
@@ -619,16 +658,16 @@ async def hwid_devices_package_callback(
renewal=action == "renewal_package", renewal=action == "renewal_package",
currency="stars", 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) await callback.answer(get_text("error_try_again"), show_alert=True)
return return
markup = get_payment_method_keyboard( markup = get_payment_method_keyboard(
count, 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"]) int(stars_quote["price"])
if stars_quote and int(stars_quote.get("price") or 0) > 0 if stars_quote and int(stars_quote.get("price") or 0) > 0
else None, else None,
settings.DEFAULT_CURRENCY_SYMBOL, currency_code,
current_lang, current_lang,
i18n, i18n,
settings, settings,
@@ -715,6 +754,8 @@ async def tariff_change_select_callback(
options = await subscription_service.calculate_tariff_switch_options_with_hwid( options = await subscription_service.calculate_tariff_switch_options_with_hwid(
session, db_sub, target session, db_sub, target
) )
default_currency = default_currency_key_for_settings(settings)
currency_code = default_payment_currency_code_for_settings(settings)
rows = [] rows = []
if options["mode"] == "period_to_period": if options["mode"] == "period_to_period":
rows.append( rows.append(
@@ -729,7 +770,7 @@ async def tariff_change_select_callback(
rows.append( rows.append(
[ [
InlineKeyboardButton( 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']}", 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( rows.append(
[ [
InlineKeyboardButton( 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}", callback_data=f"tariff:package:{target.key}:{package.gb:g}",
) )
] ]
) )
else: else:
for months in target.enabled_periods: for months in target.enabled_periods:
price = target.period_price(months, "rub") price = target.period_price(months, default_currency)
if price: if price:
rows.append( rows.append(
[ [
InlineKeyboardButton( InlineKeyboardButton(
text=f"{months} мес. за {price:g} RUB", text=f"{months} мес. за {price:g} {currency_code}",
callback_data=f"tariff:period:{target.key}:{months}", callback_data=f"tariff:period:{target.key}:{months}",
) )
] ]
@@ -842,6 +883,7 @@ async def tariff_change_confirm_pay_callback(
return return
_, _, tariff_key, amount_raw = callback.data.split(":", 3) _, _, tariff_key, amount_raw = callback.data.split(":", 3)
target = config.require(tariff_key) target = config.require(tariff_key)
currency_code = default_payment_currency_code_for_settings(settings)
rows = [ rows = [
[ [
InlineKeyboardButton( InlineKeyboardButton(
@@ -857,7 +899,7 @@ async def tariff_change_confirm_pay_callback(
], ],
] ]
await callback.message.edit_text( 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), reply_markup=InlineKeyboardMarkup(inline_keyboard=rows),
) )
await callback.answer() await callback.answer()
@@ -899,11 +941,12 @@ async def tariff_change_pay_callback(
i18n: JsonI18n = i18n_data.get("i18n_instance") i18n: JsonI18n = i18n_data.get("i18n_instance")
_, _, tariff_key, amount_raw = callback.data.split(":", 3) _, _, tariff_key, amount_raw = callback.data.split(":", 3)
amount = float(amount_raw) amount = float(amount_raw)
currency_code = default_payment_currency_code_for_settings(settings)
markup = get_payment_method_keyboard( markup = get_payment_method_keyboard(
1, 1,
amount, amount,
None, None,
settings.DEFAULT_CURRENCY_SYMBOL, currency_code,
current_lang, current_lang,
i18n, i18n,
settings, settings,
@@ -1226,7 +1269,9 @@ async def my_subscription_command_handler(
if ( if (
tariff_for_devices.billing_model == "period" tariff_for_devices.billing_model == "period"
and tariff_for_devices.hwid_device_packages 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( prepend_rows.append(
[ [
@@ -1443,7 +1488,9 @@ async def my_devices_command_handler(
if ( if (
tariff_for_devices.billing_model == "period" tariff_for_devices.billing_model == "period"
and tariff_for_devices.hwid_device_packages 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( devices_kb.append(
[ [
+25 -4
View File
@@ -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.install_links import bot_install_guide_url
from bot.utils.mini_app_url import subscription_mini_app_trial_url from bot.utils.mini_app_url import subscription_mini_app_trial_url
from config.settings import Settings 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" BOT_MENU_CONTEXT = "bot"
@@ -328,19 +332,31 @@ def get_tariff_catalog_keyboard(
tariffs: List[Any], tariffs: List[Any],
lang: str, lang: str,
i18n_instance, i18n_instance,
settings: Optional[Settings] = None,
back_callback: str = "main_action:back_to_main", back_callback: str = "main_action:back_to_main",
callback_context: Optional[str] = None, callback_context: Optional[str] = None,
) -> InlineKeyboardMarkup: ) -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder() builder = InlineKeyboardBuilder()
callback_context = callback_context or callback_context_from_back_callback(back_callback) 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: for tariff in tariffs:
label = tariff.name(lang) label = tariff.name(lang)
if tariff.billing_model == "period": if tariff.billing_model == "period":
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() min_price = tariff.min_period_price_rub()
else:
min_price = None
if min_price is not None: if min_price is not None:
label = f"{label} от {min_price:g}" label = f"{label} от {min_price:g}"
else: else:
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() package = tariff.min_traffic_package_rub()
else:
package = None
if package: if package:
label = f"{label} от {package.price:g} / {package.gb:g} GB" label = f"{label} от {package.price:g} / {package.gb:g} GB"
builder.row( builder.row(
@@ -368,8 +384,10 @@ def get_tariff_periods_keyboard(
builder = InlineKeyboardBuilder() builder = InlineKeyboardBuilder()
callback_context = callback_context or callback_context_from_back_callback(back_callback) callback_context = callback_context or callback_context_from_back_callback(back_callback)
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs) _ = 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: 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: if rub_price and rub_price > 0:
builder.row( builder.row(
InlineKeyboardButton( InlineKeyboardButton(
@@ -377,7 +395,7 @@ def get_tariff_periods_keyboard(
"subscribe_for_months_button", "subscribe_for_months_button",
months=months, months=months,
price=rub_price, price=rub_price,
currency_symbol=settings.DEFAULT_CURRENCY_SYMBOL, currency_symbol=currency_code,
), ),
callback_data=f"tariff:period:{tariff.key}:{months}" callback_data=f"tariff:period:{tariff.key}:{months}"
f"{callback_suffix_for_context(callback_context)}", f"{callback_suffix_for_context(callback_context)}",
@@ -394,6 +412,7 @@ def get_tariff_packages_keyboard(
packages: List[Any], packages: List[Any],
lang: str, lang: str,
i18n_instance, i18n_instance,
currency_symbol: str = "RUB",
back_callback: str = "main_action:subscribe", back_callback: str = "main_action:subscribe",
callback_context: Optional[str] = None, callback_context: Optional[str] = None,
) -> InlineKeyboardMarkup: ) -> InlineKeyboardMarkup:
@@ -407,7 +426,7 @@ def get_tariff_packages_keyboard(
"buy_traffic_package_button", "buy_traffic_package_button",
traffic_gb=f"{package.gb:g}", traffic_gb=f"{package.gb:g}",
price=package.price, price=package.price,
currency_symbol="RUB", currency_symbol=currency_symbol,
), ),
callback_data=f"tariff:package:{tariff.key}:{package.gb:g}" callback_data=f"tariff:package:{tariff.key}:{package.gb:g}"
f"{callback_suffix_for_context(callback_context)}", f"{callback_suffix_for_context(callback_context)}",
@@ -430,6 +449,7 @@ def get_hwid_device_packages_keyboard(
) -> InlineKeyboardMarkup: ) -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder() builder = InlineKeyboardBuilder()
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs) _ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
currency_code = default_payment_currency_code_for_settings(settings)
for package in packages: for package in packages:
builder.row( builder.row(
InlineKeyboardButton( InlineKeyboardButton(
@@ -437,7 +457,7 @@ def get_hwid_device_packages_keyboard(
"buy_hwid_devices_button", "buy_hwid_devices_button",
count=package.count, count=package.count,
price=package.price, price=package.price,
currency_symbol=settings.DEFAULT_CURRENCY_SYMBOL, currency_symbol=currency_code,
), ),
callback_data=( callback_data=(
f"hwid_devices:{'renewal_package' if renewal else 'package'}:" f"hwid_devices:{'renewal_package' if renewal else 'package'}:"
@@ -484,6 +504,7 @@ def get_payment_method_keyboard(
if ( if (
not spec not spec
or not spec.callback_prefix or not spec.callback_prefix
or not spec.is_usable_for_payment_currency(settings, currency_symbol_val)
or not spec.is_available_to_user( or not spec.is_available_to_user(
settings, settings,
user_id=user_id, user_id=user_id,
+68
View File
@@ -112,6 +112,7 @@ class WebAppPaymentContext:
stars_price: Optional[int] stars_price: Optional[int]
description: str description: str
sale_mode: str sale_mode: str
currency: str = "RUB"
traffic_gb: Optional[float] = None traffic_gb: Optional[float] = None
hwid_valid_from: Optional[Any] = None hwid_valid_from: Optional[Any] = None
hwid_valid_until: Optional[Any] = None hwid_valid_until: Optional[Any] = None
@@ -125,6 +126,36 @@ ServiceFactory = Callable[[ServiceFactoryContext], Any]
WebhookPathGetter = Callable[[Any], str] WebhookPathGetter = Callable[[Any], str]
WebhookRoute = Callable[[Any], Awaitable[Any]] WebhookRoute = Callable[[Any], Awaitable[Any]]
WebAppPaymentFactory = Callable[[WebAppPaymentContext], 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) @dataclass(frozen=True)
@@ -158,6 +189,10 @@ class PaymentProviderSpec:
admin_only_manifest_key: Optional[str] = None admin_only_manifest_key: Optional[str] = None
admin_only_config_attr: str = "ADMIN_ONLY_ENABLED" admin_only_config_attr: str = "ADMIN_ONLY_ENABLED"
admin_only_enabled: Optional[EnabledPredicate] = None 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 @property
def settings_key(self) -> str: def settings_key(self) -> str:
@@ -237,6 +272,39 @@ class PaymentProviderSpec:
service = app.get(self.service_key) if hasattr(app, "get") else None service = app.get(self.service_key) if hasattr(app, "get") else None
return bool(service and getattr(service, "configured", False)) 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: def is_visible(self, source: Any, app: Any) -> bool:
return self.is_enabled(source) and self.is_service_configured(app) return self.is_enabled(source) and self.is_service_configured(app)
+55 -5
View File
@@ -17,6 +17,10 @@ from bot.middlewares.i18n import JsonI18n
from bot.services.referral_service import ReferralService from bot.services.referral_service import ReferralService
from bot.services.subscription_service import SubscriptionService from bot.services.subscription_service import SubscriptionService
from config.settings import Settings 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 db.dal import payment_dal
from .base import ( from .base import (
@@ -25,6 +29,7 @@ from .base import (
ProviderManifestField, ProviderManifestField,
ServiceFactoryContext, ServiceFactoryContext,
WebAppPaymentContext, WebAppPaymentContext,
normalize_payment_currency_code,
provider_env_file, provider_env_file,
provider_runtime_enabled, provider_runtime_enabled,
) )
@@ -49,6 +54,34 @@ from .shared import (
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
_LOG = "cryptopay" _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): class CryptoPayConfig(ProviderEnvConfig):
@@ -159,11 +192,23 @@ class CryptoPayService:
sale_mode: str = "subscription", sale_mode: str = "subscription",
url_kind: str = "bot", url_kind: str = "bot",
hwid_quote: Optional[dict] = None, hwid_quote: Optional[dict] = None,
currency: Optional[str] = None,
) -> Optional[str]: ) -> Optional[str]:
if not self.configured or not self.client: if not self.configured or not self.client:
logging.error("CryptoPayService not configured") logging.error("CryptoPayService not configured")
return None 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) sale_base = sale_mode_base(sale_mode)
amounts = payment_record_amounts(months=months, sale_mode=sale_mode) amounts = payment_record_amounts(months=months, sale_mode=sale_mode)
try: try:
@@ -172,7 +217,7 @@ class CryptoPayService:
{ {
"user_id": user_id, "user_id": user_id,
"amount": float(amount), "amount": float(amount),
"currency": self.config.ASSET, "currency": currency_code,
"status": "pending_cryptopay", "status": "pending_cryptopay",
"description": description, "description": description,
"subscription_duration_months": ( "subscription_duration_months": (
@@ -212,9 +257,9 @@ class CryptoPayService:
try: try:
invoice = await self.client.create_invoice( invoice = await self.client.create_invoice(
amount=amount, amount=amount,
currency_type=self.config.CURRENCY_TYPE, currency_type=currency_type,
fiat=self.config.ASSET if self.config.CURRENCY_TYPE == "fiat" else None, fiat=currency_code if currency_type == "fiat" else None,
asset=self.config.ASSET if self.config.CURRENCY_TYPE == "crypto" else None, asset=currency_code if currency_type == "crypto" else None,
description=description, description=description,
payload=payload, payload=payload,
) )
@@ -393,7 +438,7 @@ async def pay_crypto_callback_handler(
user_id=callback.from_user.id, user_id=callback.from_user.id,
parts=parts, parts=parts,
subscription_service=cryptopay_service.subscription_service, subscription_service=cryptopay_service.subscription_service,
currency="rub", currency=default_currency_key_for_settings(settings),
) )
if not parts: if not parts:
await notify_callback_parse_error(callback, translator) await notify_callback_parse_error(callback, translator)
@@ -408,6 +453,7 @@ async def pay_crypto_callback_handler(
description=payment_description, description=payment_description,
sale_mode=parts.sale_mode, sale_mode=parts.sale_mode,
hwid_quote=hwid_quote, hwid_quote=hwid_quote,
currency=default_payment_currency_code_for_settings(settings),
) )
if invoice_url: if invoice_url:
@@ -457,6 +503,7 @@ async def create_webapp_payment(ctx: WebAppPaymentContext) -> web.Response:
description=ctx.description, description=ctx.description,
sale_mode=ctx.sale_mode, sale_mode=ctx.sale_mode,
url_kind="web", url_kind="web",
currency=ctx.currency,
hwid_quote={ hwid_quote={
"valid_from": ctx.hwid_valid_from, "valid_from": ctx.hwid_valid_from,
"valid_until": ctx.hwid_valid_until, "valid_until": ctx.hwid_valid_until,
@@ -592,4 +639,7 @@ SPEC = PaymentProviderSpec(
config_class=CryptoPayConfig, config_class=CryptoPayConfig,
presentation_class=CryptoPayPresentation, presentation_class=CryptoPayPresentation,
manifest_fields=_CONFIG_MANIFEST + _PRESENTATION_MANIFEST, 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/",
) )
+22 -6
View File
@@ -20,6 +20,10 @@ from bot.services.referral_service import ReferralService
from bot.services.subscription_service import SubscriptionService from bot.services.subscription_service import SubscriptionService
from bot.utils.request_security import ip_in_allowlist, request_client_ip from bot.utils.request_security import ip_in_allowlist, request_client_ip
from config.settings import Settings 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 db.dal import payment_dal
from .base import ( from .base import (
@@ -28,6 +32,7 @@ from .base import (
ProviderManifestField, ProviderManifestField,
ServiceFactoryContext, ServiceFactoryContext,
WebAppPaymentContext, WebAppPaymentContext,
normalize_payment_currency_code,
provider_env_file, provider_env_file,
provider_runtime_enabled, provider_runtime_enabled,
) )
@@ -56,6 +61,7 @@ from .shared import (
) )
_LOG = "freekassa" _LOG = "freekassa"
FREEKASSA_SUPPORTED_CURRENCIES = ("RUB", "USD", "EUR", "UAH", "KZT")
class FreeKassaConfig(ProviderEnvConfig): class FreeKassaConfig(ProviderEnvConfig):
@@ -144,7 +150,7 @@ class FreeKassaService(HttpClientMixin):
self.subscription_service = subscription_service self.subscription_service = subscription_service
self.referral_service = referral_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.api_base_url: str = "https://api.fk.life/v1"
self._init_http_client(total_timeout=15) self._init_http_client(total_timeout=15)
@@ -207,7 +213,13 @@ class FreeKassaService(HttpClientMixin):
return False, {"message": "missing_ip"} return False, {"message": "missing_ip"}
email = email or f"{user_id}@telegram.org" 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] = { payload: Dict[str, Any] = {
"shopId": int(self.shop_id), "shopId": int(self.shop_id),
@@ -477,7 +489,7 @@ async def pay_fk_callback_handler(
user_id=callback.from_user.id, user_id=callback.from_user.id,
parts=parts, parts=parts,
subscription_service=freekassa_service.subscription_service, subscription_service=freekassa_service.subscription_service,
currency="rub", currency=default_currency_key_for_settings(settings),
) )
if not parts: if not parts:
await notify_callback_parse_error(callback, translator) await notify_callback_parse_error(callback, translator)
@@ -485,7 +497,7 @@ async def pay_fk_callback_handler(
currency_code = ( currency_code = (
getattr(freekassa_service, "default_currency", None) getattr(freekassa_service, "default_currency", None)
or settings.DEFAULT_CURRENCY_SYMBOL or default_payment_currency_code_for_settings(settings)
or "RUB" or "RUB"
) )
payment_description = describe_payment(translator, parts) 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"] service: FreeKassaService = ctx.request.app["freekassa_service"]
if not service or not service.configured or not service.payment_method_id: if not service or not service.configured or not service.payment_method_id:
return payment_unavailable() return payment_unavailable()
currency = ctx.currency or service.default_currency
try: try:
payment = await create_webapp_payment_record( payment = await create_webapp_payment_record(
ctx, ctx,
amount=ctx.price, amount=ctx.price,
currency=service.default_currency, currency=currency,
status="pending_freekassa", status="pending_freekassa",
provider="freekassa", provider="freekassa",
) )
@@ -592,7 +605,7 @@ async def create_webapp_payment(ctx: WebAppPaymentContext) -> web.Response:
user_id=ctx.user_id, user_id=ctx.user_id,
months=ctx.months, months=ctx.months,
amount=ctx.price, amount=ctx.price,
currency=service.default_currency, currency=currency,
payment_method_id=service.payment_method_id, payment_method_id=service.payment_method_id,
ip_address=service.server_ip, ip_address=service.server_ip,
extra_params={"us_method": service.payment_method_id}, extra_params={"us_method": service.payment_method_id},
@@ -762,4 +775,7 @@ SPEC = PaymentProviderSpec(
config_class=FreeKassaConfig, config_class=FreeKassaConfig,
presentation_class=FreeKassaPresentation, presentation_class=FreeKassaPresentation,
manifest_fields=_CONFIG_MANIFEST + _PRESENTATION_MANIFEST, 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/",
) )
+41 -4
View File
@@ -18,6 +18,10 @@ from bot.services.referral_service import ReferralService
from bot.services.subscription_service import SubscriptionService from bot.services.subscription_service import SubscriptionService
from bot.utils.request_security import ip_in_allowlist, request_client_ip from bot.utils.request_security import ip_in_allowlist, request_client_ip
from config.settings import Settings 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 db.dal import payment_dal
from .base import ( from .base import (
@@ -26,6 +30,8 @@ from .base import (
ProviderManifestField, ProviderManifestField,
ServiceFactoryContext, ServiceFactoryContext,
WebAppPaymentContext, WebAppPaymentContext,
normalize_payment_currency_code,
parse_supported_currency_codes,
provider_env_file, provider_env_file,
provider_runtime_enabled, provider_runtime_enabled,
) )
@@ -59,6 +65,10 @@ _LOG = "heleket"
_SUCCESS_STATUSES = {"paid", "paid_over"} _SUCCESS_STATUSES = {"paid", "paid_over"}
_FAILED_STATUSES = {"fail", "wrong_amount", "cancel", "system_fail"} _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): class HeleketConfig(ProviderEnvConfig):
@@ -83,6 +93,7 @@ class HeleketConfig(ProviderEnvConfig):
LIFETIME_SECONDS: int = Field(default=3600) LIFETIME_SECONDS: int = Field(default=3600)
VERIFY_WEBHOOK_SIGNATURE: bool = Field(default=True) VERIFY_WEBHOOK_SIGNATURE: bool = Field(default=True)
TRUSTED_IPS: str = Field(default="31.133.220.8") TRUSTED_IPS: str = Field(default="31.133.220.8")
SUPPORTED_CURRENCIES: str = Field(default=HELEKET_DEFAULT_SUPPORTED_CURRENCIES)
@field_validator("LIFETIME_SECONDS", mode="before") @field_validator("LIFETIME_SECONDS", mode="before")
@classmethod @classmethod
@@ -299,9 +310,18 @@ class HeleketService(HttpClientMixin):
logging.error("HeleketService is not configured. Cannot create payment link.") logging.error("HeleketService is not configured. Cannot create payment link.")
return False, {"message": "service_not_configured"} 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] = { body: Dict[str, Any] = {
"amount": str(format_decimal_amount(amount)), "amount": str(format_decimal_amount(amount)),
"currency": (currency or self.currency).upper(), "currency": currency_code,
"order_id": str(payment_db_id), "order_id": str(payment_db_id),
"url_return": self.return_url, "url_return": self.return_url,
"url_success": self.success_url, "url_success": self.success_url,
@@ -572,13 +592,13 @@ async def pay_heleket_callback_handler(
user_id=callback.from_user.id, user_id=callback.from_user.id,
parts=parts, parts=parts,
subscription_service=heleket_service.subscription_service, subscription_service=heleket_service.subscription_service,
currency="rub", currency=default_currency_key_for_settings(settings),
) )
if not parts: if not parts:
await notify_callback_parse_error(callback, translator) await notify_callback_parse_error(callback, translator)
return 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) payment_description = describe_payment(translator, parts)
record_payload = build_payment_record_payload( record_payload = build_payment_record_payload(
user_id=callback.from_user.id, 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: if not service or not service.configured:
return payment_unavailable() 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: try:
payment = await create_webapp_payment_record( payment = await create_webapp_payment_record(
ctx, ctx,
@@ -787,6 +807,18 @@ _CONFIG_MANIFEST = (
subsection="Heleket", subsection="Heleket",
attr="CURRENCY", 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( ProviderManifestField(
"HELEKET_TO_CURRENCY", "HELEKET_TO_CURRENCY",
"string", "string",
@@ -859,4 +891,9 @@ SPEC = PaymentProviderSpec(
config_class=HeleketConfig, config_class=HeleketConfig,
presentation_class=HeleketPresentation, presentation_class=HeleketPresentation,
manifest_fields=_CONFIG_MANIFEST + _PRESENTATION_MANIFEST, 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",
) )
+40 -5
View File
@@ -14,6 +14,10 @@ from bot.middlewares.i18n import JsonI18n
from bot.services.referral_service import ReferralService from bot.services.referral_service import ReferralService
from bot.services.subscription_service import SubscriptionService from bot.services.subscription_service import SubscriptionService
from config.settings import Settings 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 db.dal import payment_dal
from .base import ( from .base import (
@@ -22,6 +26,8 @@ from .base import (
ProviderManifestField, ProviderManifestField,
ServiceFactoryContext, ServiceFactoryContext,
WebAppPaymentContext, WebAppPaymentContext,
normalize_payment_currency_code,
parse_supported_currency_codes,
provider_env_file, provider_env_file,
provider_runtime_enabled, provider_runtime_enabled,
) )
@@ -76,6 +82,7 @@ class PlategaConfig(ProviderEnvConfig):
CRYPTO_METHOD: int = Field(default=13) CRYPTO_METHOD: int = Field(default=13)
RETURN_URL: Optional[str] = None RETURN_URL: Optional[str] = None
FAILED_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") @field_validator("MERCHANT_ID", "SECRET", "RETURN_URL", "FAILED_URL", mode="before")
@classmethod @classmethod
@@ -229,9 +236,19 @@ class PlategaService(HttpClientMixin):
logging.error("PlategaService is not configured. Cannot create transaction.") logging.error("PlategaService is not configured. Cannot create transaction.")
return False, {"message": "service_not_configured"} 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() session = await self._get_session()
url = f"{self.base_url}/transaction/process" 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) method_id = int(payment_method if payment_method is not None else self.payment_method)
body: Dict[str, Any] = { body: Dict[str, Any] = {
@@ -482,13 +499,13 @@ async def pay_platega_callback_handler(
user_id=callback.from_user.id, user_id=callback.from_user.id,
parts=parts, parts=parts,
subscription_service=platega_service.subscription_service, subscription_service=platega_service.subscription_service,
currency="rub", currency=default_currency_key_for_settings(settings),
) )
if not parts: if not parts:
await notify_callback_parse_error(callback, translator) await notify_callback_parse_error(callback, translator)
return return
currency_code = settings.DEFAULT_CURRENCY_SYMBOL or "RUB" currency_code = default_payment_currency_code_for_settings(settings)
payment_description = describe_payment(translator, parts) payment_description = describe_payment(translator, parts)
record_payload = build_payment_record_payload( record_payload = build_payment_record_payload(
user_id=callback.from_user.id, 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( payment = await create_webapp_payment_record(
ctx, ctx,
amount=ctx.price, amount=ctx.price,
currency=settings.DEFAULT_CURRENCY_SYMBOL or "RUB", currency=ctx.currency or settings.DEFAULT_CURRENCY_SYMBOL or "RUB",
status="pending_platega", status="pending_platega",
provider="platega", provider="platega",
) )
@@ -616,7 +633,7 @@ async def _create_webapp_payment(ctx: WebAppPaymentContext, variant: str) -> web
) )
success, response_data = await service.create_transaction( success, response_data = await service.create_transaction(
amount=ctx.price, amount=ctx.price,
currency=settings.DEFAULT_CURRENCY_SYMBOL or "RUB", currency=ctx.currency or settings.DEFAULT_CURRENCY_SYMBOL or "RUB",
description=ctx.description, description=ctx.description,
payload=payload, payload=payload,
payment_method=platega_method_id, payment_method=platega_method_id,
@@ -757,6 +774,18 @@ _CONFIG_MANIFEST = (
subsection="Platega", subsection="Platega",
attr="CRYPTO_METHOD", 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( ProviderManifestField(
"PLATEGA_RETURN_URL", "url", "Return URL", subsection="Platega", attr="RETURN_URL" "PLATEGA_RETURN_URL", "url", "Return URL", subsection="Platega", attr="RETURN_URL"
), ),
@@ -793,6 +822,9 @@ SBP_SPEC = PaymentProviderSpec(
presentation_class=PlategaSbpPresentation, presentation_class=PlategaSbpPresentation,
manifest_fields=_CONFIG_MANIFEST manifest_fields=_CONFIG_MANIFEST
+ _platega_presentation_manifest("Platega", "CreditCard", "PLATEGA_SBP"), + _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( CRYPTO_SPEC = PaymentProviderSpec(
@@ -818,6 +850,9 @@ CRYPTO_SPEC = PaymentProviderSpec(
config_class=PlategaConfig, config_class=PlategaConfig,
presentation_class=PlategaCryptoPresentation, presentation_class=PlategaCryptoPresentation,
manifest_fields=_platega_presentation_manifest("Platega", "Bitcoin", "PLATEGA_CRYPTO"), 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) SPECS = (SBP_SPEC, CRYPTO_SPEC)
+36 -4
View File
@@ -16,6 +16,10 @@ from bot.middlewares.i18n import JsonI18n
from bot.services.referral_service import ReferralService from bot.services.referral_service import ReferralService
from bot.services.subscription_service import SubscriptionService from bot.services.subscription_service import SubscriptionService
from config.settings import Settings 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 db.dal import payment_dal
from .base import ( from .base import (
@@ -24,6 +28,8 @@ from .base import (
ProviderManifestField, ProviderManifestField,
ServiceFactoryContext, ServiceFactoryContext,
WebAppPaymentContext, WebAppPaymentContext,
normalize_payment_currency_code,
parse_supported_currency_codes,
provider_env_file, provider_env_file,
provider_runtime_enabled, provider_runtime_enabled,
) )
@@ -69,6 +75,7 @@ class SeverPayConfig(ProviderEnvConfig):
RETURN_URL: Optional[str] = None RETURN_URL: Optional[str] = None
BASE_URL: str = Field(default="https://severpay.io/api/merchant") BASE_URL: str = Field(default="https://severpay.io/api/merchant")
LIFETIME_MINUTES: Optional[int] = None LIFETIME_MINUTES: Optional[int] = None
SUPPORTED_CURRENCIES: str = Field(default="RUB,USD")
@field_validator("MID", "LIFETIME_MINUTES", mode="before") @field_validator("MID", "LIFETIME_MINUTES", mode="before")
@classmethod @classmethod
@@ -201,9 +208,19 @@ class SeverPayService(HttpClientMixin):
logging.error("SeverPayService is not configured. Cannot create payment.") logging.error("SeverPayService is not configured. Cannot create payment.")
return False, {"message": "service_not_configured"} 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() session = await self._get_session()
url = f"{self.base_url}/payin/create" url = f"{self.base_url}/payin/create"
currency_code = (currency or self.settings.DEFAULT_CURRENCY_SYMBOL or "RUB").upper()
body = { body = {
"order_id": str(payment_db_id), "order_id": str(payment_db_id),
@@ -428,13 +445,13 @@ async def pay_severpay_callback_handler(
user_id=callback.from_user.id, user_id=callback.from_user.id,
parts=parts, parts=parts,
subscription_service=severpay_service.subscription_service, subscription_service=severpay_service.subscription_service,
currency="rub", currency=default_currency_key_for_settings(settings),
) )
if not parts: if not parts:
await notify_callback_parse_error(callback, translator) await notify_callback_parse_error(callback, translator)
return return
currency_code = settings.DEFAULT_CURRENCY_SYMBOL or "RUB" currency_code = default_payment_currency_code_for_settings(settings)
payment_description = describe_payment(translator, parts) payment_description = describe_payment(translator, parts)
record_payload = build_payment_record_payload( record_payload = build_payment_record_payload(
user_id=callback.from_user.id, 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: if not service or not service.configured:
return payment_unavailable() return payment_unavailable()
currency = settings.DEFAULT_CURRENCY_SYMBOL or "RUB" currency = ctx.currency or settings.DEFAULT_CURRENCY_SYMBOL or "RUB"
try: try:
payment = await create_webapp_payment_record( payment = await create_webapp_payment_record(
ctx, ctx,
@@ -627,6 +644,18 @@ _CONFIG_MANIFEST = (
max=4320, max=4320,
attr="LIFETIME_MINUTES", 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, config_class=SeverPayConfig,
presentation_class=SeverPayPresentation, presentation_class=SeverPayPresentation,
manifest_fields=_CONFIG_MANIFEST + _PRESENTATION_MANIFEST, 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",
) )
+2
View File
@@ -484,4 +484,6 @@ SPEC = PaymentProviderSpec(
telegram_emoji="", telegram_emoji="",
presentation_class=StarsPresentation, presentation_class=StarsPresentation,
manifest_fields=_PRESENTATION_MANIFEST, manifest_fields=_PRESENTATION_MANIFEST,
supported_currencies=("XTR",),
currency_support_note="Telegram Stars use Telegram's XTR currency and separate Stars prices.",
) )
+23 -4
View File
@@ -19,6 +19,10 @@ from bot.services.referral_service import ReferralService
from bot.services.subscription_service import SubscriptionService from bot.services.subscription_service import SubscriptionService
from bot.utils.request_security import ip_in_allowlist, request_client_ip from bot.utils.request_security import ip_in_allowlist, request_client_ip
from config.settings import Settings 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 db.dal import payment_dal
from .base import ( from .base import (
@@ -27,6 +31,7 @@ from .base import (
ProviderManifestField, ProviderManifestField,
ServiceFactoryContext, ServiceFactoryContext,
WebAppPaymentContext, WebAppPaymentContext,
normalize_payment_currency_code,
provider_env_file, provider_env_file,
provider_runtime_enabled, provider_runtime_enabled,
) )
@@ -63,6 +68,7 @@ from .shared import (
router = Router(name="user_subscription_payments_wata_router") router = Router(name="user_subscription_payments_wata_router")
_LOG = "wata" _LOG = "wata"
WATA_SUPPORTED_CURRENCIES = ("RUB", "USD", "EUR")
_WATA_IN_PROGRESS_STATUSES = {"created", "pending"} _WATA_IN_PROGRESS_STATUSES = {"created", "pending"}
_WATA_LINK_OPENED_STATUSES = {"opened", "open"} _WATA_LINK_OPENED_STATUSES = {"opened", "open"}
_WATA_LINK_DEFAULT_TTL_MINUTES = 15 _WATA_LINK_DEFAULT_TTL_MINUTES = 15
@@ -258,13 +264,23 @@ class WataService(HttpClientMixin):
logging.error("WataService is not configured. Cannot create payment link.") logging.error("WataService is not configured. Cannot create payment link.")
return False, {"message": "service_not_configured"} 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() session = await self._get_session()
expires_at = ( expires_at = (
datetime.now(timezone.utc) + timedelta(minutes=self.payment_link_ttl_minutes) datetime.now(timezone.utc) + timedelta(minutes=self.payment_link_ttl_minutes)
).replace(microsecond=0) ).replace(microsecond=0)
body: Dict[str, Any] = { body: Dict[str, Any] = {
"amount": float(format_decimal_amount(amount)), "amount": float(format_decimal_amount(amount)),
"currency": (currency or self.settings.DEFAULT_CURRENCY_SYMBOL or "RUB").upper(), "currency": currency_code,
"description": description, "description": description,
"orderId": str(payment_db_id), "orderId": str(payment_db_id),
"successRedirectUrl": self.return_url, "successRedirectUrl": self.return_url,
@@ -863,13 +879,13 @@ async def pay_wata_callback_handler(
user_id=callback.from_user.id, user_id=callback.from_user.id,
parts=parts, parts=parts,
subscription_service=wata_service.subscription_service, subscription_service=wata_service.subscription_service,
currency="rub", currency=default_currency_key_for_settings(settings),
) )
if not parts: if not parts:
await notify_callback_parse_error(callback, translator) await notify_callback_parse_error(callback, translator)
return return
currency_code = settings.DEFAULT_CURRENCY_SYMBOL or "RUB" currency_code = default_payment_currency_code_for_settings(settings)
payment_description = describe_payment(translator, parts) payment_description = describe_payment(translator, parts)
reuse_amounts = payment_record_amounts(months=parts.months, sale_mode=parts.sale_mode) 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: if not service or not service.configured:
return payment_unavailable() 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( reuse_amounts = payment_record_amounts(
months=ctx.months, months=ctx.months,
@@ -1190,4 +1206,7 @@ SPEC = PaymentProviderSpec(
config_class=WataConfig, config_class=WataConfig,
presentation_class=WataPresentation, presentation_class=WataPresentation,
manifest_fields=_CONFIG_MANIFEST + _PRESENTATION_MANIFEST, 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",
) )
+22 -8
View File
@@ -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.install_links import ensure_user_install_guide_links
from bot.utils.request_security import ip_in_allowlist, request_client_ip from bot.utils.request_security import ip_in_allowlist, request_client_ip
from config.settings import Settings 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.dal import payment_dal, user_billing_dal, user_dal
from db.models import Payment from db.models import Payment
@@ -49,6 +53,7 @@ from .base import (
ProviderManifestField, ProviderManifestField,
ServiceFactoryContext, ServiceFactoryContext,
WebAppPaymentContext, WebAppPaymentContext,
normalize_payment_currency_code,
provider_env_file, provider_env_file,
provider_runtime_enabled, provider_runtime_enabled,
) )
@@ -234,6 +239,11 @@ class YooKassaService:
"internal_message": "Service settings (Settings object) not initialized.", "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 = {} customer_contact_for_receipt = {}
if receipt_email: if receipt_email:
customer_contact_for_receipt["email"] = receipt_email customer_contact_for_receipt["email"] = receipt_email
@@ -885,7 +895,7 @@ async def process_successful_payment(
i18n=i18n, i18n=i18n,
user_id=user_id, user_id=user_id,
amount=payment_value, 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, months_for_admin=int(subscription_months) if sale_mode_base == "subscription" else 0,
traffic_gb_for_admin=( traffic_gb_for_admin=(
traffic_amount_gb if is_traffic_sale_base(sale_mode_base) else None 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, user_id=callback.from_user.id,
parts=PaymentCallbackParts(months=months, price=price_rub, sale_mode=sale_mode), parts=PaymentCallbackParts(months=months, price=price_rub, sale_mode=sale_mode),
subscription_service=yookassa_service.subscription_service, subscription_service=yookassa_service.subscription_service,
currency="rub", currency=default_currency_key_for_settings(settings),
) )
if not quoted_parts: if not quoted_parts:
try: try:
@@ -1713,7 +1723,7 @@ async def pay_yk_callback_handler(
months = quoted_parts.months months = quoted_parts.months
price_rub = quoted_parts.price price_rub = quoted_parts.price
user_id = callback.from_user.id 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( autopay_enabled = bool(
settings.yookassa_autopayments_active settings.yookassa_autopayments_active
and _sale_mode_base(sale_mode) == "subscription" and _sale_mode_base(sale_mode) == "subscription"
@@ -1851,7 +1861,7 @@ async def pay_yk_new_card_handler(
months, price_rub, sale_mode = parsed months, price_rub, sale_mode = parsed
user_id = callback.from_user.id 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( autopay_enabled = bool(
settings.yookassa_autopayments_active settings.yookassa_autopayments_active
and _sale_mode_base(sale_mode) == "subscription" and _sale_mode_base(sale_mode) == "subscription"
@@ -2154,7 +2164,7 @@ async def pay_yk_use_saved_handler(
pass pass
return return
currency_code_for_yk = "RUB" currency_code_for_yk = default_payment_currency_code_for_settings(settings)
await _initiate_yk_payment( await _initiate_yk_payment(
callback, callback,
@@ -2260,7 +2270,7 @@ async def payment_method_bind(
metadata = {"user_id": str(callback.from_user.id), "bind_only": "1"} metadata = {"user_id": str(callback.from_user.id), "bind_only": "1"}
resp = await yookassa_service.create_payment( resp = await yookassa_service.create_payment(
amount=1.00, amount=1.00,
currency="RUB", currency=default_payment_currency_code_for_settings(settings),
description="Bind card", description="Bind card",
metadata=metadata, metadata=metadata,
receipt_email=yookassa_service.config.DEFAULT_RECEIPT_EMAIL, 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"] service: YooKassaService = ctx.request.app["yookassa_service"]
if not service or not service.configured: if not service or not service.configured:
return payment_unavailable() return payment_unavailable()
currency = (ctx.currency or "RUB").upper()
try: try:
amounts = payment_record_amounts( amounts = payment_record_amounts(
@@ -2744,7 +2755,7 @@ async def create_webapp_payment(ctx: WebAppPaymentContext) -> web.Response:
payment = await create_webapp_payment_record( payment = await create_webapp_payment_record(
ctx, ctx,
amount=ctx.price, amount=ctx.price,
currency="RUB", currency=currency,
status="pending_yookassa", status="pending_yookassa",
provider="yookassa", provider="yookassa",
) )
@@ -2767,7 +2778,7 @@ async def create_webapp_payment(ctx: WebAppPaymentContext) -> web.Response:
metadata["tariff_key"] = amounts.tariff_key metadata["tariff_key"] = amounts.tariff_key
response = await service.create_payment( response = await service.create_payment(
amount=ctx.price, amount=ctx.price,
currency="RUB", currency=currency,
description=ctx.description, description=ctx.description,
metadata=metadata, metadata=metadata,
receipt_email=service.config.DEFAULT_RECEIPT_EMAIL, receipt_email=service.config.DEFAULT_RECEIPT_EMAIL,
@@ -2929,4 +2940,7 @@ SPEC = PaymentProviderSpec(
config_class=YooKassaConfig, config_class=YooKassaConfig,
presentation_class=YooKassaPresentation, presentation_class=YooKassaPresentation,
manifest_fields=_CONFIG_MANIFEST + _PRESENTATION_MANIFEST, 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",
) )
@@ -11,7 +11,11 @@ from bot.middlewares.i18n import JsonI18n
from bot.utils.config_link import prepare_config_links from bot.utils.config_link import prepare_config_links
from bot.utils.date_utils import add_months, month_start from bot.utils.date_utils import add_months, month_start
from config.settings import Settings 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 ( from db.dal import (
payment_dal, payment_dal,
promo_code_dal, promo_code_dal,
@@ -70,7 +70,7 @@ class HwidDeviceMixin:
package_set = tariff.hwid_device_packages package_set = tariff.hwid_device_packages
if not package_set: if not package_set:
return None 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) return next((pkg for pkg in packages if int(pkg.count) == int(device_count)), None)
def _quote_hwid_package_price( def _quote_hwid_package_price(
@@ -173,7 +173,7 @@ class HwidDeviceMixin:
valid_from=valid_from, valid_from=valid_from,
valid_until=valid_until, valid_until=valid_until,
now=now, now=now,
currency="stars" if currency == "stars" else "rub", currency=currency,
) )
quote.update( quote.update(
{ {
@@ -227,7 +227,11 @@ class HwidDeviceMixin:
) )
return None return None
packages = ( 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 if tariff.hwid_device_packages
else [] else []
) )
@@ -249,7 +249,8 @@ class SubscriptionLifecycleMixin:
) )
update_data["period_start_at"] = None update_data["period_start_at"] = None
update_data["effective_monthly_price_rub"] = ( 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: if mode == "recalc_days" and options.get("recalc_days") is not None:
update_data["end_date"] = now + timedelta(days=int(options["recalc_days"])) update_data["end_date"] = now + timedelta(days=int(options["recalc_days"]))
@@ -120,7 +120,7 @@ class PaymentContextMixin:
months=int(months or 0), months=int(months or 0),
traffic_gb=traffic_gb, traffic_gb=traffic_gb,
amount=float(payment_amount or 0), 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, end_date_text=end_date_text,
dashboard_url=dashboard_url, dashboard_url=dashboard_url,
provider_label=provider_label, provider_label=provider_label,
@@ -41,6 +41,20 @@ class RenewalMixin:
return False return False
months = sub.duration_months or 1 months = sub.duration_months or 1
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) amount = self.settings.subscription_options.get(months)
if not amount: if not amount:
logging.error(f"Auto-renew price missing for {months} months") logging.error(f"Auto-renew price missing for {months} months")
@@ -53,7 +67,7 @@ class RenewalMixin:
} }
resp = await yk.create_payment( resp = await yk.create_payment(
amount=float(amount), amount=float(amount),
currency="RUB", currency=currency,
description=f"Auto-renewal for {months} months", description=f"Auto-renewal for {months} months",
metadata=metadata, metadata=metadata,
payment_method_id=default_pm.provider_payment_method_id, payment_method_id=default_pm.provider_payment_method_id,
@@ -331,11 +331,12 @@ class TariffMixin:
remaining_days = max(0, (sub.end_date - now).days) if sub.end_date else 0 remaining_days = max(0, (sub.end_date - now).days) if sub.end_date else 0
effective = float(sub.effective_monthly_price_rub or 0) effective = float(sub.effective_monthly_price_rub or 0)
current_model = current_tariff.billing_model if current_tariff else "period" 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": if current_model == "period" and target_tariff.billing_model == "period":
target_monthly = ( target_monthly = (
target_tariff.period_price(1, "rub") target_tariff.period_price(1, default_currency)
or target_tariff.min_period_price_rub() or target_tariff.min_period_price(default_currency)
or effective or effective
or 1 or 1
) )
@@ -355,11 +356,14 @@ class TariffMixin:
"remaining_days": remaining_days, "remaining_days": remaining_days,
"recalc_days": max(0, days_after), "recalc_days": max(0, days_after),
"paid_diff_rub": paid_diff, "paid_diff_rub": paid_diff,
"paid_diff": paid_diff,
"target_monthly_rub": float(target_monthly), "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": 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 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 converted_gb = math.floor(remaining_value / rub_per_gb) if rub_per_gb else 0
return { return {
@@ -367,9 +371,15 @@ class TariffMixin:
"remaining_days": remaining_days, "remaining_days": remaining_days,
"converted_gb": max(0, converted_gb), "converted_gb": max(0, converted_gb),
"rub_per_gb": rub_per_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 @staticmethod
def _aware_utc(value: Optional[datetime]) -> Optional[datetime]: def _aware_utc(value: Optional[datetime]) -> Optional[datetime]:
@@ -434,6 +444,7 @@ class TariffMixin:
credit = await self._hwid_conversion_credit(session, sub, at=now) credit = await self._hwid_conversion_credit(session, sub, at=now)
value_rub = float(credit.get("value_rub") or 0) value_rub = float(credit.get("value_rub") or 0)
options["converted_hwid_value_rub"] = round(value_rub, 2) 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["convertible_hwid_purchase_ids"] = list(credit.get("purchase_ids") or [])
options["nonconverted_hwid_devices"] = int(credit.get("skipped_devices") or 0) options["nonconverted_hwid_devices"] = int(credit.get("skipped_devices") or 0)
if value_rub <= 0: if value_rub <= 0:
+194 -31
View File
@@ -1,14 +1,53 @@
import json import json
import logging import logging
from pathlib import Path 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"] 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): class TrafficPackage(BaseModel):
gb: float gb: float
price: float price: float
@@ -61,26 +100,74 @@ class HwidDevicePackage(BaseModel):
return float(self.price) * months_int return float(self.price) * months_int
class PackageSet(BaseModel): class PackageSet(RootModel[Dict[str, List[TrafficPackage]]]):
rub: List[TrafficPackage] = Field(default_factory=list) root: Dict[str, List[TrafficPackage]] = Field(default_factory=dict)
stars: List[TrafficPackage] = Field(default_factory=list)
@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]: 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: 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): class HwidDevicePackageSet(RootModel[Dict[str, List[HwidDevicePackage]]]):
rub: List[HwidDevicePackage] = Field(default_factory=list) root: Dict[str, List[HwidDevicePackage]] = Field(default_factory=dict)
stars: List[HwidDevicePackage] = Field(default_factory=list)
@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]: 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: 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): class Tariff(BaseModel):
@@ -93,6 +180,7 @@ class Tariff(BaseModel):
enabled: bool = True enabled: bool = True
monthly_gb: Optional[float] = None 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_rub: Dict[str, float] = Field(default_factory=dict)
prices_stars: 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) referral_bonus_days_inviter: Dict[str, int] = Field(default_factory=dict)
@@ -101,6 +189,7 @@ class Tariff(BaseModel):
topup_packages: Optional[PackageSet] = None topup_packages: Optional[PackageSet] = None
traffic_packages: Optional[PackageSet] = None traffic_packages: Optional[PackageSet] = None
conversion_rate_per_gb: Optional[float] = None
conversion_rate_rub_per_gb: Optional[float] = None conversion_rate_rub_per_gb: Optional[float] = None
hwid_device_limit: Optional[int] = None hwid_device_limit: Optional[int] = None
hwid_device_packages: Optional[HwidDevicePackageSet] = 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: 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") 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.billing_model == "period":
if self.monthly_gb is None or self.monthly_gb < 0: if self.monthly_gb is None or self.monthly_gb < 0:
raise ValueError(f"period tariff {self.key}: monthly_gb must be >= 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: for months in self.enabled_periods:
if months <= 0: if months <= 0:
raise ValueError(f"period tariff {self.key}: enabled periods must be positive") raise ValueError(f"period tariff {self.key}: enabled periods must be positive")
rub_price = self.prices_rub.get(str(months), 0) or 0 period_prices = [
stars_price = self.prices_stars.get(str(months), 0) or 0 float(prices.get(str(months), 0) or 0) for prices in self.prices.values()
if rub_price <= 0 and stars_price <= 0: ]
if not any(price > 0 for price in period_prices):
raise ValueError( 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 return self
if not self.traffic_packages or not self.traffic_packages.has_any(): if not self.traffic_packages or not self.traffic_packages.has_any():
raise ValueError(f"traffic tariff {self.key}: traffic_packages is required") 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: 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 must be > 0")
if not self.traffic_packages.rub and self.conversion_rate_rub_per_gb is None:
raise ValueError( 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 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( def _normalize_referral_bonus_map(
self, values: Dict[str, int], field_name: str self, values: Dict[str, int], field_name: str
) -> Dict[str, int]: ) -> Dict[str, int]:
@@ -196,7 +335,7 @@ class Tariff(BaseModel):
return int(float(self.monthly_gb) * (1024**3)) return int(float(self.monthly_gb) * (1024**3))
def period_price(self, months: int, currency: Currency = "rub") -> Optional[float]: 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)) value = source.get(str(months))
return float(value) if value is not None else None 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))) value = self.referral_bonus_days_referee.get(str(int(months)))
return int(value) if value is not None else None 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 = [ prices = [
float(self.prices_rub[str(months)]) float(source[str(months)])
for months in self.enabled_periods 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 return min(prices) if prices else None
def min_traffic_package_rub(self) -> Optional[TrafficPackage]: def min_period_price_rub(self) -> Optional[float]:
packages = self.traffic_packages.rub if self.traffic_packages else [] 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 return min(packages, key=lambda pkg: pkg.price) if packages else None
def rub_per_gb_for_conversion(self) -> float: def min_traffic_package_rub(self) -> Optional[TrafficPackage]:
if self.conversion_rate_rub_per_gb: return self.min_traffic_package("rub")
return float(self.conversion_rate_rub_per_gb)
packages = self.traffic_packages.rub if self.traffic_packages else [] 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) 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: def has_hwid_device_packages(self) -> bool:
return bool(self.hwid_device_packages and self.hwid_device_packages.has_any()) return bool(self.hwid_device_packages and self.hwid_device_packages.has_any())
@@ -244,11 +399,15 @@ class Tariff(BaseModel):
class TariffsConfig(BaseModel): class TariffsConfig(BaseModel):
default_tariff: str default_tariff: str
default_currency: str = DEFAULT_TARIFF_CURRENCY
topup_packages_default: Optional[PackageSet] = None topup_packages_default: Optional[PackageSet] = None
tariffs: List[Tariff] tariffs: List[Tariff]
@model_validator(mode="after") @model_validator(mode="after")
def validate_config(self) -> "TariffsConfig": 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] keys = [tariff.key for tariff in self.tariffs]
if len(keys) != len(set(keys)): if len(keys) != len(set(keys)):
raise ValueError("tariff keys must be unique") raise ValueError("tariff keys must be unique")
@@ -277,6 +436,10 @@ class TariffsConfig(BaseModel):
def default(self) -> Tariff: def default(self) -> Tariff:
return self.require(self.default_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]: def topup_packages_for(self, tariff: Tariff) -> Optional[PackageSet]:
if tariff.billing_model == "traffic": if tariff.billing_model == "traffic":
return tariff.traffic_packages return tariff.traffic_packages
+1
View File
@@ -1,5 +1,6 @@
{ {
"default_tariff": "standard", "default_tariff": "standard",
"default_currency": "rub",
"tariffs": [ "tariffs": [
{ {
"key": "standard", "key": "standard",
+41 -12
View File
@@ -22,10 +22,10 @@ JSON-каталог может содержать несколько тариф
- добавление, редактирование и удаление тарифов; - добавление, редактирование и удаление тарифов;
- включение и выключение тарифа на витрине; - включение и выключение тарифа на витрине;
- выбор тарифа по умолчанию; - выбор тарифа по умолчанию;
- настройка тарифов на срок (`period`): месячный лимит, периоды, RUB/Stars цены, реферальные бонусы и пакеты докупки трафика; - настройка тарифов на срок (`period`): месячный лимит, периоды, цены в платежной валюте/Stars, реферальные бонусы и пакеты докупки трафика;
- настройка тарифов по трафику (`traffic`): пакеты GB, RUB/Stars цены, курс конвертации; - настройка тарифов по трафику (`traffic`): пакеты GB, цены в платежной валюте/Stars, курс конвертации;
- настройка базовых Internal Squads из списка Remnawave; - настройка базовых Internal Squads из списка Remnawave;
- настройка premium-раздела: названия RU/EN, premium Internal Squads, месячный premium-лимит и RUB/Stars пакеты докупки premium-трафика; - настройка premium-раздела: названия RU/EN, premium Internal Squads, месячный premium-лимит и пакеты докупки premium-трафика в платежной валюте/Stars;
- настройка базового HWID-лимита и пакетов докупки устройств. - настройка базового HWID-лимита и пакетов докупки устройств.
После сохранения изменения применяются к новым запросам Web App сразу, потому что конфиг тарифов загружается из JSON при обращении. Уже созданные подписки сохраняют свой `tariff_key`; при удалении или отключении тарифа проверьте, что активные подписки с этим ключом не требуют дальнейшего продления или смены. После сохранения изменения применяются к новым запросам Web App сразу, потому что конфиг тарифов загружается из JSON при обращении. Уже созданные подписки сохраняют свой `tariff_key`; при удалении или отключении тарифа проверьте, что активные подписки с этим ключом не требуют дальнейшего продления или смены.
@@ -44,6 +44,31 @@ JSON-каталог может содержать несколько тариф
В режиме без JSON-каталога наличие `TRAFFIC_PACKAGES` или `STARS_TRAFFIC_PACKAGES` переключает витрину на продажу трафика вместо подписок на срок. В режиме без 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-каталога ## Структура JSON-каталога
Минимальная структура: Минимальная структура:
@@ -51,6 +76,7 @@ JSON-каталог может содержать несколько тариф
```json ```json
{ {
"default_tariff": "standard", "default_tariff": "standard",
"default_currency": "rub",
"tariffs": [ "tariffs": [
{ {
"key": "standard", "key": "standard",
@@ -90,6 +116,7 @@ JSON-каталог может содержать несколько тариф
| Поле | Назначение | | Поле | Назначение |
| --- | --- | | --- | --- |
| `default_tariff` | Тариф по умолчанию для первичного выбора и привязки активных подписок без `tariff_key`. | | `default_tariff` | Тариф по умолчанию для первичного выбора и привязки активных подписок без `tariff_key`. |
| `default_currency` | Валюта цен по умолчанию для JSON-каталога. По умолчанию `rub`; `stars` запрещен, потому что Stars используют отдельные цены. |
| `tariffs[].key` | Стабильный ключ тарифа. Используется в платежах, подписках и смене тарифа. | | `tariffs[].key` | Стабильный ключ тарифа. Используется в платежах, подписках и смене тарифа. |
| `tariffs[].names` | Названия тарифа по языкам. | | `tariffs[].names` | Названия тарифа по языкам. |
| `tariffs[].descriptions` | Описания тарифа по языкам. | | `tariffs[].descriptions` | Описания тарифа по языкам. |
@@ -108,7 +135,8 @@ JSON-каталог может содержать несколько тариф
| Поле | Назначение | | Поле | Назначение |
| --- | --- | | --- | --- |
| `monthly_gb` | Базовый месячный лимит трафика тарифа. `0` означает безлимит. | | `monthly_gb` | Базовый месячный лимит трафика тарифа. `0` означает безлимит. |
| `prices_rub` | Цены периодов в рублях, ключ - количество месяцев. | | `prices` | Generic-цены периодов по валютам, например `{ "usd": { "1": 4.99 } }`. |
| `prices_rub` | Legacy-цены периодов в рублях, ключ - количество месяцев. Эквивалент `prices.rub`. |
| `prices_stars` | Цены периодов в Telegram Stars. | | `prices_stars` | Цены периодов в Telegram Stars. |
| `referral_bonus_days_inviter` | Бонус пригласившему в днях для каждого периода. Ключ - количество месяцев, как в `enabled_periods`. | | `referral_bonus_days_inviter` | Бонус пригласившему в днях для каждого периода. Ключ - количество месяцев, как в `enabled_periods`. |
| `referral_bonus_days_referee` | Бонус приглашенному в днях для каждого периода. Ключ - количество месяцев, как в `enabled_periods`. | | `referral_bonus_days_referee` | Бонус приглашенному в днях для каждого периода. Ключ - количество месяцев, как в `enabled_periods`. |
@@ -119,10 +147,11 @@ JSON-каталог может содержать несколько тариф
| Поле | Назначение | | Поле | Назначение |
| --- | --- | | --- | --- |
| `traffic_packages` | Пакеты трафика в GB для рублей и Telegram Stars. | | `traffic_packages` | Пакеты трафика в GB по валютам каталога и Telegram Stars. |
| `conversion_rate_rub_per_gb` | Курс для конвертации оставшихся дней period-тарифа в GB при смене на traffic-тариф. | | `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`) ## Тарифы на срок (`period`)
@@ -248,10 +277,10 @@ limit_after = current_used + balance_after
- при безлимитном базовом лимите докупка устройств не применяется; - при безлимитном базовом лимите докупка устройств не применяется;
- полная цена HWID-пакета берется из `prices[duration_months]`; если периода нет, используется fallback `price * duration_months`; - полная цена HWID-пакета берется из `prices[duration_months]`; если периода нет, используется fallback `price * duration_months`;
- фактическая цена докупки считается пропорционально оплачиваемому окну `valid_from -> valid_until` относительно периода подписки и фиксируется в платежe; - фактическая цена докупки считается пропорционально оплачиваемому окну `valid_from -> valid_until` относительно периода подписки и фиксируется в платежe;
- для Telegram Stars цена округляется вверх до целого Stars, для RUB — вверх до копеек; `min_price` защищает от микроплатежей в конце периода; - для Telegram Stars цена округляется вверх до целого Stars, для платежной валюты — вверх до копеек; `min_price` защищает от микроплатежей в конце периода;
- при продлении подписки докупленные устройства не продлеваются автоматически: старая докупка действует до прежнего `end_date`, а для нового срока создается отдельная `hwid_devices_renewal`-покупка; - при продлении подписки докупленные устройства не продлеваются автоматически: старая докупка действует до прежнего `end_date`, а для нового срока создается отдельная `hwid_devices_renewal`-покупка;
- `traffic`-тарифы не показывают и не принимают докупку HWID-устройств, потому что у них нет срока подписки; - `traffic`-тарифы не показывают и не принимают докупку HWID-устройств, потому что у них нет срока подписки;
- при смене тарифа базовый лимит берется из целевого тарифа, а неиспользованная RUB-стоимость HWID-докупок конвертируется в дни нового period-тарифа или GB traffic-тарифа; XTR/Stars-докупки не конвертируются без явного курса и продолжают жить по своему `valid_until`; - при смене тарифа базовый лимит берется из целевого тарифа, а неиспользованная стоимость HWID-докупок в платежной валюте конвертируется в дни нового period-тарифа или GB traffic-тарифа; XTR/Stars-докупки не конвертируются без явного курса и продолжают жить по своему `valid_until`;
- история докупок пишется в `hwid_device_purchases`; - история докупок пишется в `hwid_device_purchases`;
- платеж хранит количество устройств в `payments.purchased_hwid_devices`. - платеж хранит количество устройств в `payments.purchased_hwid_devices`.
@@ -265,9 +294,9 @@ limit_after = current_used + balance_after
| Переход | Поведение | | Переход | Поведение |
| --- | --- | | --- | --- |
| `period -> period` | Остаток оплаченных дней оценивается по `effective_monthly_price_rub`, затем пересчитывается в дни целевого тарифа через месячную цену целевого тарифа. Неиспользованная RUB-стоимость HWID-докупок добавляется к этому расчету как дополнительные дни. Количество дней округляется вниз. | | `period -> period` | Остаток оплаченных дней оценивается по legacy-полю `effective_monthly_price_rub`, где хранится месячная цена в платежной валюте каталога, затем пересчитывается в дни целевого тарифа через месячную цену целевого тарифа. Неиспользованная стоимость HWID-докупок в платежной валюте добавляется к этому расчету как дополнительные дни. Количество дней округляется вниз. |
| `period -> period` с доплатой | Если целевой тариф дороже, может быть создан платеж `tariff_upgrade`; неиспользованная RUB-стоимость HWID-докупок уменьшает сумму доплаты. После оплаты применяется целевой тариф, а конвертированные HWID-окна закрываются. | | `period -> period` с доплатой | Если целевой тариф дороже, может быть создан платеж `tariff_upgrade`; неиспользованная стоимость HWID-докупок в платежной валюте уменьшает сумму доплаты. После оплаты применяется целевой тариф, а конвертированные HWID-окна закрываются. |
| `period -> traffic` | Остаток оплаченных дней и неиспользованная RUB-стоимость HWID-докупок конвертируются в GB по `conversion_rate_rub_per_gb` или минимальной RUB-цене GB из пакетов целевого тарифа. | | `period -> traffic` | Остаток оплаченных дней и неиспользованная стоимость HWID-докупок в платежной валюте конвертируются в GB по `conversion_rate_per_gb` или минимальной цене GB из пакетов целевого тарифа. |
| `traffic -> period` | Пользователь выбирает и оплачивает период целевого тарифа; остаток GB сохраняется как `topup_balance_bytes` поверх лимита period-тарифа. | | `traffic -> period` | Пользователь выбирает и оплачивает период целевого тарифа; остаток GB сохраняется как `topup_balance_bytes` поверх лимита period-тарифа. |
При смене тарифа бот меняет: При смене тарифа бот меняет:
@@ -5,7 +5,7 @@
import { Plus, Save, Trash2, X } from "$components/ui/icons.js"; import { Plus, Save, Trash2, X } from "$components/ui/icons.js";
import { AdminButton, AdminSelect } from "$components/patterns/admin/index.js"; import { AdminButton, AdminSelect } from "$components/patterns/admin/index.js";
import { getContext } from "svelte"; import { getContext } from "svelte";
import { normalizeUuidList } from "../../lib/admin/tariffDraft.js"; import { normalizeCurrencyKey, normalizeUuidList } from "../../lib/admin/tariffDraft.js";
export let at; export let at;
const tariffsStore = getContext("tariffsStore"); const tariffsStore = getContext("tariffsStore");
@@ -19,6 +19,7 @@
tariffDeleteTarget, tariffDeleteTarget,
panelSquadsLoading, panelSquadsLoading,
panelSquads, panelSquads,
tariffsCatalog,
} = $tariffsStore); } = $tariffsStore);
$: billingModelOptions = [ $: billingModelOptions = [
@@ -29,6 +30,33 @@
value: squad.uuid, value: squad.uuid,
label: squad.name, 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`
);
</script> </script>
<Dialog <Dialog
@@ -241,7 +269,7 @@
</Label.Root> </Label.Root>
{:else} {:else}
<Label.Root class="admin-field-label"> <Label.Root class="admin-field-label">
<span>{at("tariff_label_conversion", {}, "Курс конвертации, ₽ за 1 GB")}</span> <span>{conversionCurrencyLabel}</span>
<small <small
>{at( >{at(
"tariff_hint_conversion", "tariff_hint_conversion",
@@ -388,7 +416,7 @@
<AdminButton <AdminButton
size="sm" size="sm"
onclick={() => tariffsStore.addDraftRow("premiumTopupRubRows", { gb: 10, price: "" })} onclick={() => tariffsStore.addDraftRow("premiumTopupRubRows", { gb: 10, price: "" })}
><Plus size={12} /> {at("tariff_btn_package_rub", {}, "Пакет ₽")}</AdminButton ><Plus size={12} /> {currencyPackageLabel}</AdminButton
> >
<AdminButton <AdminButton
size="sm" size="sm"
@@ -400,11 +428,11 @@
</header> </header>
<div class="admin-package-columns"> <div class="admin-package-columns">
<div class="admin-row-editor"> <div class="admin-row-editor">
<span class="admin-row-editor-caption">{at("payment_rub", {}, "Оплата рублями")}</span> <span class="admin-row-editor-caption">{currencyPaymentLabel}</span>
{#if tariffDraft.premiumTopupRubRows.length} {#if tariffDraft.premiumTopupRubRows.length}
<div class="admin-row-editor-line admin-row-editor-header"> <div class="admin-row-editor-line admin-row-editor-header">
<span>{at("tariff_col_volume_gb", {}, "Объём, GB")}</span> <span>{at("tariff_col_volume_gb", {}, "Объём, GB")}</span>
<span>{at("tariff_col_price_rub", {}, "Цена, ₽")}</span> <span>{currencyPriceColumnLabel}</span>
<span></span> <span></span>
</div> </div>
{/if} {/if}
@@ -426,7 +454,7 @@
step="0.01" step="0.01"
placeholder="199" placeholder="199"
bind:value={row.price} bind:value={row.price}
aria-label={at("tariff_label_price_rub", {}, "Цена premium-пакета в рублях")} aria-label={currencyPriceAriaLabel}
/> />
<AdminButton <AdminButton
size="sm" size="sm"
@@ -526,7 +554,7 @@
<div class="admin-row-editor"> <div class="admin-row-editor">
<div class="admin-row-editor-line admin-row-editor-6 admin-row-editor-header"> <div class="admin-row-editor-line admin-row-editor-6 admin-row-editor-header">
<span>{at("tariff_col_period_months", {}, "Срок, мес.")}</span> <span>{at("tariff_col_period_months", {}, "Срок, мес.")}</span>
<span>{at("tariff_col_price_rub", {}, "Цена, ₽")}</span> <span>{currencyPriceColumnLabel}</span>
<span>{at("tariff_col_price_stars_full", {}, "Цена, ⭐ Stars")}</span> <span>{at("tariff_col_price_stars_full", {}, "Цена, ⭐ Stars")}</span>
<span>{at("tariff_col_ref_inviter", {}, "Бонус приглашающему")}</span> <span>{at("tariff_col_ref_inviter", {}, "Бонус приглашающему")}</span>
<span>{at("tariff_col_ref_referee", {}, "Бонус приглашённому")}</span> <span>{at("tariff_col_ref_referee", {}, "Бонус приглашённому")}</span>
@@ -549,7 +577,7 @@
step="0.01" step="0.01"
placeholder="299" placeholder="299"
bind:value={row.rub} bind:value={row.rub}
aria-label={at("tariff_label_price_rub", {}, "Цена в рублях")} aria-label={currencyPriceAriaLabel}
/> />
<Input <Input
class="input" class="input"
@@ -608,7 +636,7 @@
<AdminButton <AdminButton
size="sm" size="sm"
onclick={() => tariffsStore.addDraftRow("trafficRubRows", { gb: 10, price: "" })} onclick={() => tariffsStore.addDraftRow("trafficRubRows", { gb: 10, price: "" })}
><Plus size={12} /> {at("tariff_btn_package_rub", {}, "Пакет ₽")}</AdminButton ><Plus size={12} /> {currencyPackageLabel}</AdminButton
> >
<AdminButton <AdminButton
size="sm" size="sm"
@@ -619,12 +647,11 @@
</header> </header>
<div class="admin-package-columns"> <div class="admin-package-columns">
<div class="admin-row-editor"> <div class="admin-row-editor">
<span class="admin-row-editor-caption">{at("payment_rub", {}, "Оплата рублями")}</span <span class="admin-row-editor-caption">{currencyPaymentLabel}</span>
>
{#if tariffDraft.trafficRubRows.length} {#if tariffDraft.trafficRubRows.length}
<div class="admin-row-editor-line admin-row-editor-header"> <div class="admin-row-editor-line admin-row-editor-header">
<span>{at("tariff_col_volume_gb", {}, "Объём, GB")}</span> <span>{at("tariff_col_volume_gb", {}, "Объём, GB")}</span>
<span>{at("tariff_col_price_rub", {}, "Цена, ₽")}</span> <span>{currencyPriceColumnLabel}</span>
<span></span> <span></span>
</div> </div>
{/if} {/if}
@@ -646,7 +673,7 @@
step="0.01" step="0.01"
placeholder="299" placeholder="299"
bind:value={row.price} bind:value={row.price}
aria-label={at("tariff_label_price_rub", {}, "Цена пакета в рублях")} aria-label={currencyPriceAriaLabel}
/> />
<AdminButton <AdminButton
size="sm" size="sm"
@@ -722,7 +749,7 @@
<AdminButton <AdminButton
size="sm" size="sm"
onclick={() => tariffsStore.addDraftRow("topupRubRows", { gb: 10, price: "" })} onclick={() => tariffsStore.addDraftRow("topupRubRows", { gb: 10, price: "" })}
><Plus size={12} /> {at("tariff_btn_package_rub", {}, "Пакет ₽")}</AdminButton ><Plus size={12} /> {currencyPackageLabel}</AdminButton
> >
<AdminButton <AdminButton
size="sm" size="sm"
@@ -733,12 +760,11 @@
</header> </header>
<div class="admin-package-columns"> <div class="admin-package-columns">
<div class="admin-row-editor"> <div class="admin-row-editor">
<span class="admin-row-editor-caption">{at("payment_rub", {}, "Оплата рублями")}</span <span class="admin-row-editor-caption">{currencyPaymentLabel}</span>
>
{#if tariffDraft.topupRubRows.length} {#if tariffDraft.topupRubRows.length}
<div class="admin-row-editor-line admin-row-editor-header"> <div class="admin-row-editor-line admin-row-editor-header">
<span>{at("tariff_col_volume_gb", {}, "Объём, GB")}</span> <span>{at("tariff_col_volume_gb", {}, "Объём, GB")}</span>
<span>{at("tariff_col_price_rub", {}, "Цена, ₽")}</span> <span>{currencyPriceColumnLabel}</span>
<span></span> <span></span>
</div> </div>
{/if} {/if}
@@ -760,7 +786,7 @@
step="0.01" step="0.01"
placeholder="149" placeholder="149"
bind:value={row.price} bind:value={row.price}
aria-label={at("tariff_label_price_rub", {}, "Цена пакета в рублях")} aria-label={currencyPriceAriaLabel}
/> />
<AdminButton <AdminButton
size="sm" size="sm"
@@ -847,7 +873,7 @@
<AdminButton <AdminButton
size="sm" size="sm"
onclick={() => tariffsStore.addDraftRow("hwidRubRows", { count: 1, price: "" })} onclick={() => tariffsStore.addDraftRow("hwidRubRows", { count: 1, price: "" })}
><Plus size={12} /> {at("tariff_btn_package_rub", {}, "Пакет ₽")}</AdminButton ><Plus size={12} /> {currencyPackageLabel}</AdminButton
> >
<AdminButton <AdminButton
size="sm" size="sm"
@@ -858,11 +884,11 @@
</header> </header>
<div class="admin-package-columns"> <div class="admin-package-columns">
<div class="admin-row-editor"> <div class="admin-row-editor">
<span class="admin-row-editor-caption">{at("payment_rub", {}, "Оплата рублями")}</span> <span class="admin-row-editor-caption">{currencyPaymentLabel}</span>
{#if tariffDraft.hwidRubRows.length} {#if tariffDraft.hwidRubRows.length}
<div class="admin-row-editor-line admin-row-editor-header"> <div class="admin-row-editor-line admin-row-editor-header">
<span>{at("tariff_col_hwid_count", {}, "+ устройств")}</span> <span>{at("tariff_col_hwid_count", {}, "+ устройств")}</span>
<span>{at("tariff_col_price_rub", {}, "Цена, ₽")}</span> <span>{currencyPriceColumnLabel}</span>
<span></span> <span></span>
</div> </div>
{/if} {/if}
@@ -888,7 +914,7 @@
step="0.01" step="0.01"
placeholder="99" placeholder="99"
bind:value={row.price} bind:value={row.price}
aria-label={at("tariff_label_price_rub", {}, "Цена пакета в рублях")} aria-label={currencyPriceAriaLabel}
/> />
<AdminButton <AdminButton
size="sm" size="sm"
@@ -17,6 +17,7 @@
AdminSelect, AdminSelect,
} from "$components/patterns/admin/index.js"; } from "$components/patterns/admin/index.js";
import { Accordion, Switch } from "$components/ui/primitives.js"; import { Accordion, Switch } from "$components/ui/primitives.js";
import { normalizeCurrencyKey } from "$lib/admin/tariffDraft.js";
export let at; export let at;
export let fmtMoney; export let fmtMoney;
@@ -88,6 +89,7 @@
tariffsPath, tariffsPath,
tariffsSaving, tariffsSaving,
panelSquads, panelSquads,
providerCurrencySupport,
panelSquadsLoading, panelSquadsLoading,
} = $tariffsStore); } = $tariffsStore);
$: ({ settingsSections, settingsDirty, settingsSaving } = $settingsStore); $: ({ settingsSections, settingsDirty, settingsSaving } = $settingsStore);
@@ -111,25 +113,30 @@
let selectedTrialSquad = ""; let selectedTrialSquad = "";
let trialSquadSelectKey = 0; let trialSquadSelectKey = 0;
let tariffSettingsOpen = []; let tariffSettingsOpen = [];
let defaultCurrencyDraft = "RUB";
function tariffName(tariff) { function tariffName(tariff) {
return tariff?.names?.ru || tariff?.names?.en || tariff?.key || "—"; return tariff?.names?.ru || tariff?.names?.en || tariff?.key || "—";
} }
function tariffPriceSummary(tariff) { function tariffPriceSummary(tariff) {
const currency = normalizeCurrencyKey(tariffsCatalog.default_currency || "rub");
const currencyCode = currency.toUpperCase();
if (tariff.billing_model === "traffic") { if (tariff.billing_model === "traffic") {
const rub = tariff.traffic_packages?.rub || []; const packages = tariff.traffic_packages?.[currency] || [];
const first = rub[0]; const first = packages[0];
return first return first
? `${first.gb} GB ${at("at", {}, "за")} ${fmtMoney(first.price, "RUB")}` ? `${first.gb} GB ${at("at", {}, "за")} ${fmtMoney(first.price, currencyCode)}`
: at("tariff_traffic_packages", {}, "Пакеты трафика"); : at("tariff_traffic_packages", {}, "Пакеты трафика");
} }
const months = [...(tariff.enabled_periods || [])].sort((a, b) => a - b); const months = [...(tariff.enabled_periods || [])].sort((a, b) => a - b);
return months return months
.map((month) => { .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)]; 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} ⭐`; if (stars) return `${month} ${at("months_short", {}, "мес.")} ${stars} ⭐`;
return `${month} ${at("months_short", {}, "мес.")}`; return `${month} ${at("months_short", {}, "мес.")}`;
}) })
@@ -203,6 +210,27 @@
trialSquadSelectKey += 1; 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) { function removeTrialSquad(uuid) {
setCsvList( setCsvList(
"TRIAL_SQUAD_UUIDS", "TRIAL_SQUAD_UUIDS",
@@ -653,6 +681,62 @@
</div> </div>
</header> </header>
<div class="admin-card-body"> <div class="admin-card-body">
<div class="admin-tariff-catalog-bar">
<label class="admin-field-label-compact">
<span>{at("tariff_default_currency", {}, "Валюта оплаты")}</span>
<Input
class="input admin-currency-input"
type="text"
maxlength="12"
value={defaultCurrencyDraft}
oninput={(event) => (defaultCurrencyDraft = event.currentTarget.value.toUpperCase())}
onkeydown={(event) => {
if (event.key === "Enter" && defaultCurrencyDirty) saveDefaultCurrency();
}}
/>
</label>
<AdminBadge variant="muted">{catalogCurrencyKey.toUpperCase()}</AdminBadge>
{#if defaultCurrencyDirty}
<AdminButton
size="sm"
variant="primary"
onclick={saveDefaultCurrency}
disabled={tariffsSaving}
>
<Save size={13} />
{tariffsSaving
? at("btn_saving", {}, "Сохранение...")
: at("btn_save", {}, "Сохранить")}
</AdminButton>
{/if}
</div>
{#if providerCurrencySupport?.length}
<div class="admin-provider-currency-grid">
{#each providerCurrencySupport as provider}
<div
class="admin-provider-currency"
class:is-supported={provider.supports_default_currency && provider.enabled}
class:is-unavailable={!provider.supports_default_currency || !provider.enabled}
>
<div>
<strong>{provider.label}</strong>
<small>{providerCurrencyLabel(provider)}</small>
</div>
<AdminBadge variant={providerCurrencyVariant(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}
</AdminBadge>
</div>
{/each}
</div>
{/if}
{#if !tariffsCatalog.tariffs.length} {#if !tariffsCatalog.tariffs.length}
<AdminEmptyState> <AdminEmptyState>
{at( {at(
+32 -4
View File
@@ -4,6 +4,7 @@ import {
cloneCatalog, cloneCatalog,
draftFromTariff, draftFromTariff,
tariffFromDraft as tariffFromDraftFn, tariffFromDraft as tariffFromDraftFn,
normalizeCurrencyKey,
normalizeUuidList, normalizeUuidList,
} from "../tariffDraft.js"; } from "../tariffDraft.js";
@@ -11,6 +12,7 @@ export function createTariffsStore({ api, onTariffsSaved, flash, at }) {
const state = writable({ const state = writable({
tariffsCatalog: { tariffsCatalog: {
default_tariff: "", default_tariff: "",
default_currency: "rub",
topup_packages_default: { rub: [], stars: [] }, topup_packages_default: { rub: [], stars: [] },
tariffs: [], tariffs: [],
}, },
@@ -23,13 +25,15 @@ export function createTariffsStore({ api, onTariffsSaved, flash, at }) {
tariffDeleteTarget: null, tariffDeleteTarget: null,
tariffDraft: emptyTariffDraft(), tariffDraft: emptyTariffDraft(),
panelSquads: [], panelSquads: [],
providerCurrencySupport: [],
panelSquadsLoading: false, panelSquadsLoading: false,
selectedBaseSquad: "", selectedBaseSquad: "",
selectedPremiumSquad: "", selectedPremiumSquad: "",
tariffEditorTab: "general", tariffEditorTab: "general",
}); });
const tariffFromDraft = (draft) => tariffFromDraftFn(draft); const tariffFromDraft = (draft, defaultCurrency = "rub") =>
tariffFromDraftFn(draft, defaultCurrency);
async function loadTariffs() { async function loadTariffs() {
state.update((s) => ({ ...s, tariffsLoading: true })); state.update((s) => ({ ...s, tariffsLoading: true }));
@@ -41,6 +45,7 @@ export function createTariffsStore({ api, onTariffsSaved, flash, at }) {
...s, ...s,
tariffsCatalog: cloneCatalog(data.catalog), tariffsCatalog: cloneCatalog(data.catalog),
tariffsPath: data.path || "", tariffsPath: data.path || "",
providerCurrencySupport: data.provider_currency_support || [],
})); }));
} else { } else {
flash(data?.message || data?.error || at("load_failed", {}, "Не удалось загрузить тарифы")); flash(data?.message || data?.error || at("load_failed", {}, "Не удалось загрузить тарифы"));
@@ -119,6 +124,7 @@ export function createTariffsStore({ api, onTariffsSaved, flash, at }) {
...s, ...s,
tariffsCatalog: cloneCatalog(res.catalog), tariffsCatalog: cloneCatalog(res.catalog),
tariffsPath: res.path || currentPath, tariffsPath: res.path || currentPath,
providerCurrencySupport: res.provider_currency_support || s.providerCurrencySupport || [],
tariffEditorOpen: false, tariffEditorOpen: false,
tariffDeleteOpen: false, tariffDeleteOpen: false,
tariffDeleteTarget: null, tariffDeleteTarget: null,
@@ -139,7 +145,10 @@ export function createTariffsStore({ api, onTariffsSaved, flash, at }) {
state.update((s) => ({ state.update((s) => ({
...s, ...s,
tariffEditingKey: "", tariffEditingKey: "",
tariffDraft: emptyTariffDraft(), tariffDraft: {
...emptyTariffDraft(),
defaultCurrency: s.tariffsCatalog.default_currency || "rub",
},
tariffEditorTab: "general", tariffEditorTab: "general",
selectedBaseSquad: "", selectedBaseSquad: "",
selectedPremiumSquad: "", selectedPremiumSquad: "",
@@ -151,7 +160,7 @@ export function createTariffsStore({ api, onTariffsSaved, flash, at }) {
state.update((s) => ({ state.update((s) => ({
...s, ...s,
tariffEditingKey: tariff.key, tariffEditingKey: tariff.key,
tariffDraft: draftFromTariff(tariff), tariffDraft: draftFromTariff(tariff, s.tariffsCatalog.default_currency || "rub"),
tariffEditorTab: "general", tariffEditorTab: "general",
selectedBaseSquad: "", selectedBaseSquad: "",
selectedPremiumSquad: "", selectedPremiumSquad: "",
@@ -165,7 +174,7 @@ export function createTariffsStore({ api, onTariffsSaved, flash, at }) {
s = st; s = st;
return st; return st;
}); });
const tariff = tariffFromDraft(s.tariffDraft); const tariff = tariffFromDraft(s.tariffDraft, s.tariffsCatalog.default_currency || "rub");
if (!tariff.key) { if (!tariff.key) {
flash(at("tariff_error_key_required", {}, "Укажите ключ тарифа")); flash(at("tariff_error_key_required", {}, "Укажите ключ тарифа"));
return; 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() { async function deleteTariff() {
let s; let s;
state.update((st) => { state.update((st) => {
@@ -295,6 +322,7 @@ export function createTariffsStore({ api, onTariffsSaved, flash, at }) {
saveTariffDraft, saveTariffDraft,
toggleTariffEnabled, toggleTariffEnabled,
setDefaultTariff, setDefaultTariff,
setDefaultCurrency,
deleteTariff, deleteTariff,
addDraftRow, addDraftRow,
removeDraftRow, removeDraftRow,
+62 -16
View File
@@ -2,6 +2,7 @@ import { structuredCloneSafe } from "./format.js";
export function emptyTariffDraft() { export function emptyTariffDraft() {
return { return {
defaultCurrency: "rub",
key: "", key: "",
nameRu: "", nameRu: "",
nameEn: "", nameEn: "",
@@ -40,11 +41,22 @@ export function emptyTariffDraft() {
export function cloneCatalog(catalog) { export function cloneCatalog(catalog) {
return structuredCloneSafe({ return structuredCloneSafe({
default_tariff: catalog?.default_tariff || "", default_tariff: catalog?.default_tariff || "",
default_currency: normalizeCurrencyKey(catalog?.default_currency || "rub"),
topup_packages_default: catalog?.topup_packages_default || { rub: [], stars: [] }, topup_packages_default: catalog?.topup_packages_default || { rub: [], stars: [] },
tariffs: catalog?.tariffs || [], 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) { export function rowsFromPackages(packageSet, currency, valueKey) {
return (packageSet?.[currency] || []).map((pkg) => ({ return (packageSet?.[currency] || []).map((pkg) => ({
[valueKey]: pkg[valueKey], [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([ const months = new Set([
...(tariff.enabled_periods || []), ...(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), ...Object.keys(tariff.prices_stars || {}).map(Number),
]); ]);
const periodRows = [...months] const periodRows = [...months]
@@ -65,7 +80,10 @@ export function draftFromTariff(tariff) {
.sort((a, b) => a - b) .sort((a, b) => a - b)
.map((month) => ({ .map((month) => ({
months: 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)] ?? "", stars: tariff.prices_stars?.[String(month)] ?? "",
referral_inviter: tariff.referral_bonus_days_inviter?.[String(month)] ?? "", referral_inviter: tariff.referral_bonus_days_inviter?.[String(month)] ?? "",
referral_referee: tariff.referral_bonus_days_referee?.[String(month)] ?? "", referral_referee: tariff.referral_bonus_days_referee?.[String(month)] ?? "",
@@ -73,6 +91,7 @@ export function draftFromTariff(tariff) {
return { return {
...emptyTariffDraft(), ...emptyTariffDraft(),
defaultCurrency: currency,
key: tariff.key || "", key: tariff.key || "",
nameRu: tariff.names?.ru || "", nameRu: tariff.names?.ru || "",
nameEn: tariff.names?.en || "", nameEn: tariff.names?.en || "",
@@ -89,13 +108,13 @@ export function draftFromTariff(tariff) {
hwid_device_limit: tariff.hwid_device_limit ?? "", hwid_device_limit: tariff.hwid_device_limit ?? "",
conversion_rate_rub_per_gb: tariff.conversion_rate_rub_per_gb ?? "", conversion_rate_rub_per_gb: tariff.conversion_rate_rub_per_gb ?? "",
periodRows: periodRows.length ? periodRows : emptyTariffDraft().periodRows, 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"), 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"), 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"), 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"), 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); .filter((row) => row[valueKey] > 0 && row.price !== null && row.price >= 0);
} }
export function packageSetFromRows(rubRows, starsRows, valueKey) { export function packageSetFromRows(rubRows, starsRows, valueKey, defaultCurrency = "rub") {
const rub = packagesFromRows(rubRows, valueKey); const currency = normalizeCurrencyKey(defaultCurrency);
const defaultCurrencyPackages = packagesFromRows(rubRows, valueKey);
const stars = packagesFromRows(starsRows, 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) { export function normalizeUuidList(value) {
@@ -150,7 +174,8 @@ export function normalizeUuidList(value) {
.filter(Boolean); .filter(Boolean);
} }
export function tariffFromDraft(draft) { export function tariffFromDraft(draft, fallbackCurrency = "rub") {
const defaultCurrency = normalizeCurrencyKey(draft.defaultCurrency || fallbackCurrency);
const key = draft.key.trim(); const key = draft.key.trim();
const names = compactMap({ ru: draft.nameRu.trim(), en: draft.nameEn.trim() }); const names = compactMap({ ru: draft.nameRu.trim(), en: draft.nameEn.trim() });
const descriptions = compactMap({ const descriptions = compactMap({
@@ -174,14 +199,20 @@ export function tariffFromDraft(draft) {
const hwidLimit = parseIntNumber(draft.hwid_device_limit); const hwidLimit = parseIntNumber(draft.hwid_device_limit);
if (hwidLimit !== null) tariff.hwid_device_limit = hwidLimit; 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; if (hwidPackages) tariff.hwid_device_packages = hwidPackages;
const premiumMonthlyGb = parseNumber(draft.premium_monthly_gb); const premiumMonthlyGb = parseNumber(draft.premium_monthly_gb);
if (premiumMonthlyGb !== null) tariff.premium_monthly_gb = premiumMonthlyGb; if (premiumMonthlyGb !== null) tariff.premium_monthly_gb = premiumMonthlyGb;
const premiumTopupPackages = packageSetFromRows( const premiumTopupPackages = packageSetFromRows(
draft.premiumTopupRubRows, draft.premiumTopupRubRows,
draft.premiumTopupStarsRows, draft.premiumTopupStarsRows,
"gb" "gb",
defaultCurrency
); );
if (premiumTopupPackages) tariff.premium_topup_packages = premiumTopupPackages; if (premiumTopupPackages) tariff.premium_topup_packages = premiumTopupPackages;
@@ -204,7 +235,12 @@ export function tariffFromDraft(draft) {
.sort((a, b) => a.months - b.months); .sort((a, b) => a.months - b.months);
tariff.monthly_gb = parseNumber(draft.monthly_gb, 0); tariff.monthly_gb = parseNumber(draft.monthly_gb, 0);
tariff.enabled_periods = rows.map((row) => row.months); 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( tariff.prices_stars = Object.fromEntries(
rows.map((row) => [String(row.months), row.stars || 0]) rows.map((row) => [String(row.months), row.stars || 0])
); );
@@ -218,10 +254,20 @@ export function tariffFromDraft(draft) {
.filter((row) => row.referral_referee !== null) .filter((row) => row.referral_referee !== null)
.map((row) => [String(row.months), row.referral_referee]) .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; if (topupPackages) tariff.topup_packages = topupPackages;
} else { } else {
const trafficPackages = packageSetFromRows(draft.trafficRubRows, draft.trafficStarsRows, "gb"); const trafficPackages = packageSetFromRows(
draft.trafficRubRows,
draft.trafficStarsRows,
"gb",
defaultCurrency
);
if (trafficPackages) tariff.traffic_packages = trafficPackages; if (trafficPackages) tariff.traffic_packages = trafficPackages;
const conversion = parseNumber(draft.conversion_rate_rub_per_gb); const conversion = parseNumber(draft.conversion_rate_rub_per_gb);
if (conversion !== null) tariff.conversion_rate_rub_per_gb = conversion; if (conversion !== null) tariff.conversion_rate_rub_per_gb = conversion;
+64
View File
@@ -3222,6 +3222,70 @@
flex-wrap: wrap; 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 { .admin-tariff-settings-card {
margin-top: 14px; margin-top: 14px;
} }
+60
View File
@@ -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" 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): def test_admin_only_provider_is_visible_only_to_admins(monkeypatch):
from bot.app.web.webapp.serializers import _serialize_payment_methods 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" 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(): def test_admin_only_provider_toggle_pairs_are_declared():
pairs = set(provider_admin_only_pairs()) pairs = set(provider_admin_only_pairs())
+22 -1
View File
@@ -1,7 +1,7 @@
import json import json
import unittest 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(): def _valid_config():
@@ -128,6 +128,27 @@ class TariffsConfigTests(unittest.TestCase):
with self.assertRaises(ValueError): with self.assertRaises(ValueError):
TariffsConfig.model_validate(data) 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): def test_hwid_device_limit_and_packages_load(self):
data = _valid_config() data = _valid_config()
data["tariffs"][0]["hwid_device_limit"] = 5 data["tariffs"][0]["hwid_device_limit"] = 5