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