feat: add multicurrency tariff payments
This commit is contained in:
@@ -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,
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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(
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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(
|
||||||
[
|
[
|
||||||
|
|||||||
@@ -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":
|
||||||
min_price = tariff.min_period_price_rub()
|
if hasattr(tariff, "min_period_price"):
|
||||||
|
min_price = tariff.min_period_price(default_currency)
|
||||||
|
elif default_currency == "rub" and hasattr(tariff, "min_period_price_rub"):
|
||||||
|
min_price = tariff.min_period_price_rub()
|
||||||
|
else:
|
||||||
|
min_price = None
|
||||||
if min_price is not None:
|
if min_price is not None:
|
||||||
label = f"{label} от {min_price:g}"
|
label = f"{label} от {min_price:g}"
|
||||||
else:
|
else:
|
||||||
package = tariff.min_traffic_package_rub()
|
if hasattr(tariff, "min_traffic_package"):
|
||||||
|
package = tariff.min_traffic_package(default_currency)
|
||||||
|
elif default_currency == "rub" and hasattr(tariff, "min_traffic_package_rub"):
|
||||||
|
package = tariff.min_traffic_package_rub()
|
||||||
|
else:
|
||||||
|
package = None
|
||||||
if package:
|
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,
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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/",
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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/",
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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",
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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",
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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.",
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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",
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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,7 +41,21 @@ class RenewalMixin:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
months = sub.duration_months or 1
|
months = sub.duration_months or 1
|
||||||
amount = self.settings.subscription_options.get(months)
|
currency = default_payment_currency_code_for_settings(self.settings)
|
||||||
|
amount = None
|
||||||
|
tariffs_config = self._tariffs_config() if callable(getattr(self, "_tariffs_config", None)) else None
|
||||||
|
if tariffs_config and callable(getattr(self, "_resolve_tariff", None)):
|
||||||
|
try:
|
||||||
|
tariff = self._resolve_tariff(getattr(sub, "tariff_key", None))
|
||||||
|
except Exception:
|
||||||
|
tariff = None
|
||||||
|
if tariff and tariff.billing_model == "period":
|
||||||
|
amount = tariff.period_price(
|
||||||
|
months,
|
||||||
|
default_currency_key_for_settings(self.settings),
|
||||||
|
)
|
||||||
|
if amount is None:
|
||||||
|
amount = self.settings.subscription_options.get(months)
|
||||||
if not amount:
|
if not amount:
|
||||||
logging.error(f"Auto-renew price missing for {months} months")
|
logging.error(f"Auto-renew price missing for {months} months")
|
||||||
return False
|
return False
|
||||||
@@ -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:
|
||||||
|
|||||||
@@ -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,5 +1,6 @@
|
|||||||
{
|
{
|
||||||
"default_tariff": "standard",
|
"default_tariff": "standard",
|
||||||
|
"default_currency": "rub",
|
||||||
"tariffs": [
|
"tariffs": [
|
||||||
{
|
{
|
||||||
"key": "standard",
|
"key": "standard",
|
||||||
|
|||||||
+41
-12
@@ -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(
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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())
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user