Merge pull request #18 from 3252a8/feature/multicurrency

Add ability to use different currency than rub
This commit is contained in:
3252a8
2026-06-01 11:26:18 +03:00
committed by GitHub
37 changed files with 1323 additions and 241 deletions
@@ -37,7 +37,7 @@ from bot.services.settings_override_service import (
from bot.utils import MessageContent, send_message_via_queue
from bot.utils.message_queue import get_queue_manager
from config.settings import Settings
from config.tariffs_config import TariffsConfig
from config.tariffs_config import TariffsConfig, default_payment_currency_code_for_settings
from db.dal import (
ad_dal,
app_settings_dal,
+1 -1
View File
@@ -34,7 +34,7 @@ async def admin_stats_route(request: web.Request) -> web.Response:
except Exception: # pragma: no cover - defensive
payload["queue"] = None
payload["currency_symbol"] = settings.DEFAULT_CURRENCY_SYMBOL or "RUB"
payload["currency_symbol"] = default_payment_currency_code_for_settings(settings)
return _ok(payload)
+54 -1
View File
@@ -21,9 +21,14 @@ async def admin_tariffs_get_route(request: web.Request) -> web.Response:
"path": str(path),
"catalog": {
"default_tariff": "",
"default_currency": "rub",
"topup_packages_default": {"rub": [], "stars": []},
"tariffs": [],
},
"provider_currency_support": _provider_currency_support_payload(
settings,
request.app,
),
}
)
@@ -32,6 +37,7 @@ async def admin_tariffs_get_route(request: web.Request) -> web.Response:
"exists": True,
"path": str(path),
"catalog": _tariffs_config_payload(config),
"provider_currency_support": _provider_currency_support_payload(settings, request.app),
}
)
@@ -58,4 +64,51 @@ async def admin_tariffs_save_route(request: web.Request) -> web.Response:
await refresh_webapp_runtime_after_settings_change(request, updates={}, deletes=[])
return _ok({"exists": True, "path": str(path), "catalog": _tariffs_config_payload(config)})
return _ok(
{
"exists": True,
"path": str(path),
"catalog": _tariffs_config_payload(config),
"provider_currency_support": _provider_currency_support_payload(settings, request.app),
}
)
def _provider_currency_support_payload(
settings: Settings,
app: web.Application,
) -> List[Dict[str, Any]]:
from bot.payment_providers import iter_provider_specs, resolve_provider_presentation
default_currency = default_payment_currency_code_for_settings(settings)
providers: List[Dict[str, Any]] = []
for spec in iter_provider_specs():
presentation = resolve_provider_presentation(spec, settings)
supported = spec.supported_currency_codes(settings)
providers.append(
{
"id": spec.id,
"provider_key": spec.provider_key,
"label": presentation.webapp_label or spec.label,
"telegram_label": presentation.telegram_label,
"icon": presentation.webapp_icon,
"enabled": spec.is_effectively_enabled(settings),
"configured": spec.is_service_configured(app),
"admin_only": spec.is_admin_only_enabled(settings),
"price_source": spec.price_source,
"currencies": list(supported) if supported is not None else None,
"accepts_any_currency": supported is None,
"supports_default_currency": spec.is_usable_for_payment_currency(
settings,
default_currency,
),
"directly_supports_default_currency": spec.supports_currency(
settings,
default_currency,
),
"default_currency": default_currency,
"note": spec.currency_support_note,
"docs_url": spec.currency_support_url,
}
)
return providers
+5
View File
@@ -55,6 +55,11 @@ from bot.utils.text_sanitizer import (
sanitize_username,
)
from config.settings import Settings
from config.tariffs_config import (
default_currency_key_for_settings,
default_payment_currency_code_for_settings,
payment_currency_code,
)
from db.dal import payment_dal, security_dal, subscription_dal, support_dal, user_dal
from db.dal.user_dal import UserMergeConflictError
from db.models import Payment, User, UserTelegramAvatar
+56 -21
View File
@@ -103,6 +103,8 @@ async def create_payment_route(request: web.Request) -> web.Response:
subscription_service: SubscriptionService = request.app["subscription_service"]
cached = _get_cached_webapp_settings(request)
tariffs_config = settings.tariffs_config
default_currency = default_currency_key_for_settings(settings)
default_currency_code = payment_currency_code(default_currency)
traffic_mode = bool(settings.traffic_sale_mode)
sale_mode = "subscription"
traffic_gb_for_payment: Optional[float] = None
@@ -155,17 +157,17 @@ async def create_payment_route(request: web.Request) -> web.Response:
if requested_sale_mode == "premium_topup"
else tariffs_config.topup_packages_for(tariff)
)
rub_packages = {
currency_packages = {
float(package.gb): float(package.price)
for package in (packages.rub if packages else [])
for package in (packages.for_currency(default_currency) if packages else [])
}
stars_packages = {
float(package.gb): int(float(package.price))
for package in (packages.stars if packages else [])
}
package_key = _resolve_numeric_option_key(rub_packages, traffic_gb)
package_key = _resolve_numeric_option_key(currency_packages, traffic_gb)
stars_package_key = _resolve_numeric_option_key(stars_packages, traffic_gb)
price = rub_packages.get(package_key) if package_key is not None else None
price = currency_packages.get(package_key) if package_key is not None else None
stars_price = (
stars_packages.get(stars_package_key) if stars_package_key is not None else None
)
@@ -196,17 +198,21 @@ async def create_payment_route(request: web.Request) -> web.Response:
return _json_error(400, "invalid_plan", "Invalid traffic package")
if traffic_gb <= 0:
return _json_error(400, "invalid_plan", "Invalid traffic package")
rub_packages = {
currency_packages = {
float(package.gb): float(package.price)
for package in (tariff.traffic_packages.rub if tariff.traffic_packages else [])
for package in (
tariff.traffic_packages.for_currency(default_currency)
if tariff.traffic_packages
else []
)
}
stars_packages = {
float(package.gb): int(float(package.price))
for package in (tariff.traffic_packages.stars if tariff.traffic_packages else [])
}
package_key = _resolve_numeric_option_key(rub_packages, traffic_gb)
package_key = _resolve_numeric_option_key(currency_packages, traffic_gb)
stars_package_key = _resolve_numeric_option_key(stars_packages, traffic_gb)
price = rub_packages.get(package_key) if package_key is not None else None
price = currency_packages.get(package_key) if package_key is not None else None
stars_price = (
stars_packages.get(stars_package_key) if stars_package_key is not None else None
)
@@ -224,7 +230,7 @@ async def create_payment_route(request: web.Request) -> web.Response:
return _json_error(400, "invalid_plan", "Invalid subscription period")
if months not in tariff.enabled_periods:
return _json_error(400, "invalid_plan", "Subscription period is not available")
price = tariff.period_price(months, "rub")
price = tariff.period_price(months, default_currency)
stars_price_raw = tariff.period_price(months, "stars")
stars_price = int(stars_price_raw) if stars_price_raw and stars_price_raw > 0 else None
if price is None and method != "stars":
@@ -296,7 +302,7 @@ async def create_payment_route(request: web.Request) -> web.Response:
active_tariff = None
if not active_tariff or active_tariff.billing_model != "period":
return _json_error(400, "invalid_plan", "Device top-up is not available")
currency = "stars" if method == "stars" else "rub"
currency = "stars" if method == "stars" else default_currency
hwid_quote = await subscription_service.quote_hwid_device_topup(
session,
user_id=user_id,
@@ -325,6 +331,7 @@ async def create_payment_route(request: web.Request) -> web.Response:
months=payment_units,
price=float(price or 0),
stars_price=stars_price,
currency=default_currency_code,
lang=lang,
sale_mode=sale_mode,
traffic_gb=traffic_gb_for_payment,
@@ -583,6 +590,7 @@ async def tariff_change_payment_route(request: web.Request) -> web.Response:
tariff_key = str(payment_payload.tariff_key or "").strip()
settings: Settings = request.app["settings"]
config = settings.tariffs_config
default_currency_code = default_payment_currency_code_for_settings(settings)
if not config:
return _json_error(404, "tariffs_unavailable", "Tariffs are not configured")
if not tariff_key:
@@ -618,6 +626,7 @@ async def tariff_change_payment_route(request: web.Request) -> web.Response:
months=1,
price=price,
stars_price=None,
currency=default_currency_code,
lang=db_user.language_code or settings.DEFAULT_LANGUAGE,
sale_mode=f"tariff_upgrade@{target.key}",
)
@@ -656,20 +665,26 @@ async def device_topup_options_route(request: web.Request) -> web.Response:
active.get("extra_hwid_devices_valid_until_text") if active else None
) or _billing_datetime_text(extra_hwid_valid_until)
packages = tariff.hwid_device_packages
rub_counts = {int(package.count) for package in (packages.rub if packages else [])}
default_currency = default_currency_key_for_settings(settings)
default_currency_code = payment_currency_code(default_currency)
if packages and hasattr(packages, "for_currency"):
default_packages = packages.for_currency(default_currency)
else:
default_packages = getattr(packages, default_currency, []) if packages else []
currency_counts = {int(package.count) for package in default_packages}
stars_counts = {int(package.count) for package in (packages.stars if packages else [])}
plans = []
for count in sorted(rub_counts | stars_counts):
rub_quote = (
for count in sorted(currency_counts | stars_counts):
currency_quote = (
await subscription_service.quote_hwid_device_topup(
session,
user_id=user_id,
device_count=count,
tariff_key=tariff.key,
renewal=renewal_available,
currency="rub",
currency=default_currency,
)
if count in rub_counts
if count in currency_counts
else None
)
stars_quote = (
@@ -684,7 +699,7 @@ async def device_topup_options_route(request: web.Request) -> web.Response:
if count in stars_counts
else None
)
if not rub_quote and not stars_quote:
if not currency_quote and not stars_quote:
continue
sale_mode_for_plan = "hwid_devices_renewal" if renewal_available else "hwid_devices"
plan = {
@@ -695,13 +710,19 @@ async def device_topup_options_route(request: web.Request) -> web.Response:
"sale_mode": sale_mode_for_plan,
"months": count,
"device_count": count,
"price": float(rub_quote.get("price") if rub_quote else 0),
"currency": settings.DEFAULT_CURRENCY_SYMBOL or "RUB",
"price": float(currency_quote.get("price") if currency_quote else 0),
"currency": default_currency_code,
"title": f"+{count}",
"subtitle": tariff.name(lang),
"valid_from": _billing_iso_datetime((rub_quote or stars_quote).get("valid_from")),
"valid_until": _billing_iso_datetime((rub_quote or stars_quote).get("valid_until")),
"proration_ratio": float((rub_quote or stars_quote).get("proration_ratio") or 0),
"valid_from": _billing_iso_datetime(
(currency_quote or stars_quote).get("valid_from")
),
"valid_until": _billing_iso_datetime(
(currency_quote or stars_quote).get("valid_until")
),
"proration_ratio": float(
(currency_quote or stars_quote).get("proration_ratio") or 0
),
}
if stars_quote and int(stars_quote.get("price") or 0) > 0:
plan["stars_price"] = int(stars_quote["price"])
@@ -929,12 +950,14 @@ async def _create_subscription_payment(
price: float,
stars_price: Optional[int],
lang: str,
currency: Optional[str] = None,
sale_mode: str = "subscription",
traffic_gb: Optional[float] = None,
is_admin: bool = False,
hwid_quote: Optional[Dict[str, Any]] = None,
) -> web.Response:
settings: Settings = request.app["settings"]
payment_currency = (currency or default_payment_currency_code_for_settings(settings)).upper()
sale_mode = str(sale_mode or "subscription")
traffic_sale = _sale_mode_is_traffic(sale_mode)
hwid_devices_sale = _sale_mode_is_hwid_devices(sale_mode)
@@ -958,6 +981,17 @@ async def _create_subscription_payment(
provider_spec.is_service_configured(request.app),
)
return _json_error(400, "payment_unavailable", "Payment method unavailable")
if not provider_spec.is_usable_for_payment_currency(settings, payment_currency):
logger.warning(
"WebApp payment method does not support currency: method=%s currency=%s",
method,
payment_currency,
)
return _json_error(
400,
"unsupported_currency",
"Payment method does not support this currency",
)
return await provider_spec.create_webapp_payment(
WebAppPaymentContext(
request=request,
@@ -967,6 +1001,7 @@ async def _create_subscription_payment(
months=months,
price=price,
stars_price=stars_price,
currency=payment_currency,
description=description,
sale_mode=sale_mode,
traffic_gb=traffic_gb,
+45 -22
View File
@@ -472,6 +472,8 @@ def _serialize_plans(
) -> List[Dict[str, Any]]:
tariffs_config = settings.tariffs_config
if tariffs_config:
default_currency = default_currency_key_for_settings(settings)
default_currency_code = payment_currency_code(default_currency)
plans: List[Dict[str, Any]] = []
for tariff in tariffs_config.enabled_tariffs:
common = {
@@ -481,7 +483,7 @@ def _serialize_plans(
"billing_model": tariff.billing_model,
"description": tariff.description(lang),
"squad_uuids": tariff.squad_uuids,
"currency": settings.DEFAULT_CURRENCY_SYMBOL or "RUB",
"currency": default_currency_code,
"hwid_device_limit": tariff.hwid_device_limit,
"hwid_device_packages": _serialize_hwid_device_packages(
settings,
@@ -494,7 +496,7 @@ def _serialize_plans(
}
if tariff.billing_model == "period":
for months in sorted(tariff.enabled_periods):
price = tariff.period_price(int(months), "rub")
price = tariff.period_price(int(months), default_currency)
stars_price = tariff.period_price(int(months), "stars")
if price is None and (stars_price is None or int(stars_price) <= 0):
continue
@@ -512,9 +514,13 @@ def _serialize_plans(
plan["stars_price"] = int(stars_price)
plans.append(plan)
else:
rub_packages = {
currency_packages = {
float(package.gb): float(package.price)
for package in (tariff.traffic_packages.rub if tariff.traffic_packages else [])
for package in (
tariff.traffic_packages.for_currency(default_currency)
if tariff.traffic_packages
else []
)
}
stars_packages = {
float(package.gb): int(float(package.price))
@@ -522,8 +528,8 @@ def _serialize_plans(
tariff.traffic_packages.stars if tariff.traffic_packages else []
)
}
for traffic_gb in sorted(set(rub_packages) | set(stars_packages)):
price = rub_packages.get(traffic_gb)
for traffic_gb in sorted(set(currency_packages) | set(stars_packages)):
price = currency_packages.get(traffic_gb)
stars_price = stars_packages.get(traffic_gb)
if price is None and (stars_price is None or int(stars_price) <= 0):
continue
@@ -609,16 +615,19 @@ def _serialize_topup_packages(
sale_mode: str = "topup",
title_prefix: str = "",
) -> List[Dict[str, Any]]:
rub_packages = {
float(package.gb): float(package.price) for package in (packages.rub if packages else [])
default_currency = default_currency_key_for_settings(settings)
default_currency_code = payment_currency_code(default_currency)
currency_packages = {
float(package.gb): float(package.price)
for package in (packages.for_currency(default_currency) if packages else [])
}
stars_packages = {
float(package.gb): int(float(package.price))
for package in (packages.stars if packages else [])
}
plans: List[Dict[str, Any]] = []
for traffic_gb in sorted(set(rub_packages) | set(stars_packages)):
price = rub_packages.get(traffic_gb)
for traffic_gb in sorted(set(currency_packages) | set(stars_packages)):
price = currency_packages.get(traffic_gb)
stars_price = stars_packages.get(traffic_gb)
if price is None and (stars_price is None or int(stars_price) <= 0):
continue
@@ -632,7 +641,7 @@ def _serialize_topup_packages(
"months": int(traffic_value) if traffic_value.is_integer() else traffic_value,
"traffic_gb": traffic_value,
"price": float(price or 0),
"currency": settings.DEFAULT_CURRENCY_SYMBOL or "RUB",
"currency": default_currency_code,
"title": f"{title_prefix}{_format_traffic_title(traffic_value, lang)}",
"subtitle": tariff.premium_name(lang)
if sale_mode == "premium_topup"
@@ -650,16 +659,19 @@ def _serialize_hwid_device_packages(
packages: Optional[Any],
lang: str,
) -> List[Dict[str, Any]]:
rub_packages = {
int(package.count): float(package.price) for package in (packages.rub if packages else [])
default_currency = default_currency_key_for_settings(settings)
default_currency_code = payment_currency_code(default_currency)
currency_packages = {
int(package.count): float(package.price)
for package in (packages.for_currency(default_currency) if packages else [])
}
stars_packages = {
int(package.count): int(float(package.price))
for package in (packages.stars if packages else [])
}
plans: List[Dict[str, Any]] = []
for count in sorted(set(rub_packages) | set(stars_packages)):
price = rub_packages.get(count)
for count in sorted(set(currency_packages) | set(stars_packages)):
price = currency_packages.get(count)
stars_price = stars_packages.get(count)
if price is None and (stars_price is None or int(stars_price) <= 0):
continue
@@ -672,7 +684,7 @@ def _serialize_hwid_device_packages(
"months": int(count),
"device_count": int(count),
"price": float(price or 0),
"currency": settings.DEFAULT_CURRENCY_SYMBOL or "RUB",
"currency": default_currency_code,
"title": f"+{count}",
"subtitle": tariff.name(lang),
}
@@ -689,6 +701,8 @@ def _serialize_tariff_change_target(
options: Dict[str, Any],
lang: str,
) -> Dict[str, Any]:
default_currency = default_currency_key_for_settings(settings)
default_currency_code = payment_currency_code(default_currency)
actions: List[Dict[str, Any]] = []
mode = str(options.get("mode") or "")
if mode == "period_to_period":
@@ -711,7 +725,7 @@ def _serialize_tariff_change_target(
"kind": "payment",
"title": "paid_diff",
"price": paid_diff,
"currency": settings.DEFAULT_CURRENCY_SYMBOL or "RUB",
"currency": default_currency_code,
}
)
elif mode == "period_to_traffic":
@@ -733,13 +747,17 @@ def _serialize_tariff_change_target(
"title": f"+{package.gb:g} GB",
"traffic_gb": float(package.gb),
"price": float(package.price),
"currency": settings.DEFAULT_CURRENCY_SYMBOL or "RUB",
"currency": default_currency_code,
}
for package in (tariff.traffic_packages.rub if tariff.traffic_packages else [])
for package in (
tariff.traffic_packages.for_currency(default_currency)
if tariff.traffic_packages
else []
)
)
else:
for months in tariff.enabled_periods:
price = tariff.period_price(int(months), "rub")
price = tariff.period_price(int(months), default_currency)
if price:
actions.append(
{
@@ -748,7 +766,7 @@ def _serialize_tariff_change_target(
"months": int(months),
"title": _format_months_title(int(months), lang),
"price": float(price),
"currency": settings.DEFAULT_CURRENCY_SYMBOL or "RUB",
"currency": default_currency_code,
}
)
return {
@@ -772,10 +790,15 @@ def _serialize_payment_methods(
from bot.payment_providers import get_provider_spec, resolve_provider_presentation
methods: List[Dict[str, Any]] = []
payment_currency = default_payment_currency_code_for_settings(settings)
for method in settings.payment_methods_order:
method = method.lower()
spec = get_provider_spec(method)
if spec and spec.is_visible_for_user(settings, app, is_admin=is_admin):
if (
spec
and spec.is_visible_for_user(settings, app, is_admin=is_admin)
and spec.is_usable_for_payment_currency(settings, payment_currency)
):
presentation = resolve_provider_presentation(spec, settings, language=lang)
methods.append(
{
+6 -4
View File
@@ -13,6 +13,7 @@ from bot.middlewares.i18n import JsonI18n
from bot.payment_providers import pending_statuses
from bot.services.panel_api_service import PanelApiService
from config.settings import Settings
from config.tariffs_config import default_payment_currency_code_for_settings
from db.dal import panel_sync_dal, payment_dal, user_dal
from db.models import PanelSyncStatus, Payment
@@ -195,19 +196,20 @@ async def show_statistics_handler(
# Financial statistics
financial_stats = await payment_dal.get_financial_statistics(session)
currency = default_payment_currency_code_for_settings(settings)
stats_text_parts.append(f"\n<b>💰 {_('admin_financial_stats_header')}</b>")
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(
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(
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(
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(
@@ -27,6 +27,7 @@ from bot.utils.text_sanitizer import (
username_for_display,
)
from config.settings import Settings
from config.tariffs_config import default_payment_currency_code_for_settings
from db.dal import message_log_dal, subscription_dal, user_dal
from db.models import User
@@ -442,17 +443,18 @@ async def format_user_card(
try:
from db.dal import payment_dal
currency = default_payment_currency_code_for_settings(settings)
# Total amount paid by this user
total_paid = await payment_dal.get_user_total_paid(session, user.user_id)
card_parts.append(
f"{_('admin_user_total_paid_label')} {hcode(f'{total_paid:.2f} RUB')}"
f"{_('admin_user_total_paid_label')} {hcode(f'{total_paid:.2f} {currency}')}"
)
# Total revenue from referrals
referral_revenue = await payment_dal.get_referral_revenue(session, user.user_id)
card_parts.append(
f"{_('admin_user_referral_revenue_label')} {hcode(f'{referral_revenue:.2f} RUB')}"
)
referral_revenue_text = hcode(f"{referral_revenue:.2f} {currency}")
card_parts.append(f"{_('admin_user_referral_revenue_label')} {referral_revenue_text}")
except Exception as e_fin:
logging.error(
f"Failed to build financial analytics for admin card {user.user_id}: {e_fin}"
+77 -30
View File
@@ -32,6 +32,10 @@ from bot.utils.install_links import (
ensure_user_install_guide_links,
)
from config.settings import Settings
from config.tariffs_config import (
default_currency_key_for_settings,
default_payment_currency_code_for_settings,
)
from db.dal import subscription_dal, user_billing_dal
from db.models import Subscription
@@ -80,11 +84,13 @@ def _tariff_purchase_markup(
back_callback=back_callback,
callback_context=callback_context,
)
default_currency = default_currency_key_for_settings(settings)
return get_tariff_packages_keyboard(
tariff,
tariff.traffic_packages.rub,
tariff.traffic_packages.for_currency(default_currency),
current_lang,
i18n,
currency_symbol=default_payment_currency_code_for_settings(settings),
back_callback=back_callback,
callback_context=callback_context,
)
@@ -185,6 +191,7 @@ async def display_subscription_options(
enabled_tariffs,
current_lang,
i18n,
settings=settings,
back_callback=back_callback,
callback_context=callback_context,
)
@@ -329,7 +336,9 @@ async def select_tariff_period_callback(
callback_context = parts[4] if len(parts) > 4 else None
tariff = config.require(tariff_key)
months = int(months_raw)
price_rub = tariff.period_price(months, "rub")
default_currency = default_currency_key_for_settings(settings)
currency_code = default_payment_currency_code_for_settings(settings)
price_rub = tariff.period_price(months, default_currency)
stars_price = tariff.period_price(months, "stars")
if price_rub is None:
await callback.answer(get_text("error_try_again"), show_alert=True)
@@ -338,7 +347,7 @@ async def select_tariff_period_callback(
months,
price_rub,
int(stars_price) if stars_price else None,
settings.DEFAULT_CURRENCY_SYMBOL,
currency_code,
current_lang,
i18n,
settings,
@@ -369,10 +378,16 @@ async def select_tariff_package_callback(
callback_context = parts[4] if len(parts) > 4 else None
tariff = config.require(tariff_key)
gb = float(gb_raw)
default_currency = default_currency_key_for_settings(settings)
currency_code = default_payment_currency_code_for_settings(settings)
packages = (
tariff.traffic_packages.rub
tariff.traffic_packages.for_currency(default_currency)
if tariff.billing_model == "traffic"
else (config.topup_packages_for(tariff).rub if config.topup_packages_for(tariff) else [])
else (
config.topup_packages_for(tariff).for_currency(default_currency)
if config.topup_packages_for(tariff)
else []
)
)
package = next((pkg for pkg in packages if float(pkg.gb) == gb), None)
if not package:
@@ -391,7 +406,7 @@ async def select_tariff_package_callback(
gb,
package.price,
None,
settings.DEFAULT_CURRENCY_SYMBOL,
currency_code,
current_lang,
i18n,
settings,
@@ -423,14 +438,19 @@ async def tariff_topup_list_callback(
return
tariff = config.require(active["tariff_key"])
packages = config.topup_packages_for(tariff)
rub_packages = packages.rub if packages else []
premium_packages = tariff.premium_topup_packages.rub if tariff.premium_topup_packages else []
if not rub_packages and not premium_packages:
default_currency = default_currency_key_for_settings(settings)
currency = default_payment_currency_code_for_settings(settings)
currency_packages = packages.for_currency(default_currency) if packages else []
premium_packages = (
tariff.premium_topup_packages.for_currency(default_currency)
if tariff.premium_topup_packages
else []
)
if not currency_packages and not premium_packages:
await callback.answer(get_text("no_subscription_options_available"), show_alert=True)
return
builder = InlineKeyboardBuilder()
currency = settings.DEFAULT_CURRENCY_SYMBOL
for package in rub_packages:
for package in currency_packages:
builder.row(
InlineKeyboardButton(
text=f"Обычный трафик +{package.gb:g} GB — {package.price:g} {currency}",
@@ -452,7 +472,7 @@ async def tariff_topup_list_callback(
premium_lines = []
carryover_lines = []
if rub_packages or premium_packages:
if currency_packages or premium_packages:
carryover_lines.append(
"Докупленный трафик не сгорает: сначала расходуется месячный лимит, затем докупленный остаток." # noqa: E501
)
@@ -495,7 +515,13 @@ async def select_tariff_premium_package_callback(
_, _, tariff_key, gb_raw = callback.data.split(":", 3)
tariff = config.require(tariff_key)
gb = float(gb_raw)
packages = tariff.premium_topup_packages.rub if tariff.premium_topup_packages else []
default_currency = default_currency_key_for_settings(settings)
currency_code = default_payment_currency_code_for_settings(settings)
packages = (
tariff.premium_topup_packages.for_currency(default_currency)
if tariff.premium_topup_packages
else []
)
package = next((pkg for pkg in packages if float(pkg.gb) == gb), None)
if not package:
await callback.answer(get_text("error_try_again"), show_alert=True)
@@ -504,7 +530,7 @@ async def select_tariff_premium_package_callback(
gb,
package.price,
None,
settings.DEFAULT_CURRENCY_SYMBOL,
currency_code,
current_lang,
i18n,
settings,
@@ -542,7 +568,12 @@ async def hwid_devices_list_callback(
if tariff.billing_model != "period":
await callback.answer(get_text("no_hwid_device_packages_available"), show_alert=True)
return
packages = tariff.hwid_device_packages.rub if tariff.hwid_device_packages else []
default_currency = default_currency_key_for_settings(settings)
packages = (
tariff.hwid_device_packages.for_currency(default_currency)
if tariff.hwid_device_packages
else []
)
if not packages:
await callback.answer(get_text("no_hwid_device_packages_available"), show_alert=True)
return
@@ -594,7 +625,13 @@ async def hwid_devices_package_callback(
package = next(
(
pkg
for pkg in (tariff.hwid_device_packages.rub if tariff.hwid_device_packages else [])
for pkg in (
tariff.hwid_device_packages.for_currency(
default_currency_key_for_settings(settings)
)
if tariff.hwid_device_packages
else []
)
if int(pkg.count) == count
),
None,
@@ -603,13 +640,15 @@ async def hwid_devices_package_callback(
await callback.answer(get_text("error_try_again"), show_alert=True)
return
sale_mode_base = "hwid_devices_renewal" if action == "renewal_package" else "hwid_devices"
rub_quote = await subscription_service.quote_hwid_device_topup(
default_currency = default_currency_key_for_settings(settings)
currency_code = default_payment_currency_code_for_settings(settings)
currency_quote = await subscription_service.quote_hwid_device_topup(
session,
user_id=callback.from_user.id,
device_count=count,
tariff_key=tariff.key,
renewal=action == "renewal_package",
currency="rub",
currency=default_currency,
)
stars_quote = await subscription_service.quote_hwid_device_topup(
session,
@@ -619,16 +658,16 @@ async def hwid_devices_package_callback(
renewal=action == "renewal_package",
currency="stars",
)
if not rub_quote and not stars_quote:
if not currency_quote and not stars_quote:
await callback.answer(get_text("error_try_again"), show_alert=True)
return
markup = get_payment_method_keyboard(
count,
float(rub_quote.get("price") if rub_quote else 0),
float(currency_quote.get("price") if currency_quote else 0),
int(stars_quote["price"])
if stars_quote and int(stars_quote.get("price") or 0) > 0
else None,
settings.DEFAULT_CURRENCY_SYMBOL,
currency_code,
current_lang,
i18n,
settings,
@@ -715,6 +754,8 @@ async def tariff_change_select_callback(
options = await subscription_service.calculate_tariff_switch_options_with_hwid(
session, db_sub, target
)
default_currency = default_currency_key_for_settings(settings)
currency_code = default_payment_currency_code_for_settings(settings)
rows = []
if options["mode"] == "period_to_period":
rows.append(
@@ -729,7 +770,7 @@ async def tariff_change_select_callback(
rows.append(
[
InlineKeyboardButton(
text=f"Доплатить {options['paid_diff_rub']} RUB",
text=f"Доплатить {options['paid_diff_rub']} {currency_code}",
callback_data=f"tariff_change:confirm_pay:{target.key}:{options['paid_diff_rub']}",
)
]
@@ -743,23 +784,23 @@ async def tariff_change_select_callback(
)
]
)
for package in target.traffic_packages.rub:
for package in target.traffic_packages.for_currency(default_currency):
rows.append(
[
InlineKeyboardButton(
text=f"+ {package.gb:g} GB за {package.price:g} RUB",
text=f"+ {package.gb:g} GB за {package.price:g} {currency_code}",
callback_data=f"tariff:package:{target.key}:{package.gb:g}",
)
]
)
else:
for months in target.enabled_periods:
price = target.period_price(months, "rub")
price = target.period_price(months, default_currency)
if price:
rows.append(
[
InlineKeyboardButton(
text=f"{months} мес. за {price:g} RUB",
text=f"{months} мес. за {price:g} {currency_code}",
callback_data=f"tariff:period:{target.key}:{months}",
)
]
@@ -842,6 +883,7 @@ async def tariff_change_confirm_pay_callback(
return
_, _, tariff_key, amount_raw = callback.data.split(":", 3)
target = config.require(tariff_key)
currency_code = default_payment_currency_code_for_settings(settings)
rows = [
[
InlineKeyboardButton(
@@ -857,7 +899,7 @@ async def tariff_change_confirm_pay_callback(
],
]
await callback.message.edit_text(
f"Подтвердите смену тарифа\n\nНовый тариф: {target.name(current_lang)}\nБудет создана оплата на {amount_raw} RUB.", # noqa: E501
f"Подтвердите смену тарифа\n\nНовый тариф: {target.name(current_lang)}\nБудет создана оплата на {amount_raw} {currency_code}.", # noqa: E501
reply_markup=InlineKeyboardMarkup(inline_keyboard=rows),
)
await callback.answer()
@@ -899,11 +941,12 @@ async def tariff_change_pay_callback(
i18n: JsonI18n = i18n_data.get("i18n_instance")
_, _, tariff_key, amount_raw = callback.data.split(":", 3)
amount = float(amount_raw)
currency_code = default_payment_currency_code_for_settings(settings)
markup = get_payment_method_keyboard(
1,
amount,
None,
settings.DEFAULT_CURRENCY_SYMBOL,
currency_code,
current_lang,
i18n,
settings,
@@ -1226,7 +1269,9 @@ async def my_subscription_command_handler(
if (
tariff_for_devices.billing_model == "period"
and tariff_for_devices.hwid_device_packages
and tariff_for_devices.hwid_device_packages.rub
and tariff_for_devices.hwid_device_packages.for_currency(
default_currency_key_for_settings(settings)
)
):
prepend_rows.append(
[
@@ -1443,7 +1488,9 @@ async def my_devices_command_handler(
if (
tariff_for_devices.billing_model == "period"
and tariff_for_devices.hwid_device_packages
and tariff_for_devices.hwid_device_packages.rub
and tariff_for_devices.hwid_device_packages.for_currency(
default_currency_key_for_settings(settings)
)
):
devices_kb.append(
[
+27 -6
View File
@@ -7,6 +7,10 @@ from bot.middlewares.i18n import locale_language_options
from bot.utils.install_links import bot_install_guide_url
from bot.utils.mini_app_url import subscription_mini_app_trial_url
from config.settings import Settings
from config.tariffs_config import (
default_currency_key_for_settings,
default_payment_currency_code_for_settings,
)
BOT_MENU_CONTEXT = "bot"
@@ -328,19 +332,31 @@ def get_tariff_catalog_keyboard(
tariffs: List[Any],
lang: str,
i18n_instance,
settings: Optional[Settings] = None,
back_callback: str = "main_action:back_to_main",
callback_context: Optional[str] = None,
) -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
callback_context = callback_context or callback_context_from_back_callback(back_callback)
default_currency = default_currency_key_for_settings(settings) if settings else "rub"
for tariff in tariffs:
label = tariff.name(lang)
if tariff.billing_model == "period":
min_price = tariff.min_period_price_rub()
if hasattr(tariff, "min_period_price"):
min_price = tariff.min_period_price(default_currency)
elif default_currency == "rub" and hasattr(tariff, "min_period_price_rub"):
min_price = tariff.min_period_price_rub()
else:
min_price = None
if min_price is not None:
label = f"{label} от {min_price:g}"
else:
package = tariff.min_traffic_package_rub()
if hasattr(tariff, "min_traffic_package"):
package = tariff.min_traffic_package(default_currency)
elif default_currency == "rub" and hasattr(tariff, "min_traffic_package_rub"):
package = tariff.min_traffic_package_rub()
else:
package = None
if package:
label = f"{label} от {package.price:g} / {package.gb:g} GB"
builder.row(
@@ -368,8 +384,10 @@ def get_tariff_periods_keyboard(
builder = InlineKeyboardBuilder()
callback_context = callback_context or callback_context_from_back_callback(back_callback)
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
default_currency = default_currency_key_for_settings(settings)
currency_code = default_payment_currency_code_for_settings(settings)
for months in tariff.enabled_periods:
rub_price = tariff.period_price(months, "rub")
rub_price = tariff.period_price(months, default_currency)
if rub_price and rub_price > 0:
builder.row(
InlineKeyboardButton(
@@ -377,7 +395,7 @@ def get_tariff_periods_keyboard(
"subscribe_for_months_button",
months=months,
price=rub_price,
currency_symbol=settings.DEFAULT_CURRENCY_SYMBOL,
currency_symbol=currency_code,
),
callback_data=f"tariff:period:{tariff.key}:{months}"
f"{callback_suffix_for_context(callback_context)}",
@@ -394,6 +412,7 @@ def get_tariff_packages_keyboard(
packages: List[Any],
lang: str,
i18n_instance,
currency_symbol: str = "RUB",
back_callback: str = "main_action:subscribe",
callback_context: Optional[str] = None,
) -> InlineKeyboardMarkup:
@@ -407,7 +426,7 @@ def get_tariff_packages_keyboard(
"buy_traffic_package_button",
traffic_gb=f"{package.gb:g}",
price=package.price,
currency_symbol="RUB",
currency_symbol=currency_symbol,
),
callback_data=f"tariff:package:{tariff.key}:{package.gb:g}"
f"{callback_suffix_for_context(callback_context)}",
@@ -430,6 +449,7 @@ def get_hwid_device_packages_keyboard(
) -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
currency_code = default_payment_currency_code_for_settings(settings)
for package in packages:
builder.row(
InlineKeyboardButton(
@@ -437,7 +457,7 @@ def get_hwid_device_packages_keyboard(
"buy_hwid_devices_button",
count=package.count,
price=package.price,
currency_symbol=settings.DEFAULT_CURRENCY_SYMBOL,
currency_symbol=currency_code,
),
callback_data=(
f"hwid_devices:{'renewal_package' if renewal else 'package'}:"
@@ -484,6 +504,7 @@ def get_payment_method_keyboard(
if (
not spec
or not spec.callback_prefix
or not spec.is_usable_for_payment_currency(settings, currency_symbol_val)
or not spec.is_available_to_user(
settings,
user_id=user_id,
+68
View File
@@ -112,6 +112,7 @@ class WebAppPaymentContext:
stars_price: Optional[int]
description: str
sale_mode: str
currency: str = "RUB"
traffic_gb: Optional[float] = None
hwid_valid_from: Optional[Any] = None
hwid_valid_until: Optional[Any] = None
@@ -125,6 +126,36 @@ ServiceFactory = Callable[[ServiceFactoryContext], Any]
WebhookPathGetter = Callable[[Any], str]
WebhookRoute = Callable[[Any], Awaitable[Any]]
WebAppPaymentFactory = Callable[[WebAppPaymentContext], Awaitable[Any]]
CurrencySupportResolver = Callable[[Any], Optional[Sequence[str]]]
def normalize_payment_currency_code(value: Any, default: str = "RUB") -> str:
text = str(value or "").strip().upper()
if not text:
text = str(default).strip().upper() if default is not None else ""
if not text:
return ""
aliases = {"RUR": "RUB", "STARS": "XTR", "STAR": "XTR"}
normalized = aliases.get(text, text)
return "".join(ch for ch in normalized if ch.isalnum() or ch in {"_", "-"}).strip("_-")
def parse_supported_currency_codes(value: Any) -> tuple[str, ...]:
if value is None:
return ()
if isinstance(value, str):
raw_items = value.replace(";", ",").split(",")
else:
raw_items = list(value)
currencies: list[str] = []
seen: set[str] = set()
for item in raw_items:
code = normalize_payment_currency_code(item, default="")
if not code or code in seen:
continue
seen.add(code)
currencies.append(code)
return tuple(currencies)
@dataclass(frozen=True)
@@ -158,6 +189,10 @@ class PaymentProviderSpec:
admin_only_manifest_key: Optional[str] = None
admin_only_config_attr: str = "ADMIN_ONLY_ENABLED"
admin_only_enabled: Optional[EnabledPredicate] = None
supported_currencies: Optional[Sequence[str]] = ("RUB",)
supported_currencies_resolver: Optional[CurrencySupportResolver] = None
currency_support_note: str = ""
currency_support_url: Optional[str] = None
@property
def settings_key(self) -> str:
@@ -237,6 +272,39 @@ class PaymentProviderSpec:
service = app.get(self.service_key) if hasattr(app, "get") else None
return bool(service and getattr(service, "configured", False))
def _currency_source(self, source: Any) -> Any:
if self.config_class is not None and self.service_key:
from .registry import get_provider_bundle
bundle = get_provider_bundle(self.service_key)
if bundle and bundle.config is not None:
return bundle.config
return source
def supported_currency_codes(self, source: Any = None) -> Optional[tuple[str, ...]]:
if self.price_source == "stars":
return ("XTR",)
source_for_currency = self._currency_source(source)
if self.supported_currencies_resolver is not None:
resolved = self.supported_currencies_resolver(source_for_currency)
if resolved is None:
return None
return parse_supported_currency_codes(resolved)
if self.supported_currencies is None:
return None
return parse_supported_currency_codes(self.supported_currencies)
def supports_currency(self, source: Any, currency: Any) -> bool:
supported = self.supported_currency_codes(source)
if supported is None:
return True
return normalize_payment_currency_code(currency) in supported
def is_usable_for_payment_currency(self, source: Any, currency: Any) -> bool:
if self.price_source == "stars":
return True
return self.supports_currency(source, currency)
def is_visible(self, source: Any, app: Any) -> bool:
return self.is_enabled(source) and self.is_service_configured(app)
+58 -5
View File
@@ -17,6 +17,10 @@ from bot.middlewares.i18n import JsonI18n
from bot.services.referral_service import ReferralService
from bot.services.subscription_service import SubscriptionService
from config.settings import Settings
from config.tariffs_config import (
default_currency_key_for_settings,
default_payment_currency_code_for_settings,
)
from db.dal import payment_dal
from .base import (
@@ -25,6 +29,7 @@ from .base import (
ProviderManifestField,
ServiceFactoryContext,
WebAppPaymentContext,
normalize_payment_currency_code,
provider_env_file,
provider_runtime_enabled,
)
@@ -49,6 +54,34 @@ from .shared import (
logger = logging.getLogger(__name__)
_LOG = "cryptopay"
CRYPTOPAY_FIAT_CURRENCIES = (
"USD",
"EUR",
"RUB",
"BYN",
"UAH",
"GBP",
"CNY",
"KZT",
"UZS",
"GEL",
"TRY",
"AMD",
"THB",
"INR",
"BRL",
"IDR",
"AZN",
"AED",
"PLN",
"ILS",
)
CRYPTOPAY_CRYPTO_ASSETS = ("USDT", "TON", "BTC", "ETH", "LTC", "BNB", "TRX", "USDC")
def _cryptopay_supported_currencies(config) -> tuple[str, ...]:
currency_type = str(getattr(config, "CURRENCY_TYPE", "fiat") or "fiat").strip().lower()
return CRYPTOPAY_CRYPTO_ASSETS if currency_type == "crypto" else CRYPTOPAY_FIAT_CURRENCIES
class CryptoPayConfig(ProviderEnvConfig):
@@ -159,11 +192,23 @@ class CryptoPayService:
sale_mode: str = "subscription",
url_kind: str = "bot",
hwid_quote: Optional[dict] = None,
currency: Optional[str] = None,
) -> Optional[str]:
if not self.configured or not self.client:
logging.error("CryptoPayService not configured")
return None
currency_code = normalize_payment_currency_code(currency or self.config.ASSET)
currency_type = str(self.config.CURRENCY_TYPE or "fiat").strip().lower()
supported = _cryptopay_supported_currencies(self.config)
if currency_code not in supported:
logging.error(
"CryptoPay currency %s is not supported for currency_type=%s",
currency_code,
currency_type,
)
return None
sale_base = sale_mode_base(sale_mode)
amounts = payment_record_amounts(months=months, sale_mode=sale_mode)
try:
@@ -172,7 +217,7 @@ class CryptoPayService:
{
"user_id": user_id,
"amount": float(amount),
"currency": self.config.ASSET,
"currency": currency_code,
"status": "pending_cryptopay",
"description": description,
"subscription_duration_months": (
@@ -212,9 +257,9 @@ class CryptoPayService:
try:
invoice = await self.client.create_invoice(
amount=amount,
currency_type=self.config.CURRENCY_TYPE,
fiat=self.config.ASSET if self.config.CURRENCY_TYPE == "fiat" else None,
asset=self.config.ASSET if self.config.CURRENCY_TYPE == "crypto" else None,
currency_type=currency_type,
fiat=currency_code if currency_type == "fiat" else None,
asset=currency_code if currency_type == "crypto" else None,
description=description,
payload=payload,
)
@@ -393,7 +438,7 @@ async def pay_crypto_callback_handler(
user_id=callback.from_user.id,
parts=parts,
subscription_service=cryptopay_service.subscription_service,
currency="rub",
currency=default_currency_key_for_settings(settings),
)
if not parts:
await notify_callback_parse_error(callback, translator)
@@ -408,6 +453,7 @@ async def pay_crypto_callback_handler(
description=payment_description,
sale_mode=parts.sale_mode,
hwid_quote=hwid_quote,
currency=default_payment_currency_code_for_settings(settings),
)
if invoice_url:
@@ -457,6 +503,7 @@ async def create_webapp_payment(ctx: WebAppPaymentContext) -> web.Response:
description=ctx.description,
sale_mode=ctx.sale_mode,
url_kind="web",
currency=ctx.currency,
hwid_quote={
"valid_from": ctx.hwid_valid_from,
"valid_until": ctx.hwid_valid_until,
@@ -592,4 +639,10 @@ SPEC = PaymentProviderSpec(
config_class=CryptoPayConfig,
presentation_class=CryptoPayPresentation,
manifest_fields=_CONFIG_MANIFEST + _PRESENTATION_MANIFEST,
supported_currencies_resolver=_cryptopay_supported_currencies,
currency_support_note=(
"Crypto Pay supports different sets for fiat invoices and crypto invoices; "
"CURRENCY_TYPE selects which set is active."
),
currency_support_url="https://help.crypt.bot/crypto-pay-api/",
)
+24 -6
View File
@@ -20,6 +20,10 @@ from bot.services.referral_service import ReferralService
from bot.services.subscription_service import SubscriptionService
from bot.utils.request_security import ip_in_allowlist, request_client_ip
from config.settings import Settings
from config.tariffs_config import (
default_currency_key_for_settings,
default_payment_currency_code_for_settings,
)
from db.dal import payment_dal
from .base import (
@@ -28,6 +32,7 @@ from .base import (
ProviderManifestField,
ServiceFactoryContext,
WebAppPaymentContext,
normalize_payment_currency_code,
provider_env_file,
provider_runtime_enabled,
)
@@ -56,6 +61,7 @@ from .shared import (
)
_LOG = "freekassa"
FREEKASSA_SUPPORTED_CURRENCIES = ("RUB", "USD", "EUR", "UAH", "KZT")
class FreeKassaConfig(ProviderEnvConfig):
@@ -144,7 +150,7 @@ class FreeKassaService(HttpClientMixin):
self.subscription_service = subscription_service
self.referral_service = referral_service
self.default_currency: str = (settings.DEFAULT_CURRENCY_SYMBOL or "RUB").upper()
self.default_currency: str = default_payment_currency_code_for_settings(settings).upper()
self.api_base_url: str = "https://api.fk.life/v1"
self._init_http_client(total_timeout=15)
@@ -207,7 +213,13 @@ class FreeKassaService(HttpClientMixin):
return False, {"message": "missing_ip"}
email = email or f"{user_id}@telegram.org"
currency_code = (currency or self.default_currency or "RUB").upper()
currency_code = normalize_payment_currency_code(currency or self.default_currency or "RUB")
if currency_code not in FREEKASSA_SUPPORTED_CURRENCIES:
return False, {
"message": "unsupported_currency",
"currency": currency_code,
"supported_currencies": list(FREEKASSA_SUPPORTED_CURRENCIES),
}
payload: Dict[str, Any] = {
"shopId": int(self.shop_id),
@@ -477,7 +489,7 @@ async def pay_fk_callback_handler(
user_id=callback.from_user.id,
parts=parts,
subscription_service=freekassa_service.subscription_service,
currency="rub",
currency=default_currency_key_for_settings(settings),
)
if not parts:
await notify_callback_parse_error(callback, translator)
@@ -485,7 +497,7 @@ async def pay_fk_callback_handler(
currency_code = (
getattr(freekassa_service, "default_currency", None)
or settings.DEFAULT_CURRENCY_SYMBOL
or default_payment_currency_code_for_settings(settings)
or "RUB"
)
payment_description = describe_payment(translator, parts)
@@ -578,12 +590,13 @@ async def create_webapp_payment(ctx: WebAppPaymentContext) -> web.Response:
service: FreeKassaService = ctx.request.app["freekassa_service"]
if not service or not service.configured or not service.payment_method_id:
return payment_unavailable()
currency = ctx.currency or service.default_currency
try:
payment = await create_webapp_payment_record(
ctx,
amount=ctx.price,
currency=service.default_currency,
currency=currency,
status="pending_freekassa",
provider="freekassa",
)
@@ -592,7 +605,7 @@ async def create_webapp_payment(ctx: WebAppPaymentContext) -> web.Response:
user_id=ctx.user_id,
months=ctx.months,
amount=ctx.price,
currency=service.default_currency,
currency=currency,
payment_method_id=service.payment_method_id,
ip_address=service.server_ip,
extra_params={"us_method": service.payment_method_id},
@@ -762,4 +775,9 @@ SPEC = PaymentProviderSpec(
config_class=FreeKassaConfig,
presentation_class=FreeKassaPresentation,
manifest_fields=_CONFIG_MANIFEST + _PRESENTATION_MANIFEST,
supported_currencies=FREEKASSA_SUPPORTED_CURRENCIES,
currency_support_note=(
"FreeKassa SCI documents the payment currency parameter as RUB, USD, EUR, UAH or KZT."
),
currency_support_url="https://docs.freekassa.net/",
)
+44 -4
View File
@@ -18,6 +18,10 @@ from bot.services.referral_service import ReferralService
from bot.services.subscription_service import SubscriptionService
from bot.utils.request_security import ip_in_allowlist, request_client_ip
from config.settings import Settings
from config.tariffs_config import (
default_currency_key_for_settings,
default_payment_currency_code_for_settings,
)
from db.dal import payment_dal
from .base import (
@@ -26,6 +30,8 @@ from .base import (
ProviderManifestField,
ServiceFactoryContext,
WebAppPaymentContext,
normalize_payment_currency_code,
parse_supported_currency_codes,
provider_env_file,
provider_runtime_enabled,
)
@@ -59,6 +65,10 @@ _LOG = "heleket"
_SUCCESS_STATUSES = {"paid", "paid_over"}
_FAILED_STATUSES = {"fail", "wrong_amount", "cancel", "system_fail"}
HELEKET_DEFAULT_SUPPORTED_CURRENCIES = (
"RUB,USD,EUR,USDT,USDC,BTC,ETH,LTC,TON,TRX,BNB,BCH,DASH,DAI,DOGE,"
"MATIC,SHIB,SOL,XMR,AVAX,BUSD,VERSE"
)
class HeleketConfig(ProviderEnvConfig):
@@ -83,6 +93,7 @@ class HeleketConfig(ProviderEnvConfig):
LIFETIME_SECONDS: int = Field(default=3600)
VERIFY_WEBHOOK_SIGNATURE: bool = Field(default=True)
TRUSTED_IPS: str = Field(default="31.133.220.8")
SUPPORTED_CURRENCIES: str = Field(default=HELEKET_DEFAULT_SUPPORTED_CURRENCIES)
@field_validator("LIFETIME_SECONDS", mode="before")
@classmethod
@@ -299,9 +310,18 @@ class HeleketService(HttpClientMixin):
logging.error("HeleketService is not configured. Cannot create payment link.")
return False, {"message": "service_not_configured"}
currency_code = normalize_payment_currency_code(currency or self.currency)
supported = parse_supported_currency_codes(self.config.SUPPORTED_CURRENCIES)
if supported and currency_code not in supported:
return False, {
"message": "unsupported_currency",
"currency": currency_code,
"supported_currencies": list(supported),
}
body: Dict[str, Any] = {
"amount": str(format_decimal_amount(amount)),
"currency": (currency or self.currency).upper(),
"currency": currency_code,
"order_id": str(payment_db_id),
"url_return": self.return_url,
"url_success": self.success_url,
@@ -572,13 +592,13 @@ async def pay_heleket_callback_handler(
user_id=callback.from_user.id,
parts=parts,
subscription_service=heleket_service.subscription_service,
currency="rub",
currency=default_currency_key_for_settings(settings),
)
if not parts:
await notify_callback_parse_error(callback, translator)
return
currency_code = (heleket_service.currency or settings.DEFAULT_CURRENCY_SYMBOL or "RUB").upper()
currency_code = default_payment_currency_code_for_settings(settings)
payment_description = describe_payment(translator, parts)
record_payload = build_payment_record_payload(
user_id=callback.from_user.id,
@@ -632,7 +652,7 @@ async def create_webapp_payment(ctx: WebAppPaymentContext) -> web.Response:
if not service or not service.configured:
return payment_unavailable()
currency = (service.currency or settings.DEFAULT_CURRENCY_SYMBOL or "RUB").upper()
currency = ctx.currency or default_payment_currency_code_for_settings(settings)
try:
payment = await create_webapp_payment_record(
ctx,
@@ -787,6 +807,18 @@ _CONFIG_MANIFEST = (
subsection="Heleket",
attr="CURRENCY",
),
ProviderManifestField(
"HELEKET_SUPPORTED_CURRENCIES",
"string",
"Supported currencies",
description=(
"Comma-separated invoice currencies allowed for Heleket in this shop. "
"Heleket can reject unsupported codes per account/service."
),
placeholder=HELEKET_DEFAULT_SUPPORTED_CURRENCIES,
subsection="Heleket",
attr="SUPPORTED_CURRENCIES",
),
ProviderManifestField(
"HELEKET_TO_CURRENCY",
"string",
@@ -859,4 +891,12 @@ SPEC = PaymentProviderSpec(
config_class=HeleketConfig,
presentation_class=HeleketPresentation,
manifest_fields=_CONFIG_MANIFEST + _PRESENTATION_MANIFEST,
supported_currencies_resolver=lambda config: getattr(
config, "SUPPORTED_CURRENCIES", HELEKET_DEFAULT_SUPPORTED_CURRENCIES
),
currency_support_note=(
"Heleket supports crypto and fiat invoice currencies, but exact availability "
"can depend on service/account settings."
),
currency_support_url="https://doc.heleket.com/methods/payments/creating-invoice",
)
+46 -5
View File
@@ -14,6 +14,10 @@ from bot.middlewares.i18n import JsonI18n
from bot.services.referral_service import ReferralService
from bot.services.subscription_service import SubscriptionService
from config.settings import Settings
from config.tariffs_config import (
default_currency_key_for_settings,
default_payment_currency_code_for_settings,
)
from db.dal import payment_dal
from .base import (
@@ -22,6 +26,8 @@ from .base import (
ProviderManifestField,
ServiceFactoryContext,
WebAppPaymentContext,
normalize_payment_currency_code,
parse_supported_currency_codes,
provider_env_file,
provider_runtime_enabled,
)
@@ -76,6 +82,7 @@ class PlategaConfig(ProviderEnvConfig):
CRYPTO_METHOD: int = Field(default=13)
RETURN_URL: Optional[str] = None
FAILED_URL: Optional[str] = None
SUPPORTED_CURRENCIES: str = Field(default="RUB")
@field_validator("MERCHANT_ID", "SECRET", "RETURN_URL", "FAILED_URL", mode="before")
@classmethod
@@ -229,9 +236,19 @@ class PlategaService(HttpClientMixin):
logging.error("PlategaService is not configured. Cannot create transaction.")
return False, {"message": "service_not_configured"}
currency_code = normalize_payment_currency_code(
currency or self.settings.DEFAULT_CURRENCY_SYMBOL or "RUB"
)
supported = parse_supported_currency_codes(self.config.SUPPORTED_CURRENCIES)
if supported and currency_code not in supported:
return False, {
"message": "unsupported_currency",
"currency": currency_code,
"supported_currencies": list(supported),
}
session = await self._get_session()
url = f"{self.base_url}/transaction/process"
currency_code = (currency or self.settings.DEFAULT_CURRENCY_SYMBOL or "RUB").upper()
method_id = int(payment_method if payment_method is not None else self.payment_method)
body: Dict[str, Any] = {
@@ -482,13 +499,13 @@ async def pay_platega_callback_handler(
user_id=callback.from_user.id,
parts=parts,
subscription_service=platega_service.subscription_service,
currency="rub",
currency=default_currency_key_for_settings(settings),
)
if not parts:
await notify_callback_parse_error(callback, translator)
return
currency_code = settings.DEFAULT_CURRENCY_SYMBOL or "RUB"
currency_code = default_payment_currency_code_for_settings(settings)
payment_description = describe_payment(translator, parts)
record_payload = build_payment_record_payload(
user_id=callback.from_user.id,
@@ -596,7 +613,7 @@ async def _create_webapp_payment(ctx: WebAppPaymentContext, variant: str) -> web
payment = await create_webapp_payment_record(
ctx,
amount=ctx.price,
currency=settings.DEFAULT_CURRENCY_SYMBOL or "RUB",
currency=ctx.currency or settings.DEFAULT_CURRENCY_SYMBOL or "RUB",
status="pending_platega",
provider="platega",
)
@@ -616,7 +633,7 @@ async def _create_webapp_payment(ctx: WebAppPaymentContext, variant: str) -> web
)
success, response_data = await service.create_transaction(
amount=ctx.price,
currency=settings.DEFAULT_CURRENCY_SYMBOL or "RUB",
currency=ctx.currency or settings.DEFAULT_CURRENCY_SYMBOL or "RUB",
description=ctx.description,
payload=payload,
payment_method=platega_method_id,
@@ -757,6 +774,18 @@ _CONFIG_MANIFEST = (
subsection="Platega",
attr="CRYPTO_METHOD",
),
ProviderManifestField(
"PLATEGA_SUPPORTED_CURRENCIES",
"string",
"Supported currencies",
description=(
"Comma-separated payment currencies enabled for your Platega merchant. "
"Public docs expose currency per method/limits but do not publish a fixed global list."
),
placeholder="RUB",
subsection="Platega",
attr="SUPPORTED_CURRENCIES",
),
ProviderManifestField(
"PLATEGA_RETURN_URL", "url", "Return URL", subsection="Platega", attr="RETURN_URL"
),
@@ -793,6 +822,12 @@ SBP_SPEC = PaymentProviderSpec(
presentation_class=PlategaSbpPresentation,
manifest_fields=_CONFIG_MANIFEST
+ _platega_presentation_manifest("Platega", "CreditCard", "PLATEGA_SBP"),
supported_currencies_resolver=lambda config: getattr(config, "SUPPORTED_CURRENCIES", "RUB"),
currency_support_note=(
"Platega currencies are merchant/method-specific; configure the codes "
"enabled for your account."
),
currency_support_url="https://docs.platega.io/",
)
CRYPTO_SPEC = PaymentProviderSpec(
@@ -818,6 +853,12 @@ CRYPTO_SPEC = PaymentProviderSpec(
config_class=PlategaConfig,
presentation_class=PlategaCryptoPresentation,
manifest_fields=_platega_presentation_manifest("Platega", "Bitcoin", "PLATEGA_CRYPTO"),
supported_currencies_resolver=lambda config: getattr(config, "SUPPORTED_CURRENCIES", "RUB"),
currency_support_note=(
"Platega currencies are merchant/method-specific; configure the codes "
"enabled for your account."
),
currency_support_url="https://docs.platega.io/",
)
SPECS = (SBP_SPEC, CRYPTO_SPEC)
+38 -4
View File
@@ -16,6 +16,10 @@ from bot.middlewares.i18n import JsonI18n
from bot.services.referral_service import ReferralService
from bot.services.subscription_service import SubscriptionService
from config.settings import Settings
from config.tariffs_config import (
default_currency_key_for_settings,
default_payment_currency_code_for_settings,
)
from db.dal import payment_dal
from .base import (
@@ -24,6 +28,8 @@ from .base import (
ProviderManifestField,
ServiceFactoryContext,
WebAppPaymentContext,
normalize_payment_currency_code,
parse_supported_currency_codes,
provider_env_file,
provider_runtime_enabled,
)
@@ -69,6 +75,7 @@ class SeverPayConfig(ProviderEnvConfig):
RETURN_URL: Optional[str] = None
BASE_URL: str = Field(default="https://severpay.io/api/merchant")
LIFETIME_MINUTES: Optional[int] = None
SUPPORTED_CURRENCIES: str = Field(default="RUB,USD")
@field_validator("MID", "LIFETIME_MINUTES", mode="before")
@classmethod
@@ -201,9 +208,19 @@ class SeverPayService(HttpClientMixin):
logging.error("SeverPayService is not configured. Cannot create payment.")
return False, {"message": "service_not_configured"}
currency_code = normalize_payment_currency_code(
currency or self.settings.DEFAULT_CURRENCY_SYMBOL or "RUB"
)
supported = parse_supported_currency_codes(self.config.SUPPORTED_CURRENCIES)
if supported and currency_code not in supported:
return False, {
"message": "unsupported_currency",
"currency": currency_code,
"supported_currencies": list(supported),
}
session = await self._get_session()
url = f"{self.base_url}/payin/create"
currency_code = (currency or self.settings.DEFAULT_CURRENCY_SYMBOL or "RUB").upper()
body = {
"order_id": str(payment_db_id),
@@ -428,13 +445,13 @@ async def pay_severpay_callback_handler(
user_id=callback.from_user.id,
parts=parts,
subscription_service=severpay_service.subscription_service,
currency="rub",
currency=default_currency_key_for_settings(settings),
)
if not parts:
await notify_callback_parse_error(callback, translator)
return
currency_code = settings.DEFAULT_CURRENCY_SYMBOL or "RUB"
currency_code = default_payment_currency_code_for_settings(settings)
payment_description = describe_payment(translator, parts)
record_payload = build_payment_record_payload(
user_id=callback.from_user.id,
@@ -503,7 +520,7 @@ async def create_webapp_payment(ctx: WebAppPaymentContext) -> web.Response:
if not service or not service.configured:
return payment_unavailable()
currency = settings.DEFAULT_CURRENCY_SYMBOL or "RUB"
currency = ctx.currency or settings.DEFAULT_CURRENCY_SYMBOL or "RUB"
try:
payment = await create_webapp_payment_record(
ctx,
@@ -627,6 +644,18 @@ _CONFIG_MANIFEST = (
max=4320,
attr="LIFETIME_MINUTES",
),
ProviderManifestField(
"SEVERPAY_SUPPORTED_CURRENCIES",
"string",
"Supported currencies",
description=(
"Comma-separated currencies enabled for your SeverPay merchant. "
"The public PayIn docs show USD examples but do not publish a fixed global list."
),
placeholder="RUB,USD",
subsection="SeverPay",
attr="SUPPORTED_CURRENCIES",
),
)
@@ -651,4 +680,9 @@ SPEC = PaymentProviderSpec(
config_class=SeverPayConfig,
presentation_class=SeverPayPresentation,
manifest_fields=_CONFIG_MANIFEST + _PRESENTATION_MANIFEST,
supported_currencies_resolver=lambda config: getattr(config, "SUPPORTED_CURRENCIES", "RUB,USD"),
currency_support_note=(
"SeverPay PayIn requires a currency; keep this list aligned with your merchant account."
),
currency_support_url="https://docs.severpay.io/ru/payin/create",
)
+2
View File
@@ -484,4 +484,6 @@ SPEC = PaymentProviderSpec(
telegram_emoji="",
presentation_class=StarsPresentation,
manifest_fields=_PRESENTATION_MANIFEST,
supported_currencies=("XTR",),
currency_support_note="Telegram Stars use Telegram's XTR currency and separate Stars prices.",
)
+25 -4
View File
@@ -19,6 +19,10 @@ from bot.services.referral_service import ReferralService
from bot.services.subscription_service import SubscriptionService
from bot.utils.request_security import ip_in_allowlist, request_client_ip
from config.settings import Settings
from config.tariffs_config import (
default_currency_key_for_settings,
default_payment_currency_code_for_settings,
)
from db.dal import payment_dal
from .base import (
@@ -27,6 +31,7 @@ from .base import (
ProviderManifestField,
ServiceFactoryContext,
WebAppPaymentContext,
normalize_payment_currency_code,
provider_env_file,
provider_runtime_enabled,
)
@@ -63,6 +68,7 @@ from .shared import (
router = Router(name="user_subscription_payments_wata_router")
_LOG = "wata"
WATA_SUPPORTED_CURRENCIES = ("RUB", "USD", "EUR")
_WATA_IN_PROGRESS_STATUSES = {"created", "pending"}
_WATA_LINK_OPENED_STATUSES = {"opened", "open"}
_WATA_LINK_DEFAULT_TTL_MINUTES = 15
@@ -258,13 +264,23 @@ class WataService(HttpClientMixin):
logging.error("WataService is not configured. Cannot create payment link.")
return False, {"message": "service_not_configured"}
currency_code = normalize_payment_currency_code(
currency or self.settings.DEFAULT_CURRENCY_SYMBOL or "RUB"
)
if currency_code not in WATA_SUPPORTED_CURRENCIES:
return False, {
"message": "unsupported_currency",
"currency": currency_code,
"supported_currencies": list(WATA_SUPPORTED_CURRENCIES),
}
session = await self._get_session()
expires_at = (
datetime.now(timezone.utc) + timedelta(minutes=self.payment_link_ttl_minutes)
).replace(microsecond=0)
body: Dict[str, Any] = {
"amount": float(format_decimal_amount(amount)),
"currency": (currency or self.settings.DEFAULT_CURRENCY_SYMBOL or "RUB").upper(),
"currency": currency_code,
"description": description,
"orderId": str(payment_db_id),
"successRedirectUrl": self.return_url,
@@ -863,13 +879,13 @@ async def pay_wata_callback_handler(
user_id=callback.from_user.id,
parts=parts,
subscription_service=wata_service.subscription_service,
currency="rub",
currency=default_currency_key_for_settings(settings),
)
if not parts:
await notify_callback_parse_error(callback, translator)
return
currency_code = settings.DEFAULT_CURRENCY_SYMBOL or "RUB"
currency_code = default_payment_currency_code_for_settings(settings)
payment_description = describe_payment(translator, parts)
reuse_amounts = payment_record_amounts(months=parts.months, sale_mode=parts.sale_mode)
@@ -956,7 +972,7 @@ async def create_webapp_payment(ctx: WebAppPaymentContext) -> web.Response:
if not service or not service.configured:
return payment_unavailable()
currency = settings.DEFAULT_CURRENCY_SYMBOL or "RUB"
currency = ctx.currency or settings.DEFAULT_CURRENCY_SYMBOL or "RUB"
reuse_amounts = payment_record_amounts(
months=ctx.months,
@@ -1190,4 +1206,9 @@ SPEC = PaymentProviderSpec(
config_class=WataConfig,
presentation_class=WataPresentation,
manifest_fields=_CONFIG_MANIFEST + _PRESENTATION_MANIFEST,
supported_currencies=WATA_SUPPORTED_CURRENCIES,
currency_support_note=(
"WATA H2H payment links and widget document RUB, USD and EUR as payment currencies."
),
currency_support_url="https://wata.pro/api",
)
+28 -8
View File
@@ -40,6 +40,10 @@ from bot.utils.config_link import prepare_config_links
from bot.utils.install_links import ensure_user_install_guide_links
from bot.utils.request_security import ip_in_allowlist, request_client_ip
from config.settings import Settings
from config.tariffs_config import (
default_currency_key_for_settings,
default_payment_currency_code_for_settings,
)
from db.dal import payment_dal, user_billing_dal, user_dal
from db.models import Payment
@@ -49,6 +53,7 @@ from .base import (
ProviderManifestField,
ServiceFactoryContext,
WebAppPaymentContext,
normalize_payment_currency_code,
provider_env_file,
provider_runtime_enabled,
)
@@ -234,6 +239,11 @@ class YooKassaService:
"internal_message": "Service settings (Settings object) not initialized.",
}
currency = normalize_payment_currency_code(currency)
if currency != "RUB":
logging.error("YooKassa currency %s is not supported by this integration", currency)
return None
customer_contact_for_receipt = {}
if receipt_email:
customer_contact_for_receipt["email"] = receipt_email
@@ -885,7 +895,10 @@ async def process_successful_payment(
i18n=i18n,
user_id=user_id,
amount=payment_value,
currency=settings.DEFAULT_CURRENCY_SYMBOL,
currency=amount_data.get(
"currency",
default_payment_currency_code_for_settings(settings),
),
months_for_admin=int(subscription_months) if sale_mode_base == "subscription" else 0,
traffic_gb_for_admin=(
traffic_amount_gb if is_traffic_sale_base(sale_mode_base) else None
@@ -1702,7 +1715,7 @@ async def pay_yk_callback_handler(
user_id=callback.from_user.id,
parts=PaymentCallbackParts(months=months, price=price_rub, sale_mode=sale_mode),
subscription_service=yookassa_service.subscription_service,
currency="rub",
currency=default_currency_key_for_settings(settings),
)
if not quoted_parts:
try:
@@ -1713,7 +1726,7 @@ async def pay_yk_callback_handler(
months = quoted_parts.months
price_rub = quoted_parts.price
user_id = callback.from_user.id
currency_code_for_yk = "RUB"
currency_code_for_yk = default_payment_currency_code_for_settings(settings)
autopay_enabled = bool(
settings.yookassa_autopayments_active
and _sale_mode_base(sale_mode) == "subscription"
@@ -1851,7 +1864,7 @@ async def pay_yk_new_card_handler(
months, price_rub, sale_mode = parsed
user_id = callback.from_user.id
currency_code_for_yk = "RUB"
currency_code_for_yk = default_payment_currency_code_for_settings(settings)
autopay_enabled = bool(
settings.yookassa_autopayments_active
and _sale_mode_base(sale_mode) == "subscription"
@@ -2154,7 +2167,7 @@ async def pay_yk_use_saved_handler(
pass
return
currency_code_for_yk = "RUB"
currency_code_for_yk = default_payment_currency_code_for_settings(settings)
await _initiate_yk_payment(
callback,
@@ -2260,7 +2273,7 @@ async def payment_method_bind(
metadata = {"user_id": str(callback.from_user.id), "bind_only": "1"}
resp = await yookassa_service.create_payment(
amount=1.00,
currency="RUB",
currency=default_payment_currency_code_for_settings(settings),
description="Bind card",
metadata=metadata,
receipt_email=yookassa_service.config.DEFAULT_RECEIPT_EMAIL,
@@ -2734,6 +2747,7 @@ async def create_webapp_payment(ctx: WebAppPaymentContext) -> web.Response:
service: YooKassaService = ctx.request.app["yookassa_service"]
if not service or not service.configured:
return payment_unavailable()
currency = (ctx.currency or "RUB").upper()
try:
amounts = payment_record_amounts(
@@ -2744,7 +2758,7 @@ async def create_webapp_payment(ctx: WebAppPaymentContext) -> web.Response:
payment = await create_webapp_payment_record(
ctx,
amount=ctx.price,
currency="RUB",
currency=currency,
status="pending_yookassa",
provider="yookassa",
)
@@ -2767,7 +2781,7 @@ async def create_webapp_payment(ctx: WebAppPaymentContext) -> web.Response:
metadata["tariff_key"] = amounts.tariff_key
response = await service.create_payment(
amount=ctx.price,
currency="RUB",
currency=currency,
description=ctx.description,
metadata=metadata,
receipt_email=service.config.DEFAULT_RECEIPT_EMAIL,
@@ -2929,4 +2943,10 @@ SPEC = PaymentProviderSpec(
config_class=YooKassaConfig,
presentation_class=YooKassaPresentation,
manifest_fields=_CONFIG_MANIFEST + _PRESENTATION_MANIFEST,
supported_currencies=("RUB",),
currency_support_note=(
"YooKassa public payment API examples and limits are RUB-based; "
"treat non-RUB as unsupported unless your YooKassa contract confirms otherwise."
),
currency_support_url="https://yookassa.ru/developers/payment-acceptance/integration-scenarios/smart-payment",
)
+11 -8
View File
@@ -12,11 +12,12 @@ from __future__ import annotations
import html
import re
from dataclasses import dataclass
from typing import Optional, Sequence, Tuple
from typing import TYPE_CHECKING, Optional, Sequence, Tuple
from urllib.parse import urlsplit
from bot.middlewares.i18n import JsonI18n, get_i18n_instance, normalize_locale_language_code
from config.settings import Settings
if TYPE_CHECKING:
from bot.middlewares.i18n import JsonI18n
from config.settings import Settings
_BG = "#05070a"
_CARD_BG = "#0e1116"
@@ -64,14 +65,16 @@ def _brand_title(settings: Settings) -> str:
def _normalize_lang(language_code: Optional[str], settings: Settings) -> str:
return normalize_locale_language_code(
language_code or settings.DEFAULT_LANGUAGE or "ru",
prefer_known_base=False,
)
value = str(language_code or settings.DEFAULT_LANGUAGE or "ru").strip().lower()
return value.replace("_", "-") or "ru"
def _resolve_i18n(i18n: Optional[JsonI18n]) -> JsonI18n:
return i18n or get_i18n_instance()
if i18n is not None:
return i18n
from bot.middlewares.i18n import get_i18n_instance
return get_i18n_instance()
def _t_html(i18n: JsonI18n, lang: str, key: str, **kwargs) -> str:
@@ -11,7 +11,11 @@ from bot.middlewares.i18n import JsonI18n
from bot.utils.config_link import prepare_config_links
from bot.utils.date_utils import add_months, month_start
from config.settings import Settings
from config.tariffs_config import Tariff
from config.tariffs_config import (
Tariff,
default_currency_key_for_settings,
default_payment_currency_code_for_settings,
)
from db.dal import (
payment_dal,
promo_code_dal,
@@ -70,7 +70,7 @@ class HwidDeviceMixin:
package_set = tariff.hwid_device_packages
if not package_set:
return None
packages = package_set.for_currency("stars" if currency == "stars" else "rub")
packages = package_set.for_currency(currency)
return next((pkg for pkg in packages if int(pkg.count) == int(device_count)), None)
def _quote_hwid_package_price(
@@ -173,7 +173,7 @@ class HwidDeviceMixin:
valid_from=valid_from,
valid_until=valid_until,
now=now,
currency="stars" if currency == "stars" else "rub",
currency=currency,
)
quote.update(
{
@@ -227,7 +227,11 @@ class HwidDeviceMixin:
)
return None
packages = (
[*tariff.hwid_device_packages.rub, *tariff.hwid_device_packages.stars]
[
package
for currency_packages in tariff.hwid_device_packages.root.values()
for package in currency_packages
]
if tariff.hwid_device_packages
else []
)
@@ -248,9 +248,9 @@ class SubscriptionLifecycleMixin:
traffic_used_bytes=used_sub,
)
update_data["period_start_at"] = None
update_data["effective_monthly_price_rub"] = (
target.period_price(1, "rub") or target.min_period_price_rub()
)
update_data["effective_monthly_price_rub"] = target.period_price(
1, default_currency_key_for_settings(self.settings)
) or target.min_period_price(default_currency_key_for_settings(self.settings))
if mode == "recalc_days" and options.get("recalc_days") is not None:
update_data["end_date"] = now + timedelta(days=int(options["recalc_days"]))
else:
@@ -120,7 +120,7 @@ class PaymentContextMixin:
months=int(months or 0),
traffic_gb=traffic_gb,
amount=float(payment_amount or 0),
currency=self.settings.DEFAULT_CURRENCY_SYMBOL,
currency=default_payment_currency_code_for_settings(self.settings),
end_date_text=end_date_text,
dashboard_url=dashboard_url,
provider_label=provider_label,
@@ -41,7 +41,23 @@ class RenewalMixin:
return False
months = sub.duration_months or 1
amount = self.settings.subscription_options.get(months)
currency = default_payment_currency_code_for_settings(self.settings)
amount = None
tariffs_config = (
self._tariffs_config() if callable(getattr(self, "_tariffs_config", None)) else None
)
if tariffs_config and callable(getattr(self, "_resolve_tariff", None)):
try:
tariff = self._resolve_tariff(getattr(sub, "tariff_key", None))
except Exception:
tariff = None
if tariff and tariff.billing_model == "period":
amount = tariff.period_price(
months,
default_currency_key_for_settings(self.settings),
)
if amount is None:
amount = self.settings.subscription_options.get(months)
if not amount:
logging.error(f"Auto-renew price missing for {months} months")
return False
@@ -53,7 +69,7 @@ class RenewalMixin:
}
resp = await yk.create_payment(
amount=float(amount),
currency="RUB",
currency=currency,
description=f"Auto-renewal for {months} months",
metadata=metadata,
payment_method_id=default_pm.provider_payment_method_id,
@@ -330,11 +330,12 @@ class TariffMixin:
remaining_days = max(0, (sub.end_date - now).days) if sub.end_date else 0
effective = float(sub.effective_monthly_price_rub or 0)
current_model = current_tariff.billing_model if current_tariff else "period"
default_currency = default_currency_key_for_settings(self.settings)
if current_model == "period" and target_tariff.billing_model == "period":
target_monthly = (
target_tariff.period_price(1, "rub")
or target_tariff.min_period_price_rub()
target_tariff.period_price(1, default_currency)
or target_tariff.min_period_price(default_currency)
or effective
or 1
)
@@ -354,11 +355,14 @@ class TariffMixin:
"remaining_days": remaining_days,
"recalc_days": max(0, days_after),
"paid_diff_rub": paid_diff,
"paid_diff": paid_diff,
"target_monthly_rub": float(target_monthly),
"target_monthly_price": float(target_monthly),
"currency": default_currency,
}
if current_model == "period" and target_tariff.billing_model == "traffic":
rub_per_gb = target_tariff.rub_per_gb_for_conversion()
rub_per_gb = target_tariff.currency_per_gb_for_conversion(default_currency)
remaining_value = remaining_days * (effective / 30) if effective else 0
converted_gb = math.floor(remaining_value / rub_per_gb) if rub_per_gb else 0
return {
@@ -366,9 +370,15 @@ class TariffMixin:
"remaining_days": remaining_days,
"converted_gb": max(0, converted_gb),
"rub_per_gb": rub_per_gb,
"currency_per_gb": rub_per_gb,
"currency": default_currency,
}
return {"mode": "traffic_to_period", "remaining_days": remaining_days}
return {
"mode": "traffic_to_period",
"remaining_days": remaining_days,
"currency": default_currency,
}
@staticmethod
def _aware_utc(value: Optional[datetime]) -> Optional[datetime]:
@@ -433,6 +443,7 @@ class TariffMixin:
credit = await self._hwid_conversion_credit(session, sub, at=now)
value_rub = float(credit.get("value_rub") or 0)
options["converted_hwid_value_rub"] = round(value_rub, 2)
options["converted_hwid_value"] = round(value_rub, 2)
options["convertible_hwid_purchase_ids"] = list(credit.get("purchase_ids") or [])
options["nonconverted_hwid_devices"] = int(credit.get("skipped_devices") or 0)
if value_rub <= 0: