feat: show optional serivce description before payment

This commit is contained in:
3252a8
2026-05-20 21:55:22 +03:00
parent d8a1da1f13
commit 7521f89ffd
51 changed files with 1199 additions and 499 deletions
+3
View File
@@ -93,6 +93,9 @@ WATA_ENABLED=False #
HELEKET_ENABLED=False # Turn on Heleket (crypto payments)
# Order of payment methods (top to bottom). Supported: severpay, wata, freekassa, platega, yookassa, stars, cryptopay, heleket
PAYMENT_METHODS_ORDER=severpay,wata,yookassa,cryptopay,freekassa,platega,stars,heleket
SUBSCRIPTION_PURCHASE_DESCRIPTION_ENABLED=True # Show subscription description before choosing a purchase/renewal period
SUBSCRIPTION_PURCHASE_DESCRIPTION_RU=Покупая или продлевая подписку, вы получаете доступ к VPN/прокси-сервису, который помогает защищать ваше соединение и поддерживать стабильный доступ к сети.
SUBSCRIPTION_PURCHASE_DESCRIPTION_EN=By buying or renewing a subscription, you get access to a VPN/proxy service that helps protect your connection and keep your access stable.
# Payment button presentation overrides (all optional; empty = provider defaults)
# Text supports per-language overrides: *_LABEL_RU and *_LABEL_EN. Legacy *_LABEL applies to all languages if per-language values are empty.
+23 -6
View File
@@ -133,6 +133,27 @@ SETTINGS_MANIFEST: List[SettingField] = [
"Порядок методов оплаты",
"Через запятую, например: severpay,freekassa,yookassa",
),
SettingField(
"SUBSCRIPTION_PURCHASE_DESCRIPTION_ENABLED",
"bool",
"pricing",
"Показывать описание подписки",
"Текст появится перед выбором срока покупки или продления.",
),
SettingField(
"SUBSCRIPTION_PURCHASE_DESCRIPTION_RU",
"text",
"pricing",
"Описание подписки (RU)",
"Русская версия текста на этапе оплаты.",
),
SettingField(
"SUBSCRIPTION_PURCHASE_DESCRIPTION_EN",
"text",
"pricing",
"Описание подписки (EN)",
"Английская версия текста на этапе оплаты.",
),
# ─── Payment providers (toggles) ───────────────────────────────
# Common
SettingField("STARS_ENABLED", "bool", "payments", "Telegram Stars", subsection="Общие"),
@@ -338,9 +359,7 @@ SETTINGS_MANIFEST: List[SettingField] = [
]
def _provider_field_to_setting_field(
spec: Any, manifest_field: Any
) -> SettingField:
def _provider_field_to_setting_field(spec: Any, manifest_field: Any) -> SettingField:
return SettingField(
key=manifest_field.key,
type=manifest_field.type,
@@ -445,9 +464,7 @@ def manifest_payload() -> List[dict]:
items: List[dict] = []
for field in aggregated_manifest():
auto_label_i18n_key = f"admin_settings_field_{field.key.lower()}_label"
auto_description_i18n_key = (
f"admin_settings_field_{field.key.lower()}_description"
)
auto_description_i18n_key = f"admin_settings_field_{field.key.lower()}_description"
default_value: Optional[str] = None
owner = find_manifest_owner(field.key)
-2
View File
@@ -679,5 +679,3 @@ async def _create_subscription_payment(
)
return _json_error(400, "payment_unavailable", "Payment method unavailable")
@@ -131,6 +131,7 @@ async def _build_user_payload(request: web.Request, user_id: int) -> Dict[str, A
"trial_duration_days": int(settings.TRIAL_DURATION_DAYS or 0),
"trial_traffic_limit_gb": float(settings.TRIAL_TRAFFIC_LIMIT_GB or 0),
"trial_traffic_strategy": getattr(settings, "TRIAL_TRAFFIC_STRATEGY", "NO_RESET"),
"subscription_purchase_description": settings.subscription_purchase_description(lang),
"email_auth_enabled": settings.email_auth_configured,
},
}
+41 -3
View File
@@ -94,6 +94,22 @@ def _tariff_purchase_text(tariff, current_lang: str, i18n: JsonI18n, settings: S
return f"{tariff.name(current_lang)}\n{tariff.description(current_lang)}".strip()
def _with_subscription_purchase_description(
text: str,
settings: Settings,
current_lang: str,
*,
include: bool,
) -> str:
if not include:
return text
description_resolver = getattr(settings, "subscription_purchase_description", None)
description = description_resolver(current_lang) if callable(description_resolver) else ""
if not description:
return text
return f"{description}\n\n{text}"
async def display_subscription_options(
event: Union[types.Message, types.CallbackQuery],
i18n_data: dict,
@@ -125,6 +141,12 @@ async def display_subscription_options(
if len(enabled_tariffs) == 1:
tariff = enabled_tariffs[0]
text_content = _tariff_purchase_text(tariff, current_lang, i18n, settings)
text_content = _with_subscription_purchase_description(
text_content,
settings,
current_lang,
include=tariff.billing_model == "period",
)
reply_markup = _tariff_purchase_markup(
tariff,
current_lang,
@@ -135,6 +157,12 @@ async def display_subscription_options(
)
else:
text_content = get_text("select_subscription_period")
text_content = _with_subscription_purchase_description(
text_content,
settings,
current_lang,
include=any(tariff.billing_model == "period" for tariff in enabled_tariffs),
)
reply_markup = get_tariff_catalog_keyboard(
enabled_tariffs,
current_lang,
@@ -174,6 +202,12 @@ async def display_subscription_options(
if traffic_mode
else get_text("select_subscription_period")
)
text_content = _with_subscription_purchase_description(
text_content,
settings,
current_lang,
include=not traffic_mode,
)
reply_markup = get_subscription_options_keyboard(
options,
currency_symbol_val,
@@ -248,6 +282,12 @@ async def select_tariff_callback(
callback_context=callback_context,
)
text = _tariff_purchase_text(tariff, current_lang, i18n, settings)
text = _with_subscription_purchase_description(
text,
settings,
current_lang,
include=tariff.billing_model == "period",
)
await callback.message.edit_text(text, reply_markup=markup)
await callback.answer()
@@ -284,9 +324,7 @@ async def select_tariff_period_callback(
current_lang,
i18n,
settings,
sale_mode=sale_mode_with_callback_context(
f"subscription@{tariff.key}", callback_context
),
sale_mode=sale_mode_with_callback_context(f"subscription@{tariff.key}", callback_context),
back_callback=f"tariff:select:{tariff.key}{callback_suffix_for_context(callback_context)}",
)
await callback.message.edit_text(get_text("choose_payment_method"), reply_markup=markup)
@@ -293,8 +293,7 @@ def get_subscription_options_keyboard(
currency_symbol=currency_symbol_val,
)
callback_data = (
f"subscribe_period:{months}"
f"{callback_suffix_for_context(callback_context)}"
f"subscribe_period:{months}{callback_suffix_for_context(callback_context)}"
)
builder.button(text=button_text, callback_data=callback_data)
builder.adjust(1)
@@ -332,9 +331,7 @@ def get_tariff_catalog_keyboard(
)
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
builder.row(
InlineKeyboardButton(
text=_(key="back_to_main_menu_button"), callback_data=back_callback
)
InlineKeyboardButton(text=_(key="back_to_main_menu_button"), callback_data=back_callback)
)
return builder.as_markup()
@@ -366,9 +363,7 @@ def get_tariff_periods_keyboard(
)
)
builder.row(
InlineKeyboardButton(
text=_(key="back_to_main_menu_button"), callback_data=back_callback
)
InlineKeyboardButton(text=_(key="back_to_main_menu_button"), callback_data=back_callback)
)
return builder.as_markup()
+1 -1
View File
@@ -18,9 +18,9 @@ from .registry import (
get_spec_presentation,
iter_provider_manifest_fields,
iter_provider_specs,
manifest_field_default,
iter_service_keys,
iter_unique_provider_routers,
manifest_field_default,
pending_statuses,
provider_emoji_map,
provider_label_map,
+4 -2
View File
@@ -2,7 +2,7 @@ from __future__ import annotations
import os
from dataclasses import dataclass, field
from typing import Any, Awaitable, Callable, List, Mapping, Optional, Sequence, Type
from typing import Any, Awaitable, Callable, Mapping, Optional, Sequence, Type
from pydantic_settings import BaseSettings, SettingsConfigDict
@@ -65,7 +65,9 @@ class ProviderManifestField:
choices: Optional[Sequence[tuple[str, str]]] = None
subsection: Optional[str] = None
target: str = "config" # "config" or "presentation" — which bundle slot it writes to
attr: Optional[str] = None # attribute name on the target model; defaults to key without env_prefix
attr: Optional[str] = (
None # attribute name on the target model; defaults to key without env_prefix
)
@dataclass(frozen=True)
+93 -33
View File
@@ -349,10 +349,7 @@ async def pay_crypto_callback_handler(
await notify_callback_parse_error(callback, translator)
return
if (
not cryptopay_service
or not getattr(cryptopay_service, "configured", False)
):
if not cryptopay_service or not getattr(cryptopay_service, "configured", False):
await notify_service_unavailable(callback, translator)
return
@@ -390,7 +387,11 @@ async def pay_crypto_callback_handler(
def create_service(ctx: ServiceFactoryContext) -> CryptoPayService:
bundle = ctx.config_for("cryptopay_service")
config = bundle.config if bundle and isinstance(bundle.config, CryptoPayConfig) else CryptoPayConfig()
config = (
bundle.config
if bundle and isinstance(bundle.config, CryptoPayConfig)
else CryptoPayConfig()
)
return CryptoPayService(
bot=ctx.bot,
settings=ctx.settings,
@@ -422,40 +423,99 @@ async def create_webapp_payment(ctx: WebAppPaymentContext) -> web.Response:
_PRESENTATION_MANIFEST = tuple(
ProviderManifestField(
key=key, type=type_, label=label, description=description,
placeholder=placeholder, subsection="CryptoPay",
target="presentation", attr=attr,
key=key,
type=type_,
label=label,
description=description,
placeholder=placeholder,
subsection="CryptoPay",
target="presentation",
attr=attr,
)
for key, type_, label, description, placeholder, attr in (
("PAYMENT_CRYPTOPAY_WEBAPP_LABEL_RU", "string", "WebApp button text (RU)",
"Custom Russian text shown in the Web App payment method button.", "", "WEBAPP_LABEL_RU"),
("PAYMENT_CRYPTOPAY_WEBAPP_LABEL_EN", "string", "WebApp button text (EN)",
"Custom English text shown in the Web App payment method button.", "", "WEBAPP_LABEL_EN"),
("PAYMENT_CRYPTOPAY_WEBAPP_ICON", "icon", "WebApp button icon",
"Lucide icon name rendered inside the Web App payment method button.",
"Bitcoin", "WEBAPP_ICON"),
("PAYMENT_CRYPTOPAY_TELEGRAM_LABEL_RU", "string", "Telegram button text (RU)",
"Custom Russian text shown in Telegram bot payment buttons.", "", "TELEGRAM_LABEL_RU"),
("PAYMENT_CRYPTOPAY_TELEGRAM_LABEL_EN", "string", "Telegram button text (EN)",
"Custom English text shown in Telegram bot payment buttons.", "", "TELEGRAM_LABEL_EN"),
("PAYMENT_CRYPTOPAY_TELEGRAM_EMOJI", "string", "Telegram button emoji",
"Emoji prepended to the Telegram bot payment button when customized.",
"", "TELEGRAM_EMOJI"),
(
"PAYMENT_CRYPTOPAY_WEBAPP_LABEL_RU",
"string",
"WebApp button text (RU)",
"Custom Russian text shown in the Web App payment method button.",
"",
"WEBAPP_LABEL_RU",
),
(
"PAYMENT_CRYPTOPAY_WEBAPP_LABEL_EN",
"string",
"WebApp button text (EN)",
"Custom English text shown in the Web App payment method button.",
"",
"WEBAPP_LABEL_EN",
),
(
"PAYMENT_CRYPTOPAY_WEBAPP_ICON",
"icon",
"WebApp button icon",
"Lucide icon name rendered inside the Web App payment method button.",
"Bitcoin",
"WEBAPP_ICON",
),
(
"PAYMENT_CRYPTOPAY_TELEGRAM_LABEL_RU",
"string",
"Telegram button text (RU)",
"Custom Russian text shown in Telegram bot payment buttons.",
"",
"TELEGRAM_LABEL_RU",
),
(
"PAYMENT_CRYPTOPAY_TELEGRAM_LABEL_EN",
"string",
"Telegram button text (EN)",
"Custom English text shown in Telegram bot payment buttons.",
"",
"TELEGRAM_LABEL_EN",
),
(
"PAYMENT_CRYPTOPAY_TELEGRAM_EMOJI",
"string",
"Telegram button emoji",
"Emoji prepended to the Telegram bot payment button when customized.",
"",
"TELEGRAM_EMOJI",
),
)
)
_CONFIG_MANIFEST = (
ProviderManifestField("CRYPTOPAY_ENABLED", "bool", "Включена",
subsection="CryptoPay", attr="ENABLED"),
ProviderManifestField("CRYPTOPAY_TOKEN", "string", "Token",
subsection="CryptoPay", secret=True, attr="TOKEN"),
ProviderManifestField("CRYPTOPAY_NETWORK", "string", "Network",
placeholder="mainnet", subsection="CryptoPay", attr="NETWORK"),
ProviderManifestField("CRYPTOPAY_CURRENCY_TYPE", "string", "Currency type",
description="fiat or crypto.",
placeholder="fiat", subsection="CryptoPay", attr="CURRENCY_TYPE"),
ProviderManifestField("CRYPTOPAY_ASSET", "string", "Asset",
placeholder="RUB", subsection="CryptoPay", attr="ASSET"),
ProviderManifestField(
"CRYPTOPAY_ENABLED", "bool", "Включена", subsection="CryptoPay", attr="ENABLED"
),
ProviderManifestField(
"CRYPTOPAY_TOKEN", "string", "Token", subsection="CryptoPay", secret=True, attr="TOKEN"
),
ProviderManifestField(
"CRYPTOPAY_NETWORK",
"string",
"Network",
placeholder="mainnet",
subsection="CryptoPay",
attr="NETWORK",
),
ProviderManifestField(
"CRYPTOPAY_CURRENCY_TYPE",
"string",
"Currency type",
description="fiat or crypto.",
placeholder="fiat",
subsection="CryptoPay",
attr="CURRENCY_TYPE",
),
ProviderManifestField(
"CRYPTOPAY_ASSET",
"string",
"Asset",
placeholder="RUB",
subsection="CryptoPay",
attr="ASSET",
),
)
+133 -46
View File
@@ -71,9 +71,7 @@ class FreeKassaConfig(ProviderEnvConfig):
API_KEY: Optional[str] = None
PAYMENT_IP: Optional[str] = None
PAYMENT_METHOD_ID: Optional[int] = None
TRUSTED_IPS: str = Field(
default="168.119.157.136,168.119.60.227,178.154.197.79,51.250.54.238"
)
TRUSTED_IPS: str = Field(default="168.119.157.136,168.119.60.227,178.154.197.79,51.250.54.238")
@field_validator("PAYMENT_METHOD_ID", mode="before")
@classmethod
@@ -85,7 +83,11 @@ class FreeKassaConfig(ProviderEnvConfig):
return v
@field_validator(
"MERCHANT_ID", "FIRST_SECRET", "SECOND_SECRET", "API_KEY", "PAYMENT_IP",
"MERCHANT_ID",
"FIRST_SECRET",
"SECOND_SECRET",
"API_KEY",
"PAYMENT_IP",
mode="before",
)
@classmethod
@@ -505,8 +507,10 @@ async def pay_fk_callback_handler(
provider_identifier = first_value(response_data, "orderHash", "orderId")
lead_text: Optional[str] = None
if success and location:
order_id_display = first_value(response_data, "orderId") or provider_identifier or str(
payment_record.payment_id
order_id_display = (
first_value(response_data, "orderId")
or provider_identifier
or str(payment_record.payment_id)
)
lead_text = translator(
"free_kassa_order_info",
@@ -532,7 +536,11 @@ async def pay_fk_callback_handler(
def create_service(ctx: ServiceFactoryContext) -> FreeKassaService:
bundle = ctx.config_for("freekassa_service")
config = bundle.config if bundle and isinstance(bundle.config, FreeKassaConfig) else FreeKassaConfig()
config = (
bundle.config
if bundle and isinstance(bundle.config, FreeKassaConfig)
else FreeKassaConfig()
)
return FreeKassaService(
bot=ctx.bot,
settings=ctx.settings,
@@ -584,51 +592,130 @@ async def create_webapp_payment(ctx: WebAppPaymentContext) -> web.Response:
_PRESENTATION_MANIFEST = tuple(
ProviderManifestField(
key=key, type=type_, label=label, description=description,
placeholder=placeholder, subsection="FreeKassa",
target="presentation", attr=attr,
key=key,
type=type_,
label=label,
description=description,
placeholder=placeholder,
subsection="FreeKassa",
target="presentation",
attr=attr,
)
for key, type_, label, description, placeholder, attr in (
("PAYMENT_FREEKASSA_WEBAPP_LABEL_RU", "string", "WebApp button text (RU)",
"Custom Russian text shown in the Web App payment method button.", "", "WEBAPP_LABEL_RU"),
("PAYMENT_FREEKASSA_WEBAPP_LABEL_EN", "string", "WebApp button text (EN)",
"Custom English text shown in the Web App payment method button.", "", "WEBAPP_LABEL_EN"),
("PAYMENT_FREEKASSA_WEBAPP_ICON", "icon", "WebApp button icon",
"Lucide icon name rendered inside the Web App payment method button.",
"Smartphone", "WEBAPP_ICON"),
("PAYMENT_FREEKASSA_TELEGRAM_LABEL_RU", "string", "Telegram button text (RU)",
"Custom Russian text shown in Telegram bot payment buttons.", "", "TELEGRAM_LABEL_RU"),
("PAYMENT_FREEKASSA_TELEGRAM_LABEL_EN", "string", "Telegram button text (EN)",
"Custom English text shown in Telegram bot payment buttons.", "", "TELEGRAM_LABEL_EN"),
("PAYMENT_FREEKASSA_TELEGRAM_EMOJI", "string", "Telegram button emoji",
"Emoji prepended to the Telegram bot payment button when customized.",
"📱", "TELEGRAM_EMOJI"),
(
"PAYMENT_FREEKASSA_WEBAPP_LABEL_RU",
"string",
"WebApp button text (RU)",
"Custom Russian text shown in the Web App payment method button.",
"",
"WEBAPP_LABEL_RU",
),
(
"PAYMENT_FREEKASSA_WEBAPP_LABEL_EN",
"string",
"WebApp button text (EN)",
"Custom English text shown in the Web App payment method button.",
"",
"WEBAPP_LABEL_EN",
),
(
"PAYMENT_FREEKASSA_WEBAPP_ICON",
"icon",
"WebApp button icon",
"Lucide icon name rendered inside the Web App payment method button.",
"Smartphone",
"WEBAPP_ICON",
),
(
"PAYMENT_FREEKASSA_TELEGRAM_LABEL_RU",
"string",
"Telegram button text (RU)",
"Custom Russian text shown in Telegram bot payment buttons.",
"",
"TELEGRAM_LABEL_RU",
),
(
"PAYMENT_FREEKASSA_TELEGRAM_LABEL_EN",
"string",
"Telegram button text (EN)",
"Custom English text shown in Telegram bot payment buttons.",
"",
"TELEGRAM_LABEL_EN",
),
(
"PAYMENT_FREEKASSA_TELEGRAM_EMOJI",
"string",
"Telegram button emoji",
"Emoji prepended to the Telegram bot payment button when customized.",
"📱",
"TELEGRAM_EMOJI",
),
)
)
_CONFIG_MANIFEST = (
ProviderManifestField("FREEKASSA_ENABLED", "bool", "Включена",
subsection="FreeKassa", attr="ENABLED"),
ProviderManifestField("FREEKASSA_MERCHANT_ID", "string", "Merchant ID",
subsection="FreeKassa", attr="MERCHANT_ID"),
ProviderManifestField("FREEKASSA_FIRST_SECRET", "string", "First secret",
subsection="FreeKassa", secret=True, attr="FIRST_SECRET"),
ProviderManifestField("FREEKASSA_SECOND_SECRET", "string", "Second secret",
subsection="FreeKassa", secret=True, attr="SECOND_SECRET"),
ProviderManifestField("FREEKASSA_API_KEY", "string", "API key",
subsection="FreeKassa", secret=True, attr="API_KEY"),
ProviderManifestField("FREEKASSA_PAYMENT_URL", "url", "Payment URL",
placeholder="https://pay.freekassa.ru/",
subsection="FreeKassa", attr="PAYMENT_URL"),
ProviderManifestField("FREEKASSA_PAYMENT_METHOD_ID", "int", "Payment method ID",
description="See https://merchant.freekassa.net/settings/currencies",
subsection="FreeKassa", attr="PAYMENT_METHOD_ID"),
ProviderManifestField("FREEKASSA_PAYMENT_IP", "string", "Server IP",
description="Public IP address reported to FreeKassa.",
subsection="FreeKassa", attr="PAYMENT_IP"),
ProviderManifestField("FREEKASSA_TRUSTED_IPS", "string", "Trusted IPs",
description="Comma-separated IP addresses accepted for FreeKassa webhooks.",
subsection="FreeKassa", attr="TRUSTED_IPS"),
ProviderManifestField(
"FREEKASSA_ENABLED", "bool", "Включена", subsection="FreeKassa", attr="ENABLED"
),
ProviderManifestField(
"FREEKASSA_MERCHANT_ID", "string", "Merchant ID", subsection="FreeKassa", attr="MERCHANT_ID"
),
ProviderManifestField(
"FREEKASSA_FIRST_SECRET",
"string",
"First secret",
subsection="FreeKassa",
secret=True,
attr="FIRST_SECRET",
),
ProviderManifestField(
"FREEKASSA_SECOND_SECRET",
"string",
"Second secret",
subsection="FreeKassa",
secret=True,
attr="SECOND_SECRET",
),
ProviderManifestField(
"FREEKASSA_API_KEY",
"string",
"API key",
subsection="FreeKassa",
secret=True,
attr="API_KEY",
),
ProviderManifestField(
"FREEKASSA_PAYMENT_URL",
"url",
"Payment URL",
placeholder="https://pay.freekassa.ru/",
subsection="FreeKassa",
attr="PAYMENT_URL",
),
ProviderManifestField(
"FREEKASSA_PAYMENT_METHOD_ID",
"int",
"Payment method ID",
description="See https://merchant.freekassa.net/settings/currencies",
subsection="FreeKassa",
attr="PAYMENT_METHOD_ID",
),
ProviderManifestField(
"FREEKASSA_PAYMENT_IP",
"string",
"Server IP",
description="Public IP address reported to FreeKassa.",
subsection="FreeKassa",
attr="PAYMENT_IP",
),
ProviderManifestField(
"FREEKASSA_TRUSTED_IPS",
"string",
"Trusted IPs",
description="Comma-separated IP addresses accepted for FreeKassa webhooks.",
subsection="FreeKassa",
attr="TRUSTED_IPS",
),
)
+140 -42
View File
@@ -93,7 +93,12 @@ class HeleketConfig(ProviderEnvConfig):
return min(43200, max(300, value))
@field_validator(
"MERCHANT_ID", "API_KEY", "TO_CURRENCY", "NETWORK", "RETURN_URL", "SUCCESS_URL",
"MERCHANT_ID",
"API_KEY",
"TO_CURRENCY",
"NETWORK",
"RETURN_URL",
"SUCCESS_URL",
mode="before",
)
@classmethod
@@ -644,7 +649,9 @@ async def heleket_webhook_route(request: web.Request) -> web.Response:
def create_service(ctx: ServiceFactoryContext) -> HeleketService:
bundle = ctx.config_for("heleket_service")
config = bundle.config if bundle and isinstance(bundle.config, HeleketConfig) else HeleketConfig()
config = (
bundle.config if bundle and isinstance(bundle.config, HeleketConfig) else HeleketConfig()
)
return HeleketService(
bot=ctx.bot,
settings=ctx.settings,
@@ -669,51 +676,142 @@ _PRESENTATION_MANIFEST = tuple(
attr=attr,
)
for key, type_, label, description, placeholder, attr in (
("PAYMENT_HELEKET_WEBAPP_LABEL_RU", "string", "WebApp button text (RU)",
"Custom Russian text shown in the Web App payment method button.", "", "WEBAPP_LABEL_RU"),
("PAYMENT_HELEKET_WEBAPP_LABEL_EN", "string", "WebApp button text (EN)",
"Custom English text shown in the Web App payment method button.", "", "WEBAPP_LABEL_EN"),
("PAYMENT_HELEKET_WEBAPP_ICON", "icon", "WebApp button icon",
"Lucide icon name rendered inside the Web App payment method button.", "Bitcoin", "WEBAPP_ICON"),
("PAYMENT_HELEKET_TELEGRAM_LABEL_RU", "string", "Telegram button text (RU)",
"Custom Russian text shown in Telegram bot payment buttons.", "", "TELEGRAM_LABEL_RU"),
("PAYMENT_HELEKET_TELEGRAM_LABEL_EN", "string", "Telegram button text (EN)",
"Custom English text shown in Telegram bot payment buttons.", "", "TELEGRAM_LABEL_EN"),
("PAYMENT_HELEKET_TELEGRAM_EMOJI", "string", "Telegram button emoji",
"Emoji prepended to the Telegram bot payment button when customized.", "🪙", "TELEGRAM_EMOJI"),
(
"PAYMENT_HELEKET_WEBAPP_LABEL_RU",
"string",
"WebApp button text (RU)",
"Custom Russian text shown in the Web App payment method button.",
"",
"WEBAPP_LABEL_RU",
),
(
"PAYMENT_HELEKET_WEBAPP_LABEL_EN",
"string",
"WebApp button text (EN)",
"Custom English text shown in the Web App payment method button.",
"",
"WEBAPP_LABEL_EN",
),
(
"PAYMENT_HELEKET_WEBAPP_ICON",
"icon",
"WebApp button icon",
"Lucide icon name rendered inside the Web App payment method button.",
"Bitcoin",
"WEBAPP_ICON",
),
(
"PAYMENT_HELEKET_TELEGRAM_LABEL_RU",
"string",
"Telegram button text (RU)",
"Custom Russian text shown in Telegram bot payment buttons.",
"",
"TELEGRAM_LABEL_RU",
),
(
"PAYMENT_HELEKET_TELEGRAM_LABEL_EN",
"string",
"Telegram button text (EN)",
"Custom English text shown in Telegram bot payment buttons.",
"",
"TELEGRAM_LABEL_EN",
),
(
"PAYMENT_HELEKET_TELEGRAM_EMOJI",
"string",
"Telegram button emoji",
"Emoji prepended to the Telegram bot payment button when customized.",
"🪙",
"TELEGRAM_EMOJI",
),
)
)
_CONFIG_MANIFEST = (
ProviderManifestField("HELEKET_ENABLED", "bool", "Enabled", subsection="Heleket", attr="ENABLED"),
ProviderManifestField("HELEKET_MERCHANT_ID", "string", "Merchant ID", subsection="Heleket",
secret=True, attr="MERCHANT_ID"),
ProviderManifestField("HELEKET_API_KEY", "string", "Payment API key", subsection="Heleket",
secret=True, attr="API_KEY"),
ProviderManifestField("HELEKET_BASE_URL", "url", "Base URL",
placeholder="https://api.heleket.com", subsection="Heleket", attr="BASE_URL"),
ProviderManifestField("HELEKET_CURRENCY", "string", "Invoice currency",
description="Fiat or crypto code (RUB, USD, USDT).",
placeholder="RUB", subsection="Heleket", attr="CURRENCY"),
ProviderManifestField("HELEKET_TO_CURRENCY", "string", "Target crypto",
description="Optional target cryptocurrency for conversion.",
subsection="Heleket", attr="TO_CURRENCY"),
ProviderManifestField("HELEKET_NETWORK", "string", "Blockchain network",
description="Optional blockchain network code (tron, bsc, eth).",
subsection="Heleket", attr="NETWORK"),
ProviderManifestField("HELEKET_RETURN_URL", "url", "Return URL", subsection="Heleket",
attr="RETURN_URL"),
ProviderManifestField("HELEKET_SUCCESS_URL", "url", "Success URL", subsection="Heleket",
attr="SUCCESS_URL"),
ProviderManifestField("HELEKET_LIFETIME_SECONDS", "int", "Invoice lifetime (seconds)",
description="300..43200; Heleket defaults to 3600.",
subsection="Heleket", min=300, max=43200, attr="LIFETIME_SECONDS"),
ProviderManifestField("HELEKET_VERIFY_WEBHOOK_SIGNATURE", "bool", "Verify webhook signature",
subsection="Heleket", attr="VERIFY_WEBHOOK_SIGNATURE"),
ProviderManifestField("HELEKET_TRUSTED_IPS", "string", "Trusted IPs",
description="Comma-separated IP addresses accepted for Heleket webhooks.",
subsection="Heleket", attr="TRUSTED_IPS"),
ProviderManifestField(
"HELEKET_ENABLED", "bool", "Enabled", subsection="Heleket", attr="ENABLED"
),
ProviderManifestField(
"HELEKET_MERCHANT_ID",
"string",
"Merchant ID",
subsection="Heleket",
secret=True,
attr="MERCHANT_ID",
),
ProviderManifestField(
"HELEKET_API_KEY",
"string",
"Payment API key",
subsection="Heleket",
secret=True,
attr="API_KEY",
),
ProviderManifestField(
"HELEKET_BASE_URL",
"url",
"Base URL",
placeholder="https://api.heleket.com",
subsection="Heleket",
attr="BASE_URL",
),
ProviderManifestField(
"HELEKET_CURRENCY",
"string",
"Invoice currency",
description="Fiat or crypto code (RUB, USD, USDT).",
placeholder="RUB",
subsection="Heleket",
attr="CURRENCY",
),
ProviderManifestField(
"HELEKET_TO_CURRENCY",
"string",
"Target crypto",
description="Optional target cryptocurrency for conversion.",
subsection="Heleket",
attr="TO_CURRENCY",
),
ProviderManifestField(
"HELEKET_NETWORK",
"string",
"Blockchain network",
description="Optional blockchain network code (tron, bsc, eth).",
subsection="Heleket",
attr="NETWORK",
),
ProviderManifestField(
"HELEKET_RETURN_URL", "url", "Return URL", subsection="Heleket", attr="RETURN_URL"
),
ProviderManifestField(
"HELEKET_SUCCESS_URL", "url", "Success URL", subsection="Heleket", attr="SUCCESS_URL"
),
ProviderManifestField(
"HELEKET_LIFETIME_SECONDS",
"int",
"Invoice lifetime (seconds)",
description="300..43200; Heleket defaults to 3600.",
subsection="Heleket",
min=300,
max=43200,
attr="LIFETIME_SECONDS",
),
ProviderManifestField(
"HELEKET_VERIFY_WEBHOOK_SIGNATURE",
"bool",
"Verify webhook signature",
subsection="Heleket",
attr="VERIFY_WEBHOOK_SIGNATURE",
),
ProviderManifestField(
"HELEKET_TRUSTED_IPS",
"string",
"Trusted IPs",
description="Comma-separated IP addresses accepted for Heleket webhooks.",
subsection="Heleket",
attr="TRUSTED_IPS",
),
)
+115 -51
View File
@@ -429,7 +429,11 @@ async def pay_platega_callback_handler(
return
callback_prefix, _, _ = (callback.data or "").partition(":")
variant = _resolve_platega_variant(callback_prefix, platega_service.config) if platega_service else None
variant = (
_resolve_platega_variant(callback_prefix, platega_service.config)
if platega_service
else None
)
if variant is None:
await safe_callback_answer(callback)
return
@@ -513,7 +517,9 @@ async def pay_platega_callback_handler(
def create_service(ctx: ServiceFactoryContext) -> PlategaService:
bundle = ctx.config_for("platega_service")
config = bundle.config if bundle and isinstance(bundle.config, PlategaConfig) else PlategaConfig()
config = (
bundle.config if bundle and isinstance(bundle.config, PlategaConfig) else PlategaConfig()
)
return PlategaService(
bot=ctx.bot,
settings=ctx.settings,
@@ -613,52 +619,109 @@ def _platega_presentation_manifest(subsection: str, default_icon: str, prefix: s
attr=attr,
)
for suffix_key, type_, label, description, placeholder, attr in (
("WEBAPP_LABEL_RU", "string", "WebApp button text (RU)",
"Custom Russian text shown in the Web App payment method button.",
"", "WEBAPP_LABEL_RU"),
("WEBAPP_LABEL_EN", "string", "WebApp button text (EN)",
"Custom English text shown in the Web App payment method button.",
"", "WEBAPP_LABEL_EN"),
("WEBAPP_ICON", "icon", "WebApp button icon",
"Lucide icon name rendered inside the Web App payment method button.",
default_icon, "WEBAPP_ICON"),
("TELEGRAM_LABEL_RU", "string", "Telegram button text (RU)",
"Custom Russian text shown in Telegram bot payment buttons.",
"", "TELEGRAM_LABEL_RU"),
("TELEGRAM_LABEL_EN", "string", "Telegram button text (EN)",
"Custom English text shown in Telegram bot payment buttons.",
"", "TELEGRAM_LABEL_EN"),
("TELEGRAM_EMOJI", "string", "Telegram button emoji",
"Emoji prepended to the Telegram bot payment button when customized.",
"", "TELEGRAM_EMOJI"),
(
"WEBAPP_LABEL_RU",
"string",
"WebApp button text (RU)",
"Custom Russian text shown in the Web App payment method button.",
"",
"WEBAPP_LABEL_RU",
),
(
"WEBAPP_LABEL_EN",
"string",
"WebApp button text (EN)",
"Custom English text shown in the Web App payment method button.",
"",
"WEBAPP_LABEL_EN",
),
(
"WEBAPP_ICON",
"icon",
"WebApp button icon",
"Lucide icon name rendered inside the Web App payment method button.",
default_icon,
"WEBAPP_ICON",
),
(
"TELEGRAM_LABEL_RU",
"string",
"Telegram button text (RU)",
"Custom Russian text shown in Telegram bot payment buttons.",
"",
"TELEGRAM_LABEL_RU",
),
(
"TELEGRAM_LABEL_EN",
"string",
"Telegram button text (EN)",
"Custom English text shown in Telegram bot payment buttons.",
"",
"TELEGRAM_LABEL_EN",
),
(
"TELEGRAM_EMOJI",
"string",
"Telegram button emoji",
"Emoji prepended to the Telegram bot payment button when customized.",
"",
"TELEGRAM_EMOJI",
),
)
)
_CONFIG_MANIFEST = (
ProviderManifestField("PLATEGA_ENABLED", "bool", "Включена",
subsection="Platega", attr="ENABLED"),
ProviderManifestField("PLATEGA_BASE_URL", "url", "Base URL",
placeholder="https://app.platega.io",
subsection="Platega", attr="BASE_URL"),
ProviderManifestField("PLATEGA_MERCHANT_ID", "string", "Merchant ID",
subsection="Platega", attr="MERCHANT_ID"),
ProviderManifestField("PLATEGA_SECRET", "string", "Secret",
subsection="Platega", secret=True, attr="SECRET"),
ProviderManifestField("PLATEGA_PAYMENT_METHOD", "int", "Метод оплаты (legacy)",
subsection="Platega", attr="PAYMENT_METHOD"),
ProviderManifestField("PLATEGA_SBP_ENABLED", "bool", "SBP-кнопка",
subsection="Platega", attr="SBP_ENABLED"),
ProviderManifestField("PLATEGA_SBP_METHOD", "int", "SBP method ID",
subsection="Platega", attr="SBP_METHOD"),
ProviderManifestField("PLATEGA_CRYPTO_ENABLED", "bool", "Crypto-кнопка",
subsection="Platega", attr="CRYPTO_ENABLED"),
ProviderManifestField("PLATEGA_CRYPTO_METHOD", "int", "Crypto method ID",
subsection="Platega", attr="CRYPTO_METHOD"),
ProviderManifestField("PLATEGA_RETURN_URL", "url", "Return URL",
subsection="Platega", attr="RETURN_URL"),
ProviderManifestField("PLATEGA_FAILED_URL", "url", "Failed URL",
subsection="Platega", attr="FAILED_URL"),
ProviderManifestField(
"PLATEGA_ENABLED", "bool", "Включена", subsection="Platega", attr="ENABLED"
),
ProviderManifestField(
"PLATEGA_BASE_URL",
"url",
"Base URL",
placeholder="https://app.platega.io",
subsection="Platega",
attr="BASE_URL",
),
ProviderManifestField(
"PLATEGA_MERCHANT_ID", "string", "Merchant ID", subsection="Platega", attr="MERCHANT_ID"
),
ProviderManifestField(
"PLATEGA_SECRET", "string", "Secret", subsection="Platega", secret=True, attr="SECRET"
),
ProviderManifestField(
"PLATEGA_PAYMENT_METHOD",
"int",
"Метод оплаты (legacy)",
subsection="Platega",
attr="PAYMENT_METHOD",
),
ProviderManifestField(
"PLATEGA_SBP_ENABLED", "bool", "SBP-кнопка", subsection="Platega", attr="SBP_ENABLED"
),
ProviderManifestField(
"PLATEGA_SBP_METHOD", "int", "SBP method ID", subsection="Platega", attr="SBP_METHOD"
),
ProviderManifestField(
"PLATEGA_CRYPTO_ENABLED",
"bool",
"Crypto-кнопка",
subsection="Platega",
attr="CRYPTO_ENABLED",
),
ProviderManifestField(
"PLATEGA_CRYPTO_METHOD",
"int",
"Crypto method ID",
subsection="Platega",
attr="CRYPTO_METHOD",
),
ProviderManifestField(
"PLATEGA_RETURN_URL", "url", "Return URL", subsection="Platega", attr="RETURN_URL"
),
ProviderManifestField(
"PLATEGA_FAILED_URL", "url", "Failed URL", subsection="Platega", attr="FAILED_URL"
),
)
@@ -672,7 +735,9 @@ SBP_SPEC = PaymentProviderSpec(
telegram_labels={"ru": "Оплата через СБП", "en": "Pay via SBP"},
telegram_emoji="🏦",
pending_status="pending_platega",
enabled=lambda config: bool(getattr(config, "ENABLED", False) and getattr(config, "SBP_ENABLED", False)),
enabled=lambda config: bool(
getattr(config, "ENABLED", False) and getattr(config, "SBP_ENABLED", False)
),
service_key="platega_service",
callback_prefix="pay_platega_sbp",
aliases=("platega",),
@@ -683,9 +748,8 @@ SBP_SPEC = PaymentProviderSpec(
create_webapp_payment=create_sbp_webapp_payment,
config_class=PlategaConfig,
presentation_class=PlategaSbpPresentation,
manifest_fields=_CONFIG_MANIFEST + _platega_presentation_manifest(
"Platega SBP", "CreditCard", "PLATEGA_SBP"
),
manifest_fields=_CONFIG_MANIFEST
+ _platega_presentation_manifest("Platega SBP", "CreditCard", "PLATEGA_SBP"),
)
CRYPTO_SPEC = PaymentProviderSpec(
@@ -700,15 +764,15 @@ CRYPTO_SPEC = PaymentProviderSpec(
pending_status="pending_platega",
# Uses the same PlategaConfig as SBP_SPEC (shared service_key); enable
# flag combines the global PLATEGA_ENABLED with the per-button toggle.
enabled=lambda config: bool(getattr(config, "ENABLED", False) and getattr(config, "CRYPTO_ENABLED", False)),
enabled=lambda config: bool(
getattr(config, "ENABLED", False) and getattr(config, "CRYPTO_ENABLED", False)
),
service_key="platega_service",
callback_prefix="pay_platega_crypto",
create_webapp_payment=create_crypto_webapp_payment,
config_class=PlategaConfig,
presentation_class=PlategaCryptoPresentation,
manifest_fields=_platega_presentation_manifest(
"Platega Crypto", "Bitcoin", "PLATEGA_CRYPTO"
),
manifest_fields=_platega_presentation_manifest("Platega Crypto", "Bitcoin", "PLATEGA_CRYPTO"),
)
SPECS = (SBP_SPEC, CRYPTO_SPEC)
+3 -12
View File
@@ -211,10 +211,7 @@ def resolve_provider_presentation(
or _localized_default(spec.webapp_labels, lang, spec.webapp_label)
or spec.label
)
webapp_icon = (
_bare_setting_value(settings, spec, "WEBAPP_ICON")
or spec.webapp_icon
)
webapp_icon = _bare_setting_value(settings, spec, "WEBAPP_ICON") or spec.webapp_icon
telegram_label_override = _localized_setting_value(
settings,
spec,
@@ -351,15 +348,9 @@ def manifest_field_default(
return None
attr = manifest_field.attr or manifest_field.key
if attr == "WEBAPP_LABEL_RU":
return (
_localized_default(spec.webapp_labels, "ru", spec.webapp_label)
or spec.label
)
return _localized_default(spec.webapp_labels, "ru", spec.webapp_label) or spec.label
if attr == "WEBAPP_LABEL_EN":
return (
_localized_default(spec.webapp_labels, "en", spec.webapp_label)
or spec.label
)
return _localized_default(spec.webapp_labels, "en", spec.webapp_label) or spec.label
if attr == "WEBAPP_ICON":
return spec.webapp_icon
if attr == "TELEGRAM_LABEL_RU":
+86 -30
View File
@@ -453,7 +453,9 @@ async def pay_severpay_callback_handler(
def create_service(ctx: ServiceFactoryContext) -> SeverPayService:
bundle = ctx.config_for("severpay_service")
config = bundle.config if bundle and isinstance(bundle.config, SeverPayConfig) else SeverPayConfig()
config = (
bundle.config if bundle and isinstance(bundle.config, SeverPayConfig) else SeverPayConfig()
)
return SeverPayService(
bot=ctx.bot,
settings=ctx.settings,
@@ -506,42 +508,96 @@ async def create_webapp_payment(ctx: WebAppPaymentContext) -> web.Response:
_PRESENTATION_MANIFEST = tuple(
ProviderManifestField(
key=key, type=type_, label=label, description=description,
placeholder=placeholder, subsection="SeverPay",
target="presentation", attr=attr,
key=key,
type=type_,
label=label,
description=description,
placeholder=placeholder,
subsection="SeverPay",
target="presentation",
attr=attr,
)
for key, type_, label, description, placeholder, attr in (
("PAYMENT_SEVERPAY_WEBAPP_LABEL_RU", "string", "WebApp button text (RU)",
"Custom Russian text shown in the Web App payment method button.", "", "WEBAPP_LABEL_RU"),
("PAYMENT_SEVERPAY_WEBAPP_LABEL_EN", "string", "WebApp button text (EN)",
"Custom English text shown in the Web App payment method button.", "", "WEBAPP_LABEL_EN"),
("PAYMENT_SEVERPAY_WEBAPP_ICON", "icon", "WebApp button icon",
"Lucide icon name rendered inside the Web App payment method button.",
"CreditCard", "WEBAPP_ICON"),
("PAYMENT_SEVERPAY_TELEGRAM_LABEL_RU", "string", "Telegram button text (RU)",
"Custom Russian text shown in Telegram bot payment buttons.", "", "TELEGRAM_LABEL_RU"),
("PAYMENT_SEVERPAY_TELEGRAM_LABEL_EN", "string", "Telegram button text (EN)",
"Custom English text shown in Telegram bot payment buttons.", "", "TELEGRAM_LABEL_EN"),
("PAYMENT_SEVERPAY_TELEGRAM_EMOJI", "string", "Telegram button emoji",
"Emoji prepended to the Telegram bot payment button when customized.",
"💳", "TELEGRAM_EMOJI"),
(
"PAYMENT_SEVERPAY_WEBAPP_LABEL_RU",
"string",
"WebApp button text (RU)",
"Custom Russian text shown in the Web App payment method button.",
"",
"WEBAPP_LABEL_RU",
),
(
"PAYMENT_SEVERPAY_WEBAPP_LABEL_EN",
"string",
"WebApp button text (EN)",
"Custom English text shown in the Web App payment method button.",
"",
"WEBAPP_LABEL_EN",
),
(
"PAYMENT_SEVERPAY_WEBAPP_ICON",
"icon",
"WebApp button icon",
"Lucide icon name rendered inside the Web App payment method button.",
"CreditCard",
"WEBAPP_ICON",
),
(
"PAYMENT_SEVERPAY_TELEGRAM_LABEL_RU",
"string",
"Telegram button text (RU)",
"Custom Russian text shown in Telegram bot payment buttons.",
"",
"TELEGRAM_LABEL_RU",
),
(
"PAYMENT_SEVERPAY_TELEGRAM_LABEL_EN",
"string",
"Telegram button text (EN)",
"Custom English text shown in Telegram bot payment buttons.",
"",
"TELEGRAM_LABEL_EN",
),
(
"PAYMENT_SEVERPAY_TELEGRAM_EMOJI",
"string",
"Telegram button emoji",
"Emoji prepended to the Telegram bot payment button when customized.",
"💳",
"TELEGRAM_EMOJI",
),
)
)
_CONFIG_MANIFEST = (
ProviderManifestField("SEVERPAY_ENABLED", "bool", "Включена",
subsection="SeverPay", attr="ENABLED"),
ProviderManifestField(
"SEVERPAY_ENABLED", "bool", "Включена", subsection="SeverPay", attr="ENABLED"
),
ProviderManifestField("SEVERPAY_MID", "int", "MID", subsection="SeverPay", attr="MID"),
ProviderManifestField("SEVERPAY_TOKEN", "string", "Token", subsection="SeverPay",
secret=True, attr="TOKEN"),
ProviderManifestField("SEVERPAY_BASE_URL", "url", "Base URL",
placeholder="https://severpay.io/api/merchant",
subsection="SeverPay", attr="BASE_URL"),
ProviderManifestField("SEVERPAY_RETURN_URL", "url", "Return URL",
subsection="SeverPay", attr="RETURN_URL"),
ProviderManifestField("SEVERPAY_LIFETIME_MINUTES", "int", "Payment link lifetime (minutes)",
description="30..4320; leave empty for the SeverPay default.",
subsection="SeverPay", min=30, max=4320, attr="LIFETIME_MINUTES"),
ProviderManifestField(
"SEVERPAY_TOKEN", "string", "Token", subsection="SeverPay", secret=True, attr="TOKEN"
),
ProviderManifestField(
"SEVERPAY_BASE_URL",
"url",
"Base URL",
placeholder="https://severpay.io/api/merchant",
subsection="SeverPay",
attr="BASE_URL",
),
ProviderManifestField(
"SEVERPAY_RETURN_URL", "url", "Return URL", subsection="SeverPay", attr="RETURN_URL"
),
ProviderManifestField(
"SEVERPAY_LIFETIME_MINUTES",
"int",
"Payment link lifetime (minutes)",
description="30..4320; leave empty for the SeverPay default.",
subsection="SeverPay",
min=30,
max=4320,
attr="LIFETIME_MINUTES",
),
)
@@ -125,11 +125,7 @@ def payment_link_message_text(
"topup",
"premium_topup",
}
key = (
"payment_link_message_traffic"
if traffic_like
else "payment_link_message"
)
key = "payment_link_message_traffic" if traffic_like else "payment_link_message"
body = translator(
key,
months=int(parts.months),
@@ -35,9 +35,7 @@ async def resolve_user_language(
if db_user is None:
db_user = await user_dal.get_user_by_id(session, user_id)
language = (
db_user.language_code
if db_user and db_user.language_code
else settings.DEFAULT_LANGUAGE
db_user.language_code if db_user and db_user.language_code else settings.DEFAULT_LANGUAGE
)
return db_user, language
@@ -256,9 +254,7 @@ async def finalize_successful_payment(
activation_months = (
int(float(req.months)) if is_subscription else int(float(req.traffic_amount or req.months))
)
traffic_gb_for_activation = (
float(req.traffic_amount or req.months) if is_traffic else None
)
traffic_gb_for_activation = float(req.traffic_amount or req.months) if is_traffic else None
try:
activation = await req.subscription_service.activate_subscription(
@@ -308,9 +304,7 @@ async def finalize_successful_payment(
base_end_date = activation.get("end_date") if activation else None
final_end_date = base_end_date
applied_referee_bonus_days = 0
applied_promo_bonus_days = (
activation.get("applied_promo_bonus_days", 0) if activation else 0
)
applied_promo_bonus_days = activation.get("applied_promo_bonus_days", 0) if activation else 0
inviter_name: Optional[str] = None
if referral_bonus and referral_bonus.get("referee_new_end_date"):
@@ -351,9 +345,7 @@ async def finalize_successful_payment(
log_prefix=req.log_prefix,
)
refreshed_payment = await payment_dal.get_payment_by_db_id(
req.session, req.payment.payment_id
)
refreshed_payment = await payment_dal.get_payment_by_db_id(req.session, req.payment.payment_id)
tariff_key = getattr(refreshed_payment or req.payment, "tariff_key", None)
await notify_admins_payment_received(
@@ -36,9 +36,7 @@ async def lookup_payment_by_order_or_provider_id(
if payment_db_id is not None:
payment = await payment_dal.get_payment_by_db_id(session, payment_db_id)
if not payment and provider_payment_id:
payment = await payment_dal.get_payment_by_provider_payment_id(
session, provider_payment_id
)
payment = await payment_dal.get_payment_by_provider_payment_id(session, provider_payment_id)
return payment
@@ -54,9 +52,7 @@ async def notify_user_payment_failed(
"""Send the localized ``payment_failed`` text to the user; never raises."""
db_user = payment.user or await user_dal.get_user_by_id(session, payment.user_id)
language = (
db_user.language_code
if db_user and db_user.language_code
else settings.DEFAULT_LANGUAGE
db_user.language_code if db_user and db_user.language_code else settings.DEFAULT_LANGUAGE
)
translator = make_translator(i18n, language)
try:
+57 -20
View File
@@ -323,9 +323,7 @@ async def create_webapp_payment(ctx: WebAppPaymentContext) -> web.Response:
provider="telegram_stars",
)
payload_units = amounts.purchased_gb if amounts.traffic_sale else ctx.months
payload = (
f"{payment.payment_id}:{format_number_for_payload(payload_units)}:{ctx.sale_mode}"
)
payload = f"{payment.payment_id}:{format_number_for_payload(payload_units)}:{ctx.sale_mode}"
prices = [LabeledPrice(label=ctx.description, amount=ctx.stars_price)]
create_invoice_link = getattr(bot, "create_invoice_link", None)
if callable(create_invoice_link):
@@ -371,25 +369,64 @@ async def create_webapp_payment(ctx: WebAppPaymentContext) -> web.Response:
_PRESENTATION_MANIFEST = tuple(
ProviderManifestField(
key=key, type=type_, label=label, description=description,
placeholder=placeholder, subsection="Telegram Stars",
target="presentation", attr=attr,
key=key,
type=type_,
label=label,
description=description,
placeholder=placeholder,
subsection="Telegram Stars",
target="presentation",
attr=attr,
)
for key, type_, label, description, placeholder, attr in (
("PAYMENT_STARS_WEBAPP_LABEL_RU", "string", "WebApp button text (RU)",
"Custom Russian text shown in the Web App payment method button.", "", "WEBAPP_LABEL_RU"),
("PAYMENT_STARS_WEBAPP_LABEL_EN", "string", "WebApp button text (EN)",
"Custom English text shown in the Web App payment method button.", "", "WEBAPP_LABEL_EN"),
("PAYMENT_STARS_WEBAPP_ICON", "icon", "WebApp button icon",
"Lucide icon name rendered inside the Web App payment method button.",
"Sparkles", "WEBAPP_ICON"),
("PAYMENT_STARS_TELEGRAM_LABEL_RU", "string", "Telegram button text (RU)",
"Custom Russian text shown in Telegram bot payment buttons.", "", "TELEGRAM_LABEL_RU"),
("PAYMENT_STARS_TELEGRAM_LABEL_EN", "string", "Telegram button text (EN)",
"Custom English text shown in Telegram bot payment buttons.", "", "TELEGRAM_LABEL_EN"),
("PAYMENT_STARS_TELEGRAM_EMOJI", "string", "Telegram button emoji",
"Emoji prepended to the Telegram bot payment button when customized.",
"🌟", "TELEGRAM_EMOJI"),
(
"PAYMENT_STARS_WEBAPP_LABEL_RU",
"string",
"WebApp button text (RU)",
"Custom Russian text shown in the Web App payment method button.",
"",
"WEBAPP_LABEL_RU",
),
(
"PAYMENT_STARS_WEBAPP_LABEL_EN",
"string",
"WebApp button text (EN)",
"Custom English text shown in the Web App payment method button.",
"",
"WEBAPP_LABEL_EN",
),
(
"PAYMENT_STARS_WEBAPP_ICON",
"icon",
"WebApp button icon",
"Lucide icon name rendered inside the Web App payment method button.",
"Sparkles",
"WEBAPP_ICON",
),
(
"PAYMENT_STARS_TELEGRAM_LABEL_RU",
"string",
"Telegram button text (RU)",
"Custom Russian text shown in Telegram bot payment buttons.",
"",
"TELEGRAM_LABEL_RU",
),
(
"PAYMENT_STARS_TELEGRAM_LABEL_EN",
"string",
"Telegram button text (EN)",
"Custom English text shown in Telegram bot payment buttons.",
"",
"TELEGRAM_LABEL_EN",
),
(
"PAYMENT_STARS_TELEGRAM_EMOJI",
"string",
"Telegram button emoji",
"Emoji prepended to the Telegram bot payment button when customized.",
"🌟",
"TELEGRAM_EMOJI",
),
)
)
+107 -37
View File
@@ -542,50 +542,120 @@ def create_service(ctx: ServiceFactoryContext) -> WataService:
_PRESENTATION_MANIFEST = tuple(
ProviderManifestField(
key=key, type=type_, label=label, description=description,
placeholder=placeholder, subsection="Wata",
target="presentation", attr=attr,
key=key,
type=type_,
label=label,
description=description,
placeholder=placeholder,
subsection="Wata",
target="presentation",
attr=attr,
)
for key, type_, label, description, placeholder, attr in (
("PAYMENT_WATA_WEBAPP_LABEL_RU", "string", "WebApp button text (RU)",
"Custom Russian text shown in the Web App payment method button.", "", "WEBAPP_LABEL_RU"),
("PAYMENT_WATA_WEBAPP_LABEL_EN", "string", "WebApp button text (EN)",
"Custom English text shown in the Web App payment method button.", "", "WEBAPP_LABEL_EN"),
("PAYMENT_WATA_WEBAPP_ICON", "icon", "WebApp button icon",
"Lucide icon name rendered inside the Web App payment method button.",
"WalletCards", "WEBAPP_ICON"),
("PAYMENT_WATA_TELEGRAM_LABEL_RU", "string", "Telegram button text (RU)",
"Custom Russian text shown in Telegram bot payment buttons.", "", "TELEGRAM_LABEL_RU"),
("PAYMENT_WATA_TELEGRAM_LABEL_EN", "string", "Telegram button text (EN)",
"Custom English text shown in Telegram bot payment buttons.", "", "TELEGRAM_LABEL_EN"),
("PAYMENT_WATA_TELEGRAM_EMOJI", "string", "Telegram button emoji",
"Emoji prepended to the Telegram bot payment button when customized.",
"💳", "TELEGRAM_EMOJI"),
(
"PAYMENT_WATA_WEBAPP_LABEL_RU",
"string",
"WebApp button text (RU)",
"Custom Russian text shown in the Web App payment method button.",
"",
"WEBAPP_LABEL_RU",
),
(
"PAYMENT_WATA_WEBAPP_LABEL_EN",
"string",
"WebApp button text (EN)",
"Custom English text shown in the Web App payment method button.",
"",
"WEBAPP_LABEL_EN",
),
(
"PAYMENT_WATA_WEBAPP_ICON",
"icon",
"WebApp button icon",
"Lucide icon name rendered inside the Web App payment method button.",
"WalletCards",
"WEBAPP_ICON",
),
(
"PAYMENT_WATA_TELEGRAM_LABEL_RU",
"string",
"Telegram button text (RU)",
"Custom Russian text shown in Telegram bot payment buttons.",
"",
"TELEGRAM_LABEL_RU",
),
(
"PAYMENT_WATA_TELEGRAM_LABEL_EN",
"string",
"Telegram button text (EN)",
"Custom English text shown in Telegram bot payment buttons.",
"",
"TELEGRAM_LABEL_EN",
),
(
"PAYMENT_WATA_TELEGRAM_EMOJI",
"string",
"Telegram button emoji",
"Emoji prepended to the Telegram bot payment button when customized.",
"💳",
"TELEGRAM_EMOJI",
),
)
)
_CONFIG_MANIFEST = (
ProviderManifestField("WATA_ENABLED", "bool", "Enabled", subsection="Wata", attr="ENABLED"),
ProviderManifestField("WATA_API_TOKEN", "string", "API token", subsection="Wata",
secret=True, attr="API_TOKEN"),
ProviderManifestField("WATA_BASE_URL", "url", "Base URL",
placeholder="https://api.wata.pro/api/h2h",
subsection="Wata", attr="BASE_URL"),
ProviderManifestField("WATA_RETURN_URL", "url", "Return URL",
subsection="Wata", attr="RETURN_URL"),
ProviderManifestField("WATA_FAILED_URL", "url", "Failed URL",
subsection="Wata", attr="FAILED_URL"),
ProviderManifestField("WATA_PAYMENT_LINK_TTL_DAYS", "int", "Payment link lifetime (days)",
description="1..30; Wata defaults to 3 days and allows up to 30 days.",
subsection="Wata", min=1, max=30, attr="PAYMENT_LINK_TTL_DAYS"),
ProviderManifestField("WATA_WEBHOOK_VERIFY_SIGNATURE", "bool", "Verify webhook signature",
subsection="Wata", attr="WEBHOOK_VERIFY_SIGNATURE"),
ProviderManifestField("WATA_PUBLIC_KEY", "text", "Webhook public key",
description="Optional. If empty, the backend fetches it from Wata.",
subsection="Wata", secret=True, attr="PUBLIC_KEY"),
ProviderManifestField("WATA_TRUSTED_IPS", "string", "Trusted IPs",
description="Comma-separated IP addresses accepted for Wata webhooks.",
subsection="Wata", attr="TRUSTED_IPS"),
ProviderManifestField(
"WATA_API_TOKEN", "string", "API token", subsection="Wata", secret=True, attr="API_TOKEN"
),
ProviderManifestField(
"WATA_BASE_URL",
"url",
"Base URL",
placeholder="https://api.wata.pro/api/h2h",
subsection="Wata",
attr="BASE_URL",
),
ProviderManifestField(
"WATA_RETURN_URL", "url", "Return URL", subsection="Wata", attr="RETURN_URL"
),
ProviderManifestField(
"WATA_FAILED_URL", "url", "Failed URL", subsection="Wata", attr="FAILED_URL"
),
ProviderManifestField(
"WATA_PAYMENT_LINK_TTL_DAYS",
"int",
"Payment link lifetime (days)",
description="1..30; Wata defaults to 3 days and allows up to 30 days.",
subsection="Wata",
min=1,
max=30,
attr="PAYMENT_LINK_TTL_DAYS",
),
ProviderManifestField(
"WATA_WEBHOOK_VERIFY_SIGNATURE",
"bool",
"Verify webhook signature",
subsection="Wata",
attr="WEBHOOK_VERIFY_SIGNATURE",
),
ProviderManifestField(
"WATA_PUBLIC_KEY",
"text",
"Webhook public key",
description="Optional. If empty, the backend fetches it from Wata.",
subsection="Wata",
secret=True,
attr="PUBLIC_KEY",
),
ProviderManifestField(
"WATA_TRUSTED_IPS",
"string",
"Trusted IPs",
description="Comma-separated IP addresses accepted for Wata webhooks.",
subsection="Wata",
attr="TRUSTED_IPS",
),
)
+112 -50
View File
@@ -90,9 +90,7 @@ class YooKassaConfig(ProviderEnvConfig):
AUTOPAYMENTS_ENABLED: bool = Field(default=False)
AUTOPAYMENTS_REQUIRE_CARD_BINDING: bool = Field(default=True)
@field_validator(
"SHOP_ID", "SECRET_KEY", "RETURN_URL", "DEFAULT_RECEIPT_EMAIL", mode="before"
)
@field_validator("SHOP_ID", "SECRET_KEY", "RETURN_URL", "DEFAULT_RECEIPT_EMAIL", mode="before")
@classmethod
def _strip_optional(cls, v):
if isinstance(v, str) and not v.strip():
@@ -147,7 +145,9 @@ class YooKassaService:
self.config = config or YooKassaConfig()
self._bot_username_for_default_return = bot_username_for_default_return
self._configured_return_url_override = configured_return_url
self._sdk_configured_for = None # (shop_id, secret_key) currently loaded into the global SDK
self._sdk_configured_for = (
None # (shop_id, secret_key) currently loaded into the global SDK
)
if not self.configured:
if not self.config.ENABLED:
@@ -1214,9 +1214,7 @@ async def _initiate_yk_payment(
"description": payment_description,
"subscription_duration_months": int(months) if sale_base == "subscription" else None,
"sale_mode": sale_base,
"tariff_key": sale_mode.split("@", 1)[1].split("|", 1)[0]
if "@" in sale_mode
else None,
"tariff_key": sale_mode.split("@", 1)[1].split("|", 1)[0] if "@" in sale_mode else None,
"purchased_gb": float(months)
if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"}
else None,
@@ -2505,7 +2503,9 @@ logger = logging.getLogger(__name__)
def create_service(ctx: ServiceFactoryContext) -> YooKassaService:
bundle = ctx.config_for("yookassa_service")
config = bundle.config if bundle and isinstance(bundle.config, YooKassaConfig) else YooKassaConfig()
config = (
bundle.config if bundle and isinstance(bundle.config, YooKassaConfig) else YooKassaConfig()
)
return YooKassaService(
shop_id=config.SHOP_ID,
secret_key=config.SECRET_KEY,
@@ -2516,12 +2516,7 @@ def create_service(ctx: ServiceFactoryContext) -> YooKassaService:
)
async def create_webapp_payment(ctx: WebAppPaymentContext) -> web.Response:
settings = ctx.request.app["settings"]
service: YooKassaService = ctx.request.app["yookassa_service"]
if not service or not service.configured:
return payment_unavailable()
@@ -2588,49 +2583,116 @@ async def create_webapp_payment(ctx: WebAppPaymentContext) -> web.Response:
_PRESENTATION_MANIFEST = tuple(
ProviderManifestField(
key=key, type=type_, label=label, description=description,
placeholder=placeholder, subsection="YooKassa",
target="presentation", attr=attr,
key=key,
type=type_,
label=label,
description=description,
placeholder=placeholder,
subsection="YooKassa",
target="presentation",
attr=attr,
)
for key, type_, label, description, placeholder, attr in (
("PAYMENT_YOOKASSA_WEBAPP_LABEL_RU", "string", "WebApp button text (RU)",
"Custom Russian text shown in the Web App payment method button.", "", "WEBAPP_LABEL_RU"),
("PAYMENT_YOOKASSA_WEBAPP_LABEL_EN", "string", "WebApp button text (EN)",
"Custom English text shown in the Web App payment method button.", "", "WEBAPP_LABEL_EN"),
("PAYMENT_YOOKASSA_WEBAPP_ICON", "icon", "WebApp button icon",
"Lucide icon name rendered inside the Web App payment method button.",
"CreditCard", "WEBAPP_ICON"),
("PAYMENT_YOOKASSA_TELEGRAM_LABEL_RU", "string", "Telegram button text (RU)",
"Custom Russian text shown in Telegram bot payment buttons.", "", "TELEGRAM_LABEL_RU"),
("PAYMENT_YOOKASSA_TELEGRAM_LABEL_EN", "string", "Telegram button text (EN)",
"Custom English text shown in Telegram bot payment buttons.", "", "TELEGRAM_LABEL_EN"),
("PAYMENT_YOOKASSA_TELEGRAM_EMOJI", "string", "Telegram button emoji",
"Emoji prepended to the Telegram bot payment button when customized.",
"💳", "TELEGRAM_EMOJI"),
(
"PAYMENT_YOOKASSA_WEBAPP_LABEL_RU",
"string",
"WebApp button text (RU)",
"Custom Russian text shown in the Web App payment method button.",
"",
"WEBAPP_LABEL_RU",
),
(
"PAYMENT_YOOKASSA_WEBAPP_LABEL_EN",
"string",
"WebApp button text (EN)",
"Custom English text shown in the Web App payment method button.",
"",
"WEBAPP_LABEL_EN",
),
(
"PAYMENT_YOOKASSA_WEBAPP_ICON",
"icon",
"WebApp button icon",
"Lucide icon name rendered inside the Web App payment method button.",
"CreditCard",
"WEBAPP_ICON",
),
(
"PAYMENT_YOOKASSA_TELEGRAM_LABEL_RU",
"string",
"Telegram button text (RU)",
"Custom Russian text shown in Telegram bot payment buttons.",
"",
"TELEGRAM_LABEL_RU",
),
(
"PAYMENT_YOOKASSA_TELEGRAM_LABEL_EN",
"string",
"Telegram button text (EN)",
"Custom English text shown in Telegram bot payment buttons.",
"",
"TELEGRAM_LABEL_EN",
),
(
"PAYMENT_YOOKASSA_TELEGRAM_EMOJI",
"string",
"Telegram button emoji",
"Emoji prepended to the Telegram bot payment button when customized.",
"💳",
"TELEGRAM_EMOJI",
),
)
)
_CONFIG_MANIFEST = (
ProviderManifestField("YOOKASSA_ENABLED", "bool", "Включена",
subsection="YooKassa", attr="ENABLED"),
ProviderManifestField("YOOKASSA_SHOP_ID", "string", "Shop ID",
subsection="YooKassa", attr="SHOP_ID"),
ProviderManifestField("YOOKASSA_SECRET_KEY", "string", "Secret key",
subsection="YooKassa", secret=True, attr="SECRET_KEY"),
ProviderManifestField("YOOKASSA_RETURN_URL", "url", "Return URL",
subsection="YooKassa", attr="RETURN_URL"),
ProviderManifestField("YOOKASSA_DEFAULT_RECEIPT_EMAIL", "string",
"Email для чека по умолчанию",
subsection="YooKassa", attr="DEFAULT_RECEIPT_EMAIL"),
ProviderManifestField("YOOKASSA_VAT_CODE", "int", "VAT code",
description="1..6 в зависимости от системы налогообложения",
subsection="YooKassa", min=1, max=6, attr="VAT_CODE"),
ProviderManifestField("YOOKASSA_AUTOPAYMENTS_ENABLED", "bool",
"Автоплатежи (recurring)",
subsection="YooKassa", attr="AUTOPAYMENTS_ENABLED"),
ProviderManifestField("YOOKASSA_AUTOPAYMENTS_REQUIRE_CARD_BINDING", "bool",
"Принудительная привязка карты",
subsection="YooKassa", attr="AUTOPAYMENTS_REQUIRE_CARD_BINDING"),
ProviderManifestField(
"YOOKASSA_ENABLED", "bool", "Включена", subsection="YooKassa", attr="ENABLED"
),
ProviderManifestField(
"YOOKASSA_SHOP_ID", "string", "Shop ID", subsection="YooKassa", attr="SHOP_ID"
),
ProviderManifestField(
"YOOKASSA_SECRET_KEY",
"string",
"Secret key",
subsection="YooKassa",
secret=True,
attr="SECRET_KEY",
),
ProviderManifestField(
"YOOKASSA_RETURN_URL", "url", "Return URL", subsection="YooKassa", attr="RETURN_URL"
),
ProviderManifestField(
"YOOKASSA_DEFAULT_RECEIPT_EMAIL",
"string",
"Email для чека по умолчанию",
subsection="YooKassa",
attr="DEFAULT_RECEIPT_EMAIL",
),
ProviderManifestField(
"YOOKASSA_VAT_CODE",
"int",
"VAT code",
description="1..6 в зависимости от системы налогообложения",
subsection="YooKassa",
min=1,
max=6,
attr="VAT_CODE",
),
ProviderManifestField(
"YOOKASSA_AUTOPAYMENTS_ENABLED",
"bool",
"Автоплатежи (recurring)",
subsection="YooKassa",
attr="AUTOPAYMENTS_ENABLED",
),
ProviderManifestField(
"YOOKASSA_AUTOPAYMENTS_REQUIRE_CARD_BINDING",
"bool",
"Принудительная привязка карты",
subsection="YooKassa",
attr="AUTOPAYMENTS_REQUIRE_CARD_BINDING",
),
)
+1 -1
View File
@@ -514,7 +514,7 @@ def _support_email(
footer = _t_html(_resolve_i18n(i18n), lang, "email_footer_auto", brand=brand)
preview_block = (
f'<div style="margin:0 0 16px 0;background:{_BG};border:1px solid {_BORDER};'
f'border-radius:14px;padding:14px 16px;font-size:14px;line-height:1.55;color:{_TEXT};'
f"border-radius:14px;padding:14px 16px;font-size:14px;line-height:1.55;color:{_TEXT};"
f'white-space:pre-wrap;">{html.escape(body_preview or "")}</div>'
)
body_parts = [_info_rows_html(rows), preview_block]
+1 -3
View File
@@ -290,9 +290,7 @@ class NotificationService:
return bool(value)
async def support_admin_email_notifications_enabled(self) -> bool:
enabled = bool(
getattr(self.settings, SUPPORT_ADMIN_EMAIL_NOTIFICATIONS_KEY, False)
)
enabled = bool(getattr(self.settings, SUPPORT_ADMIN_EMAIL_NOTIFICATIONS_KEY, False))
if not self.session_factory:
return enabled
try:
@@ -255,8 +255,7 @@ class PanelWebhookService:
"panel",
{"event": event_name, "user": user_data},
event_id=(
f"{event_name}:"
f"{telegram_id or user_data.get('uuid') or user_data.get('shortUuid')}"
f"{event_name}:{telegram_id or user_data.get('uuid') or user_data.get('shortUuid')}"
),
)
if not queued:
@@ -70,10 +70,9 @@ def _apply_to_provider_bundle(key: str, value: Any) -> bool:
from bot.payment_providers import (
find_manifest_owner,
get_provider_bundle,
get_spec_presentation,
)
from bot.payment_providers import get_spec_presentation
owner = find_manifest_owner(key)
if owner is None:
return False
@@ -213,9 +212,7 @@ async def load_overrides_from_db(settings: Settings, async_session_factory: sess
overrides = await app_settings_dal.get_all_overrides(session)
backup_overrides = _read_appearance_backup()
missing_backup_overrides = {
key: value
for key, value in backup_overrides.items()
if key not in overrides
key: value for key, value in backup_overrides.items() if key not in overrides
}
if missing_backup_overrides:
for key, value in missing_backup_overrides.items():
+6 -6
View File
@@ -644,12 +644,12 @@ class TariffTrafficWorker:
for node_uuid in node_uuids:
stats_cache_key = (node_uuid, start_date, end_date)
if stats_cache_key not in self._premium_node_stats_tick_cache:
self._premium_node_stats_tick_cache[stats_cache_key] = (
await self.panel_service.get_node_users_bandwidth_stats(
node_uuid,
start=start_date,
end=end_date,
)
self._premium_node_stats_tick_cache[
stats_cache_key
] = await self.panel_service.get_node_users_bandwidth_stats(
node_uuid,
start=start_date,
end=end_date,
)
stats = self._premium_node_stats_tick_cache.get(stats_cache_key)
if not stats:
+37
View File
@@ -12,6 +12,15 @@ from config.webapp_themes_config import (
resolved_webapp_themes_catalog,
)
DEFAULT_SUBSCRIPTION_PURCHASE_DESCRIPTION_RU = (
"Покупая или продлевая подписку, вы получаете доступ к VPN/прокси-сервису, "
"который помогает защищать ваше соединение и поддерживать стабильный доступ к сети."
)
DEFAULT_SUBSCRIPTION_PURCHASE_DESCRIPTION_EN = (
"By buying or renewing a subscription, you get access to a VPN/proxy service "
"that helps protect your connection and keep your access stable."
)
def _split_csv(value: Optional[str]) -> List[str]:
if not value:
@@ -147,6 +156,18 @@ class Settings(BaseSettings):
default=None,
description="Comma-separated list of payment methods to show (e.g., severpay,wata,freekassa,yookassa,platega,stars,cryptopay)", # noqa: E501
)
SUBSCRIPTION_PURCHASE_DESCRIPTION_ENABLED: bool = Field(
default=True,
description="Show a localized description of the subscription before users choose a purchase/renewal period.", # noqa: E501
)
SUBSCRIPTION_PURCHASE_DESCRIPTION_RU: str = Field(
default=DEFAULT_SUBSCRIPTION_PURCHASE_DESCRIPTION_RU,
description="Russian subscription description shown before purchase/renewal options.",
)
SUBSCRIPTION_PURCHASE_DESCRIPTION_EN: str = Field(
default=DEFAULT_SUBSCRIPTION_PURCHASE_DESCRIPTION_EN,
description="English subscription description shown before purchase/renewal options.",
)
MONTH_1_ENABLED: bool = Field(default=True, alias="1_MONTH_ENABLED")
MONTH_3_ENABLED: bool = Field(default=True, alias="3_MONTHS_ENABLED")
@@ -772,6 +793,22 @@ class Settings(BaseSettings):
methods.append(sid)
return methods or default_order
def subscription_purchase_description(self, language: Optional[str] = None) -> str:
if not self.SUBSCRIPTION_PURCHASE_DESCRIPTION_ENABLED:
return ""
lang = (language or self.DEFAULT_LANGUAGE or "ru").split("-")[0].lower()
primary = (
self.SUBSCRIPTION_PURCHASE_DESCRIPTION_EN
if lang == "en"
else self.SUBSCRIPTION_PURCHASE_DESCRIPTION_RU
)
fallback = (
self.SUBSCRIPTION_PURCHASE_DESCRIPTION_RU
if lang == "en"
else self.SUBSCRIPTION_PURCHASE_DESCRIPTION_EN
)
return (primary or fallback or "").strip()
@computed_field
@property
def email_auth_configured(self) -> bool:
+1 -2
View File
@@ -738,8 +738,7 @@ def _migration_0022_add_indexes_for_admin_reports(connection: Connection) -> Non
)
connection.execute(
text(
"CREATE INDEX IF NOT EXISTS ix_message_logs_timestamp "
"ON message_logs (timestamp DESC)"
"CREATE INDEX IF NOT EXISTS ix_message_logs_timestamp ON message_logs (timestamp DESC)"
)
)
+1 -3
View File
@@ -403,9 +403,7 @@ class SupportTicket(Base):
passive_deletes=True,
)
__table_args__ = (
Index("ix_support_tickets_status_last_msg", "status", "last_message_at"),
)
__table_args__ = (Index("ix_support_tickets_status_last_msg", "status", "last_message_at"),)
class SupportTicketMessage(Base):
+2
View File
@@ -49,6 +49,8 @@ nano .env
| Переменная | Назначение |
| --- | --- |
| `PAYMENT_METHODS_ORDER` | Порядок кнопок оплаты через запятую: `severpay`, `wata`, `freekassa`, `platega`, `yookassa`, `stars`, `cryptopay`. |
| `SUBSCRIPTION_PURCHASE_DESCRIPTION_ENABLED` | Показывать описание подписки перед выбором срока покупки или продления в Telegram и Web App. |
| `SUBSCRIPTION_PURCHASE_DESCRIPTION_RU` / `SUBSCRIPTION_PURCHASE_DESCRIPTION_EN` | Текст описания подписки для русской и английской локалей; эти же значения можно переопределить в админке. |
| `PAYMENT_<METHOD>_WEBAPP_LABEL_RU` / `PAYMENT_<METHOD>_WEBAPP_LABEL_EN` / `PAYMENT_<METHOD>_WEBAPP_ICON` | Необязательная мультиязычная кастомизация текста и lucide-иконки кнопки оплаты в Web App. |
| `PAYMENT_<METHOD>_TELEGRAM_LABEL_RU` / `PAYMENT_<METHOD>_TELEGRAM_LABEL_EN` / `PAYMENT_<METHOD>_TELEGRAM_EMOJI` | Необязательная мультиязычная кастомизация текста и эмодзи кнопки оплаты в Telegram-боте. |
| `YOOKASSA_ENABLED` | Включает YooKassa. |
+4
View File
@@ -279,6 +279,9 @@
$: plans = data?.plans?.length ? data.plans : DEV_MOCK.data.plans;
$: methods = data?.payment_methods?.length ? data.payment_methods : [];
$: appSettings = data?.settings || DEV_MOCK.data.settings;
$: subscriptionPurchaseDescription = String(
appSettings?.subscription_purchase_description || ""
).trim();
$: trafficMode = Boolean(appSettings?.traffic_mode);
$: tariffMode = plans.some((plan) => plan?.tariff_key);
$: tariffCatalog = buildTariffCatalog(plans);
@@ -1321,6 +1324,7 @@
{selectedTariffPlans}
{singleTariffMode}
{subscription}
{subscriptionPurchaseDescription}
{tariffCatalog}
{tariffMode}
closeDeviceDisconnectDialog={devicesStore.closeDeviceDisconnectDialog}
@@ -269,6 +269,14 @@
value={valueFor(field) ?? ""}
oninput={(e) => settingsStore.markDirty(field.key, e.currentTarget.value)}
/>
{:else if field.type === "text"}
<textarea
class="admin-setting-textarea"
rows="4"
placeholder={field.placeholder}
value={valueFor(field) ?? ""}
oninput={(e) => settingsStore.markDirty(field.key, e.currentTarget.value)}
></textarea>
{:else if field.secret}
<input
class="input"
+4
View File
@@ -669,6 +669,10 @@ export async function mockApi(path, options = {}, context = {}) {
}
const language = normalizeLangCode(payload?.language || currentLang);
DEV_MOCK.data.user.language_code = language;
DEV_MOCK.data.settings.subscription_purchase_description =
language === "en"
? "By buying or renewing a subscription, you get access to a VPN/proxy service that helps protect your connection and keep your access stable."
: "Покупая или продлевая подписку, вы получаете доступ к VPN/прокси-сервису, который помогает защищать ваше соединение и поддерживать стабильный доступ к сети.";
return { ok: true, language };
}
if (path === "/account/email/request" && String(options.method || "").toUpperCase() === "POST") {
+2
View File
@@ -222,6 +222,8 @@ export const DEV_MOCK = {
trial_duration_days: 5,
trial_traffic_limit_gb: 10,
trial_traffic_strategy: "NO_RESET",
subscription_purchase_description:
"Покупая или продлевая подписку, вы получаете доступ к VPN/прокси-сервису, который помогает защищать ваше соединение и поддерживать стабильный доступ к сети.",
email_auth_enabled: true,
},
},
+20
View File
@@ -206,6 +206,7 @@
.admin-setting-control .input,
.admin-setting-control .admin-setting-select,
.admin-setting-control .admin-setting-textarea,
.admin-setting-control input[type="text"],
.admin-setting-control input[type="number"] {
flex: 1 1 160px;
@@ -213,6 +214,25 @@
width: 100%;
}
.admin-setting-control .admin-setting-textarea {
min-height: 86px;
resize: vertical;
border-radius: 8px;
border: 1px solid var(--admin-border-strong);
background: color-mix(in srgb, var(--admin-bg) 82%, var(--admin-surface-2));
color: var(--admin-text);
padding: 10px 12px;
font-size: 13px;
line-height: 1.45;
outline: none;
transition: border-color 0.12s ease, box-shadow 0.12s ease;
}
.admin-setting-control .admin-setting-textarea:focus {
border-color: var(--admin-ring);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 20%, transparent);
}
.admin-btn.admin-btn-icon {
width: 30px;
height: 30px;
+18
View File
@@ -795,6 +795,24 @@ a {
line-height: 1.35;
}
.subscription-purchase-description {
display: grid;
gap: 6px;
padding: 11px 12px;
border: 1px solid color-mix(in srgb, var(--accent) 34%, var(--border));
border-radius: var(--radius);
background: color-mix(in srgb, var(--accent) 8%, var(--surface-muted));
box-shadow: inset 0 1px 0 var(--inset-highlight);
}
.subscription-purchase-description p {
margin: 0;
color: var(--text);
font-size: 12px;
line-height: 1.45;
overflow-wrap: anywhere;
}
.skeleton-row,
.skeleton-method,
.skeleton-pay-button {
+18
View File
@@ -64,6 +64,7 @@
export let setPasswordValue = "";
export let singleTariffMode = false;
export let subscription = {};
export let subscriptionPurchaseDescription = "";
export let tariffCatalog = [];
export let tariffMode = false;
export let trafficMode = false;
@@ -111,6 +112,13 @@
return trafficMode ? t("wa_traffic_packages_choose") : t("wa_subscription_choose_period");
}
function showSubscriptionPurchaseDescription() {
if (!subscriptionPurchaseDescription || trafficMode) return false;
if (!tariffMode) return true;
if (paymentStep === "tariff") return false;
return String(selectedTariff?.billing_model || "period").toLowerCase() !== "traffic";
}
export let closeDeviceDisconnectDialog = () => {};
export let closeLinkEmailDialog = () => {};
export let closePaymentModal = () => {};
@@ -184,6 +192,11 @@
</p>
{/if}
{#if selectedTariffPlans.length}
{#if showSubscriptionPurchaseDescription()}
<div class="subscription-purchase-description">
<p>{subscriptionPurchaseDescription}</p>
</div>
{/if}
<div class="period-grid period-grid-two-columns">
{#each selectedTariffPlans as plan}
<button
@@ -233,6 +246,11 @@
branch above, so users on legacy mode saw the period grid, payment
method grid and pay button duplicated.
-->
{#if showSubscriptionPurchaseDescription()}
<div class="subscription-purchase-description">
<p>{subscriptionPurchaseDescription}</p>
</div>
{/if}
<div class="period-grid period-grid-two-columns">
{#each plans as plan}
<button
+6
View File
@@ -1311,6 +1311,12 @@
"admin_settings_field_stars_traffic_packages_label": "Stars Traffic Packages",
"admin_settings_field_payment_methods_order_label": "Payment Methods Order",
"admin_settings_field_payment_methods_order_description": "Controls the 'Payment Methods Order' setting in admin overrides.",
"admin_settings_field_subscription_purchase_description_enabled_label": "Show subscription description",
"admin_settings_field_subscription_purchase_description_enabled_description": "Shows this text before the user chooses a purchase or renewal period.",
"admin_settings_field_subscription_purchase_description_ru_label": "Subscription description (RU)",
"admin_settings_field_subscription_purchase_description_ru_description": "Russian text shown during checkout.",
"admin_settings_field_subscription_purchase_description_en_label": "Subscription description (EN)",
"admin_settings_field_subscription_purchase_description_en_description": "English text shown during checkout.",
"admin_settings_field_stars_enabled_label": "Stars Enabled",
"admin_settings_field_yookassa_enabled_label": "YooKassa Enabled",
"admin_settings_field_yookassa_shop_id_label": "YooKassa Shop ID",
+6
View File
@@ -1311,6 +1311,12 @@
"admin_settings_field_stars_traffic_packages_label": "Пакеты трафика (Stars)",
"admin_settings_field_payment_methods_order_label": "Порядок методов оплаты",
"admin_settings_field_payment_methods_order_description": "Через запятую, например: severpay,freekassa,yookassa",
"admin_settings_field_subscription_purchase_description_enabled_label": "Показывать описание подписки",
"admin_settings_field_subscription_purchase_description_enabled_description": "Текст появится перед выбором срока покупки или продления.",
"admin_settings_field_subscription_purchase_description_ru_label": "Описание подписки (RU)",
"admin_settings_field_subscription_purchase_description_ru_description": "Русская версия текста на этапе оплаты.",
"admin_settings_field_subscription_purchase_description_en_label": "Описание подписки (EN)",
"admin_settings_field_subscription_purchase_description_en_description": "Английская версия текста на этапе оплаты.",
"admin_settings_field_stars_enabled_label": "Telegram Stars",
"admin_settings_field_yookassa_enabled_label": "Включена",
"admin_settings_field_yookassa_shop_id_label": "Shop ID",
@@ -16,6 +16,12 @@ SUPPORT_RELATED_SETTINGS = (
"SUPPORT_TICKET_RATE_LIMIT_PER_HOUR",
)
SUBSCRIPTION_PURCHASE_DESCRIPTION_SETTINGS = (
"SUBSCRIPTION_PURCHASE_DESCRIPTION_ENABLED",
"SUBSCRIPTION_PURCHASE_DESCRIPTION_RU",
"SUBSCRIPTION_PURCHASE_DESCRIPTION_EN",
)
def _manifest_by_key() -> dict[str, dict]:
return {item["key"]: item for item in manifest_payload()}
@@ -50,3 +56,15 @@ def test_support_settings_i18n_keys_exist_in_admin_locales():
field = manifest[setting_key]
assert field["i18n_label_key"] in messages
assert field["i18n_description_key"] in messages
def test_subscription_purchase_description_settings_i18n_keys_exist():
manifest = _manifest_by_key()
for language in ("ru", "en"):
messages = _locale(language)
for setting_key in SUBSCRIPTION_PURCHASE_DESCRIPTION_SETTINGS:
field = manifest[setting_key]
assert field["section"] == "pricing"
assert field["i18n_label_key"] in messages
assert field["i18n_description_key"] in messages
+7 -2
View File
@@ -51,8 +51,13 @@ class ConfigureLoggingTests(unittest.TestCase):
# Confirm the format string structure by emitting a record into a buffer.
formatter = handler.formatter
record = logging.LogRecord(
name="test", level=logging.INFO, pathname=__file__, lineno=1,
msg="hello", args=(), exc_info=None,
name="test",
level=logging.INFO,
pathname=__file__,
lineno=1,
msg="hello",
args=(),
exc_info=None,
)
rendered = formatter.format(record) if formatter else ""
# Format is "%(asctime)s - %(name)s - %(levelname)s - %(message)s".
+2 -6
View File
@@ -155,15 +155,11 @@ class LiveGitFallbackTests(unittest.TestCase):
return _resolve()
def test_tag_with_zero_commits_since_returns_bare_tag(self):
result = self._run_with_git(
{"tag": "v2.0.0", "sha": "abcdef1", "commits_since_tag": "0"}
)
result = self._run_with_git({"tag": "v2.0.0", "sha": "abcdef1", "commits_since_tag": "0"})
self.assertEqual(result, "v2.0.0")
def test_tag_plus_distance_plus_sha_format(self):
result = self._run_with_git(
{"tag": "v2.0.0", "sha": "abcdef1", "commits_since_tag": "7"}
)
result = self._run_with_git({"tag": "v2.0.0", "sha": "abcdef1", "commits_since_tag": "7"})
self.assertEqual(result, "v2.0.0+7.gabcdef1")
def test_sha_only_when_no_tag(self):
+1 -3
View File
@@ -33,9 +33,7 @@ class _FakeYooKassaService:
def __init__(self, configured: bool = True, response: Optional[Dict[str, Any]] = None) -> None:
self.configured = configured
self.calls: List[Dict[str, Any]] = []
self._response = response if response is not None else {
"id": "pay-1", "status": "pending"
}
self._response = response if response is not None else {"id": "pay-1", "status": "pending"}
async def create_payment(self, **kwargs):
self.calls.append(kwargs)
+12 -8
View File
@@ -29,16 +29,22 @@ from config.settings import Settings
# Strip all provider env so per-provider BaseSettings models don't pick up
# real credentials from the local .env file during tests.
_PROVIDER_ENV_PREFIXES = (
"FREEKASSA_", "PLATEGA_", "SEVERPAY_", "WATA_", "HELEKET_",
"CRYPTOPAY_", "YOOKASSA_", "STARS_",
"FREEKASSA_",
"PLATEGA_",
"SEVERPAY_",
"WATA_",
"HELEKET_",
"CRYPTOPAY_",
"YOOKASSA_",
"STARS_",
)
def _clean_env() -> dict[str, str]:
return {
k: v for k, v in os.environ.items()
if not any(k.startswith(p) for p in _PROVIDER_ENV_PREFIXES)
and not k.startswith("PAYMENT_")
k: v
for k, v in os.environ.items()
if not any(k.startswith(p) for p in _PROVIDER_ENV_PREFIXES) and not k.startswith("PAYMENT_")
}
@@ -116,9 +122,7 @@ class BuildServicesWiringTests(unittest.TestCase):
panel_webhook = services["panel_webhook_service"]
subscription = services["subscription_service"]
self.assertIsInstance(panel_webhook, PanelWebhookService)
self.assertIs(
getattr(panel_webhook, "subscription_service", None), subscription
)
self.assertIs(getattr(panel_webhook, "subscription_service", None), subscription)
def test_factory_returns_every_documented_service(self):
"""Guards against silently dropping a service from the bundle. The
+3 -8
View File
@@ -59,18 +59,14 @@ class MigrationDocumentationFactsTests(unittest.TestCase):
def test_doc_lists_every_running_container_in_current_compose(self):
"""The architecture table must reflect what ``docker compose up``
actually produces today."""
missing = sorted(
name for name in EXPECTED_CONTAINER_NAMES if name not in self.doc
)
missing = sorted(name for name in EXPECTED_CONTAINER_NAMES if name not in self.doc)
self.assertFalse(
missing,
f"migration-to-minishop.md is missing container names from current compose: {missing}",
)
def test_doc_lists_every_volume_in_current_compose(self):
missing = sorted(
name for name in EXPECTED_VOLUME_NAMES if name not in self.doc
)
missing = sorted(name for name in EXPECTED_VOLUME_NAMES if name not in self.doc)
self.assertFalse(
missing,
f"migration-to-minishop.md is missing volume names from current compose: {missing}",
@@ -185,8 +181,7 @@ class MigrationScriptCoverageTests(unittest.TestCase):
missing = sorted(EXPECTED_CONTAINER_NAMES - self.known)
self.assertFalse(
missing,
f"KNOWN_CONTAINERS missing split-arch entries: {missing}\n"
f"actual: {sorted(self.known)}",
f"KNOWN_CONTAINERS missing split-arch entries: {missing}\nactual: {sorted(self.known)}",
)
def test_known_containers_still_covers_legacy_eras(self):
+2 -6
View File
@@ -86,9 +86,7 @@ class HandleWebhookQueueingTests(unittest.IsolatedAsyncioTestCase):
captured: List[dict] = []
async def fake_enqueue(settings, provider, payload, *, event_id=None):
captured.append(
{"provider": provider, "payload": payload, "event_id": event_id}
)
captured.append({"provider": provider, "payload": payload, "event_id": event_id})
return True
body = json.dumps(
@@ -121,9 +119,7 @@ class HandleWebhookQueueingTests(unittest.IsolatedAsyncioTestCase):
async def fake_handle_event(event_name, user_payload):
background_seen.append((event_name, user_payload))
body = json.dumps(
{"name": "user.expired", "payload": {"telegramId": 7}}
).encode()
body = json.dumps({"name": "user.expired", "payload": {"telegramId": 7}}).encode()
with (
patch.object(pws, "enqueue_webhook_event", fake_enqueue),
+11 -15
View File
@@ -97,11 +97,7 @@ def test_yookassa_provider_keeps_autorenew_entrypoints_local():
def test_every_payment_method_has_registry_driven_webapp_creator():
missing = [
spec.id
for spec in iter_provider_specs()
if spec.create_webapp_payment is None
]
missing = [spec.id for spec in iter_provider_specs() if spec.create_webapp_payment is None]
assert missing == []
@@ -192,10 +188,7 @@ def test_provider_presentation_ignores_cross_language_override():
settings = SimpleNamespace(PAYMENT_YOOKASSA_WEBAPP_LABEL_RU="Карта")
assert (
resolve_provider_presentation(spec, settings, language="en").webapp_label
== "Bank card"
)
assert resolve_provider_presentation(spec, settings, language="en").webapp_label == "Bank card"
def test_payment_method_keyboard_uses_custom_telegram_text_without_changing_callback(monkeypatch):
@@ -255,12 +248,15 @@ def test_provider_callbacks_are_built_from_specs():
)
== "pay_stars:1:42:subscription"
)
assert stars.callback_data(
value="1",
rub_price=150,
stars_price=None,
sale_mode="subscription",
) is None
assert (
stars.callback_data(
value="1",
rub_price=150,
stars_price=None,
sale_mode="subscription",
)
is None
)
def test_provider_visibility_uses_service_configuration():
+1 -3
View File
@@ -87,9 +87,7 @@ class SendPaymentSuccessEmailTests(unittest.IsolatedAsyncioTestCase):
DEFAULT_CURRENCY_SYMBOL="RUB",
SUBSCRIPTION_MINI_APP_URL="https://app.example.com/",
)
user = _FakeUser(
user_id=42, email="buyer@example.com", language_code="en"
)
user = _FakeUser(user_id=42, email="buyer@example.com", language_code="en")
with (
patch.object(payments_module, "render_payment_success", fake_render),
+32 -54
View File
@@ -61,9 +61,7 @@ class SkipPathTests(unittest.IsolatedAsyncioTestCase):
async def test_no_inviter_returns_empty_payload(self):
settings = _make_settings()
subscription_service = AsyncMock()
service, _bot = _make_service(
settings=settings, subscription_service=subscription_service
)
service, _bot = _make_service(settings=settings, subscription_service=subscription_service)
# Referred_by_id is None → bail out immediately.
with patch(
"bot.services.referral_service.user_dal.get_user_by_id",
@@ -85,16 +83,14 @@ class SkipPathTests(unittest.IsolatedAsyncioTestCase):
# when the same referee buys multiple subscriptions.
settings = _make_settings(REFERRAL_ONE_BONUS_PER_REFEREE=True)
subscription_service = AsyncMock()
service, _bot = _make_service(
settings=settings, subscription_service=subscription_service
)
service, _bot = _make_service(settings=settings, subscription_service=subscription_service)
with (
patch(
"bot.services.referral_service.user_dal.get_user_by_id",
AsyncMock(
side_effect=lambda session, uid: _make_user(uid, referred_by_id=1)
if uid == 42
else _make_user(uid)
side_effect=lambda session, uid: (
_make_user(uid, referred_by_id=1) if uid == 42 else _make_user(uid)
)
),
),
patch(
@@ -118,15 +114,13 @@ class SkipPathTests(unittest.IsolatedAsyncioTestCase):
settings = _make_settings(REFERRAL_ONE_BONUS_PER_REFEREE=False)
subscription_service = AsyncMock()
subscription_service.has_active_subscription = AsyncMock(return_value=True)
service, _bot = _make_service(
settings=settings, subscription_service=subscription_service
)
service, _bot = _make_service(settings=settings, subscription_service=subscription_service)
with patch(
"bot.services.referral_service.user_dal.get_user_by_id",
AsyncMock(
side_effect=lambda session, uid: _make_user(uid, referred_by_id=1)
if uid == 42
else _make_user(uid)
side_effect=lambda session, uid: (
_make_user(uid, referred_by_id=1) if uid == 42 else _make_user(uid)
)
),
):
result = await service.apply_referral_bonuses_for_payment(
@@ -156,16 +150,14 @@ class InviterBonusTests(unittest.IsolatedAsyncioTestCase):
)
new_end = datetime(2026, 1, 1, tzinfo=timezone.utc)
subscription_service.extend_active_subscription_days = AsyncMock(return_value=new_end)
service, bot = _make_service(
settings=settings, subscription_service=subscription_service
)
service, bot = _make_service(settings=settings, subscription_service=subscription_service)
with patch(
"bot.services.referral_service.user_dal.get_user_by_id",
AsyncMock(
side_effect=lambda session, uid: _make_user(uid, referred_by_id=1)
if uid == 42
else _make_user(uid)
side_effect=lambda session, uid: (
_make_user(uid, referred_by_id=1) if uid == 42 else _make_user(uid)
)
),
):
result = await service.apply_referral_bonuses_for_payment(
@@ -204,17 +196,15 @@ class InviterBonusTests(unittest.IsolatedAsyncioTestCase):
return_value={"ok": True}
)
service, bot = _make_service(
settings=settings, subscription_service=subscription_service
)
service, bot = _make_service(settings=settings, subscription_service=subscription_service)
with (
patch(
"bot.services.referral_service.user_dal.get_user_by_id",
AsyncMock(
side_effect=lambda session, uid: _make_user(uid, referred_by_id=1)
if uid == 42
else _make_user(uid)
side_effect=lambda session, uid: (
_make_user(uid, referred_by_id=1) if uid == 42 else _make_user(uid)
)
),
),
patch(
@@ -262,16 +252,14 @@ class RefereeBonusTests(unittest.IsolatedAsyncioTestCase):
subscription_service.extend_active_subscription_days = AsyncMock(
return_value=referee_new_end
)
service, _bot = _make_service(
settings=settings, subscription_service=subscription_service
)
service, _bot = _make_service(settings=settings, subscription_service=subscription_service)
with patch(
"bot.services.referral_service.user_dal.get_user_by_id",
AsyncMock(
side_effect=lambda session, uid: _make_user(uid, referred_by_id=1)
if uid == 42
else _make_user(uid)
side_effect=lambda session, uid: (
_make_user(uid, referred_by_id=1) if uid == 42 else _make_user(uid)
)
),
):
# 3-month plan → 10-day referee bonus, 21-day inviter bonus.
@@ -303,16 +291,14 @@ class RefereeBonusTests(unittest.IsolatedAsyncioTestCase):
subscription_service = AsyncMock()
subscription_service.has_active_subscription = AsyncMock(return_value=False)
subscription_service.extend_active_subscription_days = AsyncMock(return_value=None)
service, _bot = _make_service(
settings=settings, subscription_service=subscription_service
)
service, _bot = _make_service(settings=settings, subscription_service=subscription_service)
with patch(
"bot.services.referral_service.user_dal.get_user_by_id",
AsyncMock(
side_effect=lambda session, uid: _make_user(uid, referred_by_id=1)
if uid == 42
else _make_user(uid)
side_effect=lambda session, uid: (
_make_user(uid, referred_by_id=1) if uid == 42 else _make_user(uid)
)
),
):
result = await service.apply_referral_bonuses_for_payment(
@@ -331,16 +317,14 @@ class RefereeBonusTests(unittest.IsolatedAsyncioTestCase):
subscription_service = AsyncMock()
subscription_service.has_active_subscription = AsyncMock(return_value=False)
subscription_service.extend_active_subscription_days = AsyncMock()
service, _bot = _make_service(
settings=settings, subscription_service=subscription_service
)
service, _bot = _make_service(settings=settings, subscription_service=subscription_service)
with patch(
"bot.services.referral_service.user_dal.get_user_by_id",
AsyncMock(
side_effect=lambda session, uid: _make_user(uid, referred_by_id=1)
if uid == 42
else _make_user(uid)
side_effect=lambda session, uid: (
_make_user(uid, referred_by_id=1) if uid == 42 else _make_user(uid)
)
),
):
result = await service.apply_referral_bonuses_for_payment(
@@ -359,9 +343,7 @@ class GenerateReferralLinkTests(unittest.IsolatedAsyncioTestCase):
async def test_includes_bot_username_and_referral_code(self):
settings = _make_settings()
subscription_service = AsyncMock()
service, _bot = _make_service(
settings=settings, subscription_service=subscription_service
)
service, _bot = _make_service(settings=settings, subscription_service=subscription_service)
with (
patch(
@@ -383,9 +365,7 @@ class GenerateReferralLinkTests(unittest.IsolatedAsyncioTestCase):
async def test_returns_none_when_user_missing(self):
settings = _make_settings()
subscription_service = AsyncMock()
service, _bot = _make_service(
settings=settings, subscription_service=subscription_service
)
service, _bot = _make_service(settings=settings, subscription_service=subscription_service)
with patch(
"bot.services.referral_service.user_dal.get_user_by_id",
@@ -401,9 +381,7 @@ class GenerateReferralLinkTests(unittest.IsolatedAsyncioTestCase):
async def test_returns_none_when_referral_code_unavailable(self):
settings = _make_settings()
subscription_service = AsyncMock()
service, _bot = _make_service(
settings=settings, subscription_service=subscription_service
)
service, _bot = _make_service(settings=settings, subscription_service=subscription_service)
with (
patch(
+16
View File
@@ -162,6 +162,22 @@ class SettingsTests(unittest.TestCase):
self.assertFalse(settings.SUPPORT_ADMIN_EMAIL_NOTIFICATIONS_ENABLED)
def test_subscription_purchase_description_is_localized_and_toggleable(self):
settings = Settings(
_env_file=None,
BOT_TOKEN="token",
POSTGRES_USER="app_user",
POSTGRES_PASSWORD="app_password",
SUBSCRIPTION_PURCHASE_DESCRIPTION_RU="Русский текст",
SUBSCRIPTION_PURCHASE_DESCRIPTION_EN="English text",
)
self.assertEqual(settings.subscription_purchase_description("ru"), "Русский текст")
self.assertEqual(settings.subscription_purchase_description("en"), "English text")
settings.SUBSCRIPTION_PURCHASE_DESCRIPTION_ENABLED = False
self.assertEqual(settings.subscription_purchase_description("ru"), "")
def test_payment_button_presentation_env_values_are_available(self):
"""Presentation overrides now live on each provider's BaseSettings
model instead of the central Settings verify they're loaded from
+25
View File
@@ -5,6 +5,7 @@ from types import SimpleNamespace
from bot.app.web import subscription_webapp
from bot.handlers.user import referral
from bot.handlers.user.subscription.core import _with_subscription_purchase_description
from bot.keyboards.inline.user_keyboards import (
get_bot_interface_inline_keyboard,
get_information_links_keyboard,
@@ -101,6 +102,30 @@ class UserBotMenuTests(unittest.TestCase):
self.assertIn("set_lang_ru:bot", self._callback_data(language_markup))
self.assertIn("subscribe_period:1:bot", self._callback_data(subscription_markup))
def test_subscription_purchase_description_is_prepended_before_period_selection(self):
settings = SimpleNamespace(
subscription_purchase_description=lambda language: f"Description {language}"
)
self.assertEqual(
_with_subscription_purchase_description(
"Choose period",
settings,
"en",
include=True,
),
"Description en\n\nChoose period",
)
self.assertEqual(
_with_subscription_purchase_description(
"Choose traffic",
settings,
"en",
include=False,
),
"Choose traffic",
)
def test_payment_navigation_context_keeps_bot_menu_source(self):
settings = SimpleNamespace(
payment_methods_order=[],
+2 -6
View File
@@ -58,9 +58,7 @@ class FakeRedis:
bucket.insert(0, value)
return len(bucket)
async def brpop(
self, key: str, timeout: int = 0
) -> Optional[Tuple[str, str]]:
async def brpop(self, key: str, timeout: int = 0) -> Optional[Tuple[str, str]]:
bucket = self._lists.get(key)
if bucket:
return key, bucket.pop()
@@ -230,9 +228,7 @@ class RedisLockTests(unittest.IsolatedAsyncioTestCase):
settings = _make_settings()
async with redis_infra.redis_lock(settings, "panel-sync", ttl_seconds=30) as first:
self.assertTrue(first)
async with redis_infra.redis_lock(
settings, "panel-sync", ttl_seconds=30
) as second:
async with redis_infra.redis_lock(settings, "panel-sync", ttl_seconds=30) as second:
self.assertFalse(second)
# After exit, the lock is released and can be re-acquired.
async with redis_infra.redis_lock(settings, "panel-sync", ttl_seconds=30) as again: