- {t("wa_hwid_devices_renewal_notice", {
+ {t("wa_hwid_devices_renewal_unavailable", {
count: Number(subscription.extra_hwid_devices || 0),
date: subscription.extra_hwid_devices_valid_until_text || "",
})}
@@ -310,7 +435,7 @@
disabled={!selectedPlan || !methods.length || payBusy}
>
{t("wa_pay")}
- {selectedPlan ? priceLabel(selectedPlan) : ""}
+ {selectedPlan ? paymentPriceLabel(selectedPlan) : ""}
{/if}
diff --git a/frontend/src/webapp/TariffDialogs.svelte b/frontend/src/webapp/TariffDialogs.svelte
index bc59390..5443438 100644
--- a/frontend/src/webapp/TariffDialogs.svelte
+++ b/frontend/src/webapp/TariffDialogs.svelte
@@ -127,6 +127,19 @@
: "";
}
+ function deviceTopupPlanTitle(plan) {
+ return t("wa_hwid_devices_package", {
+ count: Number(plan?.device_count || plan?.months || 0),
+ });
+ }
+
+ function deviceTopupPlanHint(plan) {
+ if (plan?.valid_until_text) {
+ return t("wa_hwid_devices_active_until", { date: plan.valid_until_text });
+ }
+ return plan?.subtitle || deviceTopupOptions?.tariff_name || "";
+ }
+
function tariffChangeModalDescription() {
if (!changeOptions) return "";
return changeOptions?.current
@@ -369,16 +382,7 @@
{#if !deviceTopupOptions}
{:else if deviceTopupOptions?.plans?.length}
- {#if deviceTopupOptions?.renewal_available}
-
- {:else if Number(deviceTopupOptions?.extra_hwid_devices || 0) > 0 && deviceTopupOptions?.extra_hwid_devices_valid_until_text}
+ {#if Number(deviceTopupOptions?.extra_hwid_devices || 0) > 0 && deviceTopupOptions?.extra_hwid_devices_valid_until_text}
{t("wa_hwid_devices_valid_until", {
@@ -397,12 +401,8 @@
onclick={() => (selectedDeviceTopupPlan = plan)}
>
- {t("wa_hwid_devices_package", {
- count: Number(plan.device_count || plan.months || 0),
- })}
- {plan.subtitle || deviceTopupOptions.tariff_name}
+ {deviceTopupPlanTitle(plan)}
+ {deviceTopupPlanHint(plan)}
{priceLabel(plan)}
diff --git a/locales/en.json b/locales/en.json
index c6e3fb8..bcde007 100644
--- a/locales/en.json
+++ b/locales/en.json
@@ -948,20 +948,32 @@
"buy_hwid_devices_menu_button": "+ HWID devices",
"buy_hwid_devices_button": "+{count} HWID for {price} {currency_symbol}",
"select_hwid_device_package": "Select HWID device package:",
- "select_hwid_device_renewal_package": "Select an HWID device package for the new subscription period. Your current top-up is valid until {date}.",
+ "select_hwid_device_renewal_package": "Purchased HWID devices are renewed together with subscription renewal. Your current top-up is valid until {date}.",
"choose_payment_method_hwid_devices": "Choose a payment method for extra HWID devices:",
+ "payment_hwid_renewal_toggle_on": "✅ Also renew +{count} HWID devices for {price} {currency_symbol}",
+ "payment_hwid_renewal_toggle_off": "☐ Renew +{count} HWID devices for {price} {currency_symbol}",
"no_hwid_device_packages_available": "Extra HWID devices are not configured for this tariff.",
"hwid_devices_unlimited_no_topup": "Your device limit is already unlimited.",
"payment_description_hwid_devices": "Extra HWID devices +{count}",
- "payment_successful_hwid_devices_renewal_note": "You currently have +{count} extra HWID devices valid until {date}. Buy them again if you need them for the renewed subscription period.",
- "subscription_hwid_renewal_reminder": "You have +{count} extra HWID devices valid until {date}. Renewing the subscription does not renew the device top-up automatically.",
+ "payment_successful_hwid_devices_renewal_note": "Your +{count} extra HWID devices are valid until {date}. To keep them for the next period, enable device renewal while renewing the subscription.",
+ "payment_successful_hwid_devices_renewed_note": "Your +{count} extra HWID devices were renewed with the subscription until {date}.",
+ "subscription_hwid_renewal_reminder": "You have +{count} extra HWID devices valid until {date}. When renewing the subscription, you can include these devices in the same payment.",
"wa_buy_hwid_devices": "Buy devices",
"wa_device_topup_for_tariff": "Device packages for {tariff}",
"wa_hwid_devices_package": "+{count} devices",
+ "wa_hwid_devices_renewal_package": "Renew +{count} devices",
+ "wa_hwid_devices_active_until": "Active until {date}",
+ "wa_hwid_devices_renewal_period": "From {from} to {to}",
"wa_hwid_devices_valid_until": "+{count} extra devices are valid until {date}",
- "wa_hwid_devices_renewal_notice": "Your +{count} extra devices are valid until {date}. Renewing the subscription does not renew them automatically.",
- "wa_hwid_devices_renewal_offer": "Your current +{count} device top-up is valid until {date}. Choose a package to renew devices for the new subscription period.",
- "wa_hwid_devices_renewal_prompt": "Subscription renewed. Buy devices again for the new period.",
+ "wa_hwid_devices_renewal_notice": "Your +{count} extra devices are valid until {date}. You can renew them together with the subscription.",
+ "wa_hwid_devices_renewal_checkbox": "Also renew +{count} extra devices for {price}",
+ "wa_hwid_devices_renewal_checkbox_aria": "Renew extra devices with the subscription",
+ "wa_hwid_devices_renewal_checkbox_hint": "New device period: from {from} to {to}",
+ "wa_hwid_devices_renewal_checkbox_hint_short": "Devices will be renewed for the new subscription period",
+ "wa_hwid_devices_desync_notice": "The current device top-up is valid until {date}; before the new period starts, the limit may return to the base tariff limit.",
+ "wa_hwid_devices_renewal_offer": "Your current +{count} device top-up is valid until {date}. Device renewal is handled together with subscription renewal.",
+ "wa_hwid_devices_renewal_prompt": "Subscription renewed. If devices were not renewed with it, the current device top-up remains valid until its own end date.",
+ "wa_hwid_devices_renewal_unavailable": "You currently have +{count} extra devices valid until {date}. Device renewal is not available for the selected payment method or tariff.",
"wa_no_hwid_device_options": "No device packages available",
"wa_device_topup_options_failed": "Could not load device packages",
"admin_nav_overview": "Overview",
@@ -1392,6 +1404,9 @@
"admin_user_btn_reset_trial": "Reset Trial",
"admin_user_label_extend": "Extend Subscription",
"admin_user_label_extend_days": "Days",
+ "admin_user_extend_hwid_devices": "Also extend +{count} HWID devices",
+ "admin_user_extend_hwid_devices_aria": "Extend purchased HWID devices",
+ "admin_user_extend_hwid_devices_hint": "Active device top-ups will receive the same number of extra days.",
"admin_user_btn_extend": "Extend",
"admin_user_label_telegram_msg": "Telegram Message",
"admin_user_hint_telegram_msg": "Telegram HTML formatting supported",
diff --git a/locales/ru.json b/locales/ru.json
index ee6aa30..736a2b7 100644
--- a/locales/ru.json
+++ b/locales/ru.json
@@ -948,20 +948,32 @@
"buy_hwid_devices_menu_button": "+ HWID устройства",
"buy_hwid_devices_button": "+{count} HWID за {price} {currency_symbol}",
"select_hwid_device_package": "Выберите пакет HWID устройств:",
- "select_hwid_device_renewal_package": "Выберите пакет HWID устройств для нового срока подписки. Текущая докупка действует до {date}.",
+ "select_hwid_device_renewal_package": "Продление докупленных HWID устройств выполняется вместе с продлением подписки. Текущая докупка действует до {date}.",
"choose_payment_method_hwid_devices": "Выберите способ оплаты дополнительных HWID устройств:",
+ "payment_hwid_renewal_toggle_on": "✅ Продлить также +{count} HWID устройств за {price} {currency_symbol}",
+ "payment_hwid_renewal_toggle_off": "☐ Продлить +{count} HWID устройств за {price} {currency_symbol}",
"no_hwid_device_packages_available": "Дополнительные HWID устройства для этого тарифа не настроены.",
"hwid_devices_unlimited_no_topup": "У вас уже безлимитное число устройств.",
"payment_description_hwid_devices": "Дополнительные HWID устройства +{count}",
- "payment_successful_hwid_devices_renewal_note": "У вас сейчас докуплено +{count} HWID устройств до {date}. При продлении подписки их нужно докупить заново для нового срока.",
- "subscription_hwid_renewal_reminder": "У вас докуплено +{count} HWID устройств до {date}. При продлении подписки докупку нужно оформить заново.",
+ "payment_successful_hwid_devices_renewal_note": "Докупленные +{count} HWID устройств действуют до {date}. Если нужно продлить их на следующий срок, включите продление устройств при оплате подписки.",
+ "payment_successful_hwid_devices_renewed_note": "Докупленные +{count} HWID устройств продлены вместе с подпиской до {date}.",
+ "subscription_hwid_renewal_reminder": "У вас докуплено +{count} HWID устройств до {date}. При продлении подписки можно включить продление этих устройств в тот же платеж.",
"wa_buy_hwid_devices": "Купить устройства",
"wa_device_topup_for_tariff": "Пакеты устройств для тарифа {tariff}",
"wa_hwid_devices_package": "+{count} устройств",
+ "wa_hwid_devices_renewal_package": "Продлить +{count} устройств",
+ "wa_hwid_devices_active_until": "Действует до {date}",
+ "wa_hwid_devices_renewal_period": "С {from} до {to}",
"wa_hwid_devices_valid_until": "+{count} дополнительных устройств действует до {date}",
- "wa_hwid_devices_renewal_notice": "Докупленные +{count} устройств действуют до {date}. При продлении подписки их нужно докупить заново.",
- "wa_hwid_devices_renewal_offer": "Текущая докупка +{count} устройств действует до {date}. Выберите пакет, чтобы продлить устройства на новый срок подписки.",
- "wa_hwid_devices_renewal_prompt": "Подписка продлена. Докупите устройства для нового срока.",
+ "wa_hwid_devices_renewal_notice": "Докупленные +{count} устройств действуют до {date}. Продлить их на следующий срок можно вместе с продлением подписки.",
+ "wa_hwid_devices_renewal_checkbox": "Продлить также +{count} доп. устройств за {price}",
+ "wa_hwid_devices_renewal_checkbox_aria": "Продлить дополнительные устройства вместе с подпиской",
+ "wa_hwid_devices_renewal_checkbox_hint": "Новый срок устройств: с {from} до {to}",
+ "wa_hwid_devices_renewal_checkbox_hint_short": "Устройства будут продлены на новый срок подписки",
+ "wa_hwid_devices_desync_notice": "Текущая докупка действует до {date}; до начала нового периода лимит может вернуться к базовому.",
+ "wa_hwid_devices_renewal_offer": "Текущая докупка +{count} устройств действует до {date}. Продление устройств выполняется вместе с продлением подписки.",
+ "wa_hwid_devices_renewal_prompt": "Подписка продлена. Если устройства не продлевались вместе с ней, текущая докупка действует до своей даты окончания.",
+ "wa_hwid_devices_renewal_unavailable": "Сейчас докуплено +{count} устройств до {date}. Для выбранного способа оплаты или тарифа продление этих устройств недоступно.",
"wa_no_hwid_device_options": "Нет доступных пакетов устройств",
"wa_device_topup_options_failed": "Не удалось загрузить пакеты устройств",
"admin_nav_overview": "Обзор",
@@ -1392,6 +1404,9 @@
"admin_user_btn_reset_trial": "Сбросить триал",
"admin_user_label_extend": "Продлить подписку",
"admin_user_label_extend_days": "Дней",
+ "admin_user_extend_hwid_devices": "Продлить также +{count} HWID устройств",
+ "admin_user_extend_hwid_devices_aria": "Продлить докупленные HWID устройства",
+ "admin_user_extend_hwid_devices_hint": "Срок действующих докупок устройств увеличится на те же дни.",
"admin_user_btn_extend": "Продлить",
"admin_user_label_telegram_msg": "Сообщение в Telegram",
"admin_user_hint_telegram_msg": "Поддерживается HTML-разметка Telegram",
diff --git a/tests/test_admin_user_hwid_limit.py b/tests/test_admin_user_hwid_limit.py
index d86b13f..30644c2 100644
--- a/tests/test_admin_user_hwid_limit.py
+++ b/tests/test_admin_user_hwid_limit.py
@@ -1,5 +1,6 @@
import json
import unittest
+from datetime import datetime, timezone
from types import SimpleNamespace
from unittest.mock import AsyncMock, patch
@@ -118,8 +119,76 @@ class AdminUserHwidLimitRouteTests(unittest.IsolatedAsyncioTestCase):
response = await admin_users.admin_user_hwid_device_limit_route(request)
self.assertEqual(response.status, 400)
- self.assertEqual(json.loads(response.text)["error"], "invalid_hwid_device_limit")
- subscription_service.sync_hwid_device_limit_to_panel.assert_not_awaited()
+
+
+class AdminUserExtendRouteTests(unittest.IsolatedAsyncioTestCase):
+ async def test_extend_route_can_skip_hwid_device_extension(self):
+ session = FakeSession()
+ new_end = datetime(2099, 2, 1, tzinfo=timezone.utc)
+ subscription_service = SimpleNamespace(
+ extend_active_subscription_days=AsyncMock(return_value=new_end)
+ )
+ request = FakeRequest(
+ {"days": 10, "extend_hwid_devices": False},
+ session,
+ subscription_service,
+ )
+
+ with (
+ patch.object(admin_users, "_require_admin_user_id", return_value=100),
+ patch.object(admin_users.message_log_dal, "create_message_log", AsyncMock()) as log,
+ patch.object(
+ admin_users.subscription_dal,
+ "get_active_subscription_by_user_id",
+ AsyncMock(return_value=SimpleNamespace(subscription_id=1)),
+ ),
+ patch.object(admin_users, "_invalidate_after_admin_user_mutation", AsyncMock()),
+ patch.object(admin_users, "_serialize_subscription", return_value={"ok": True}),
+ ):
+ response = await admin_users.admin_user_extend_route(request)
+
+ self.assertEqual(response.status, 200)
+ subscription_service.extend_active_subscription_days.assert_awaited_once_with(
+ session,
+ 42,
+ 10,
+ "admin_extend_subscription_webapp",
+ extend_hwid_devices=False,
+ )
+ self.assertIn("hwid=no", log.await_args.args[1]["content"])
+ self.assertTrue(session.committed)
+
+ async def test_extend_route_extends_hwid_devices_by_default(self):
+ session = FakeSession()
+ new_end = datetime(2099, 2, 1, tzinfo=timezone.utc)
+ subscription_service = SimpleNamespace(
+ extend_active_subscription_days=AsyncMock(return_value=new_end)
+ )
+ request = FakeRequest({"days": 10}, session, subscription_service)
+
+ with (
+ patch.object(admin_users, "_require_admin_user_id", return_value=100),
+ patch.object(admin_users.message_log_dal, "create_message_log", AsyncMock()) as log,
+ patch.object(
+ admin_users.subscription_dal,
+ "get_active_subscription_by_user_id",
+ AsyncMock(return_value=SimpleNamespace(subscription_id=1)),
+ ),
+ patch.object(admin_users, "_invalidate_after_admin_user_mutation", AsyncMock()),
+ patch.object(admin_users, "_serialize_subscription", return_value={"ok": True}),
+ ):
+ response = await admin_users.admin_user_extend_route(request)
+
+ self.assertEqual(response.status, 200)
+ subscription_service.extend_active_subscription_days.assert_awaited_once_with(
+ session,
+ 42,
+ 10,
+ "admin_extend_subscription_webapp",
+ extend_hwid_devices=True,
+ )
+ self.assertIn("hwid=yes", log.await_args.args[1]["content"])
+ self.assertTrue(session.committed)
async def test_over_max_limit_is_rejected(self):
session = FakeSession()
diff --git a/tests/test_auto_renew_wiring.py b/tests/test_auto_renew_wiring.py
index 450515f..cc8561e 100644
--- a/tests/test_auto_renew_wiring.py
+++ b/tests/test_auto_renew_wiring.py
@@ -20,9 +20,10 @@ return.
"""
import unittest
+from datetime import datetime, timezone
from types import SimpleNamespace
from typing import Any, Dict, List, Optional
-from unittest.mock import patch
+from unittest.mock import AsyncMock, patch
from bot.services.subscription_service_impl.renewal import RenewalMixin
@@ -276,6 +277,59 @@ class ChargeRenewalHappyPathTests(unittest.IsolatedAsyncioTestCase):
self.assertEqual(yk.calls[0]["metadata"]["subscription_months"], "1")
self.assertEqual(yk.calls[0]["amount"], 99.0)
+ async def test_includes_hwid_device_renewal_in_saved_method_charge(self):
+ yk = _FakeYooKassaService(response={"id": "auto-pay-8", "status": "pending"})
+ mixin = _make_mixin(yk=yk, price_for_months=399.0)
+ valid_from = datetime(2099, 2, 1, tzinfo=timezone.utc)
+ valid_until = datetime(2099, 3, 1, tzinfo=timezone.utc)
+ mixin.quote_hwid_device_renewal_for_subscription = AsyncMock(
+ return_value={
+ "device_count": 2,
+ "price": 50.0,
+ "full_price": 50.0,
+ "valid_from": valid_from,
+ "valid_until": valid_until,
+ "pricing_period_months": 1,
+ "proration_ratio": 1.0,
+ }
+ )
+
+ with patch(
+ "db.dal.user_billing_dal.get_user_default_payment_method",
+ _stub_default_pm,
+ ):
+ ok = await mixin.charge_subscription_renewal(
+ session=None,
+ sub=_FakeSub(
+ auto_renew_enabled=True,
+ provider="yookassa",
+ user_id=77,
+ subscription_id=555,
+ tariff_key="standard",
+ duration_months=1,
+ ),
+ )
+
+ self.assertTrue(ok)
+ self.assertEqual(len(yk.calls), 1)
+ call = yk.calls[0]
+ self.assertEqual(call["amount"], 449.0)
+ meta = call["metadata"]
+ self.assertEqual(meta["sale_mode"], "subscription@standard")
+ self.assertEqual(meta["hwid_devices"], "2")
+ self.assertEqual(meta["hwid_valid_from"], valid_from.isoformat())
+ self.assertEqual(meta["hwid_valid_until"], valid_until.isoformat())
+ self.assertEqual(meta["hwid_pricing_period_months"], "1")
+ self.assertEqual(meta["hwid_proration_ratio"], "1.0")
+ self.assertEqual(meta["hwid_full_price"], "50.0")
+ mixin.quote_hwid_device_renewal_for_subscription.assert_awaited_once_with(
+ None,
+ user_id=77,
+ target_tariff_key="standard",
+ months=1,
+ currency="rub",
+ )
+
if __name__ == "__main__": # pragma: no cover
unittest.main()
diff --git a/tests/test_hwid_device_bonus_extension.py b/tests/test_hwid_device_bonus_extension.py
new file mode 100644
index 0000000..1dc2d65
--- /dev/null
+++ b/tests/test_hwid_device_bonus_extension.py
@@ -0,0 +1,72 @@
+import unittest
+from datetime import datetime, timedelta, timezone
+from types import SimpleNamespace
+from unittest.mock import AsyncMock
+
+from db.dal import tariff_dal
+
+
+class _ScalarResult:
+ def __init__(self, records):
+ self._records = records
+
+ def scalars(self):
+ return self
+
+ def all(self):
+ return self._records
+
+
+class HwidDeviceBonusExtensionTests(unittest.IsolatedAsyncioTestCase):
+ async def test_extends_tail_purchase_when_it_covers_subscription_end(self):
+ subscription_end = datetime(2099, 2, 1, tzinfo=timezone.utc)
+ future_purchase = SimpleNamespace(
+ valid_until=subscription_end,
+ )
+ session = SimpleNamespace(
+ execute=AsyncMock(return_value=_ScalarResult([future_purchase])),
+ flush=AsyncMock(),
+ )
+
+ updated = await tariff_dal.extend_hwid_device_purchases_for_subscription_bonus(
+ session,
+ subscription_id=10,
+ at=datetime(2099, 1, 1, tzinfo=timezone.utc),
+ subscription_end_before=subscription_end,
+ delta=timedelta(days=7),
+ )
+
+ self.assertEqual(updated, 1)
+ self.assertEqual(future_purchase.valid_until, subscription_end + timedelta(days=7))
+ session.flush.assert_awaited_once()
+ self.assertEqual(session.execute.await_count, 1)
+
+ async def test_extends_active_purchase_when_no_tail_purchase_exists(self):
+ active_until = datetime(2099, 1, 16, tzinfo=timezone.utc)
+ active_purchase = SimpleNamespace(valid_until=active_until)
+ session = SimpleNamespace(
+ execute=AsyncMock(
+ side_effect=[
+ _ScalarResult([]),
+ _ScalarResult([active_purchase]),
+ ]
+ ),
+ flush=AsyncMock(),
+ )
+
+ updated = await tariff_dal.extend_hwid_device_purchases_for_subscription_bonus(
+ session,
+ subscription_id=10,
+ at=datetime(2099, 1, 1, tzinfo=timezone.utc),
+ subscription_end_before=datetime(2099, 2, 1, tzinfo=timezone.utc),
+ delta=timedelta(days=7),
+ )
+
+ self.assertEqual(updated, 1)
+ self.assertEqual(active_purchase.valid_until, active_until + timedelta(days=7))
+ session.flush.assert_awaited_once()
+ self.assertEqual(session.execute.await_count, 2)
+
+
+if __name__ == "__main__": # pragma: no cover
+ unittest.main()
diff --git a/tests/test_hwid_device_topup.py b/tests/test_hwid_device_topup.py
index e1f67cf..1e2910e 100644
--- a/tests/test_hwid_device_topup.py
+++ b/tests/test_hwid_device_topup.py
@@ -273,6 +273,115 @@ class HwidDeviceTopupBehaviourTests(unittest.IsolatedAsyncioTestCase):
self.assertEqual(quote["price"], 150)
self.assertAlmostEqual(quote["proration_ratio"], 1.0)
+ async def test_quote_keeps_immediate_and_renewal_windows_separate(self):
+ with tempfile.TemporaryDirectory() as tmpdir:
+ settings = _make_settings(
+ tmpdir,
+ _tariffs_config_payload(
+ hwid_device_packages={
+ "rub": [{"count": 1, "price": 50, "prices": {"1": 50}}],
+ "stars": [],
+ }
+ ),
+ )
+ service = _make_service(settings)
+ sub = _make_sub()
+ sub.start_date = datetime(2098, 12, 1, tzinfo=timezone.utc)
+ sub.end_date = datetime(2099, 2, 1, tzinfo=timezone.utc)
+ user = _make_user()
+ now = datetime(2099, 1, 2, tzinfo=timezone.utc)
+ existing_extra_until = datetime(2099, 1, 17, tzinfo=timezone.utc)
+
+ with (
+ patch(
+ "bot.services.subscription_service_impl.devices.user_dal.get_user_by_id",
+ AsyncMock(return_value=user),
+ ),
+ patch(
+ "bot.services.subscription_service_impl.devices.subscription_dal.get_active_subscription_by_user_id",
+ AsyncMock(return_value=sub),
+ ),
+ patch(
+ "bot.services.subscription_service_impl.devices.tariff_dal.get_hwid_device_entitlement_summary",
+ AsyncMock(
+ return_value={
+ "active_devices": 1,
+ "active_until": existing_extra_until,
+ }
+ ),
+ ),
+ ):
+ immediate = await service.quote_hwid_device_topup(
+ session=AsyncMock(),
+ user_id=42,
+ device_count=1,
+ tariff_key="standard",
+ currency="rub",
+ renewal=False,
+ now=now,
+ )
+ renewal = await service.quote_hwid_device_topup(
+ session=AsyncMock(),
+ user_id=42,
+ device_count=1,
+ tariff_key="standard",
+ currency="rub",
+ renewal=True,
+ now=now,
+ )
+
+ self.assertIsNotNone(immediate)
+ self.assertIsNotNone(renewal)
+ self.assertEqual(immediate["valid_from"], now)
+ self.assertEqual(immediate["valid_until"], sub.end_date)
+ self.assertEqual(immediate["price"], 50)
+ self.assertEqual(renewal["valid_from"], existing_extra_until)
+ self.assertEqual(renewal["valid_until"], sub.end_date)
+ self.assertLess(renewal["price"], immediate["price"])
+
+ async def test_subscription_renewal_quote_prices_current_active_extra_devices(self):
+ with tempfile.TemporaryDirectory() as tmpdir:
+ settings = _make_settings(tmpdir, _tariffs_config_payload())
+ service = _make_service(settings)
+ sub = _make_sub()
+ sub.end_date = datetime(2099, 2, 1, tzinfo=timezone.utc)
+ user = _make_user()
+
+ with (
+ patch(
+ "bot.services.subscription_service_impl.devices.user_dal.get_user_by_id",
+ AsyncMock(return_value=user),
+ ),
+ patch(
+ "bot.services.subscription_service_impl.devices.subscription_dal.get_active_subscription_by_user_id",
+ AsyncMock(return_value=sub),
+ ),
+ patch(
+ "bot.services.subscription_service_impl.devices.tariff_dal.get_hwid_device_entitlement_summary",
+ AsyncMock(
+ return_value={
+ "active_devices": 4,
+ "active_until": datetime(2099, 1, 16, tzinfo=timezone.utc),
+ }
+ ),
+ ),
+ ):
+ quote = await service.quote_hwid_device_renewal_for_subscription(
+ session=AsyncMock(),
+ user_id=42,
+ target_tariff_key="standard",
+ months=1,
+ currency="rub",
+ now=datetime(2099, 1, 1, tzinfo=timezone.utc),
+ )
+
+ self.assertIsNotNone(quote)
+ self.assertEqual(quote["device_count"], 4)
+ self.assertEqual(quote["price"], 170)
+ self.assertEqual(sorted(quote["package_counts"]), [1, 3])
+ self.assertEqual(quote["valid_from"], sub.end_date)
+ self.assertEqual(quote["valid_until"], datetime(2099, 3, 1, tzinfo=timezone.utc))
+
async def test_unlimited_subscriber_returns_noop_payload(self):
# hwid_device_limit == 0 means unlimited — top-up makes no sense and must skip.
with tempfile.TemporaryDirectory() as tmpdir:
diff --git a/tests/test_hwid_tariff_switch_conversion.py b/tests/test_hwid_tariff_switch_conversion.py
index d3bdb9c..6b7f71d 100644
--- a/tests/test_hwid_tariff_switch_conversion.py
+++ b/tests/test_hwid_tariff_switch_conversion.py
@@ -185,6 +185,97 @@ class HwidTariffSwitchConversionTests(unittest.IsolatedAsyncioTestCase):
self.assertEqual(change_payload["converted_hwid_value_rub"], 50)
self.assertEqual(change_payload["converted_hwid_days"], 7)
+ async def test_paid_switch_records_payment_id_in_single_tariff_change(self):
+ with tempfile.TemporaryDirectory() as tmpdir:
+ settings = _settings(tmpdir)
+ service = _service(settings)
+ user = SimpleNamespace(
+ user_id=42,
+ telegram_id=42,
+ panel_user_uuid="panel-user",
+ email=None,
+ username="u",
+ first_name="U",
+ last_name="L",
+ )
+ sub = SimpleNamespace(
+ subscription_id=11,
+ user_id=42,
+ panel_user_uuid="panel-user",
+ panel_subscription_uuid="panel-sub",
+ tariff_key="basic",
+ start_date=datetime(2099, 1, 1, tzinfo=timezone.utc),
+ end_date=datetime(2099, 2, 1, tzinfo=timezone.utc),
+ effective_monthly_price_rub=100,
+ premium_topup_balance_bytes=0,
+ premium_topup_used_bytes=0,
+ premium_used_bytes=0,
+ topup_balance_bytes=0,
+ regular_bonus_bytes=0,
+ regular_unlimited_override=False,
+ traffic_used_bytes=0,
+ extra_hwid_devices=0,
+ hwid_device_limit=3,
+ )
+ updated = SimpleNamespace(**{**sub.__dict__, "tariff_key": "pro"})
+ updated.hwid_device_limit = 5
+ updated.extra_hwid_devices = 0
+ updated.traffic_limit_bytes = 200 * (1024**3)
+ updated.premium_is_limited = False
+ updated.effective_monthly_price_rub = 200
+
+ with (
+ patch(
+ "bot.services.subscription_service_impl.lifecycle.user_dal.get_user_by_id",
+ AsyncMock(return_value=user),
+ ),
+ patch(
+ "bot.services.subscription_service_impl.lifecycle.subscription_dal.get_active_subscription_by_user_id",
+ AsyncMock(return_value=sub),
+ ),
+ patch.object(
+ service,
+ "calculate_tariff_switch_options_with_hwid",
+ AsyncMock(
+ return_value={
+ "mode": "period_to_period",
+ "remaining_days": 20,
+ "recalc_days": 20,
+ "paid_diff_rub": 50,
+ "target_monthly_rub": 200,
+ "converted_hwid_value_rub": 0,
+ "converted_hwid_days": 0,
+ "convertible_hwid_purchase_ids": [],
+ }
+ ),
+ ),
+ patch(
+ "bot.services.subscription_service_impl.lifecycle.tariff_dal.sum_active_hwid_devices",
+ AsyncMock(return_value=0),
+ ),
+ patch(
+ "bot.services.subscription_service_impl.lifecycle.subscription_dal.update_subscription",
+ AsyncMock(return_value=updated),
+ ),
+ patch(
+ "bot.services.subscription_service_impl.lifecycle.tariff_dal.create_tariff_change",
+ AsyncMock(),
+ ) as create_change,
+ ):
+ result = await service.switch_tariff_without_payment(
+ AsyncMock(),
+ user_id=42,
+ target_tariff_key="pro",
+ mode="paid_diff",
+ payment_id=99,
+ )
+
+ self.assertEqual(result["tariff_key"], "pro")
+ create_change.assert_awaited_once()
+ change_payload = create_change.await_args.args[1]
+ self.assertEqual(change_payload["payment_id"], 99)
+ self.assertEqual(change_payload["mode"], "paid_diff")
+
if __name__ == "__main__": # pragma: no cover
unittest.main()
diff --git a/tests/test_payment_provider_registry.py b/tests/test_payment_provider_registry.py
index b5e002c..fd05e27 100644
--- a/tests/test_payment_provider_registry.py
+++ b/tests/test_payment_provider_registry.py
@@ -30,7 +30,10 @@ from bot.payment_providers.shared import (
sale_mode_is_traffic,
sale_mode_tariff_key,
)
-from bot.payment_providers.yookassa import _resolve_yookassa_activation_amounts
+from bot.payment_providers.yookassa import (
+ _parse_saved_list_payload,
+ _resolve_yookassa_activation_amounts,
+)
from config.settings import Settings
_LEGACY_PROVIDER_FILES = [
@@ -201,6 +204,47 @@ def test_provider_presentation_ignores_cross_language_override():
assert resolve_provider_presentation(spec, settings, language="en").webapp_label == "YooKassa"
+def test_subscription_hwid_renewal_token_adds_quote_to_callback_parts():
+ service = SimpleNamespace(
+ quote_hwid_device_renewal_for_subscription=AsyncMock(
+ return_value={
+ "device_count": 2,
+ "price": 50,
+ "valid_from": "2099-02-01",
+ "valid_until": "2099-03-01",
+ }
+ )
+ )
+ session = AsyncMock()
+
+ parts, quote = asyncio.run(
+ quote_hwid_callback_parts(
+ session=session,
+ user_id=77,
+ parts=PaymentCallbackParts(
+ months=1,
+ price=100,
+ sale_mode="subscription@basic|hwid_renewal",
+ ),
+ subscription_service=service,
+ currency="rub",
+ )
+ )
+
+ assert parts is not None
+ assert quote is not None
+ assert parts.months == 1
+ assert parts.price == 150
+ assert quote["device_count"] == 2
+ service.quote_hwid_device_renewal_for_subscription.assert_awaited_once_with(
+ session,
+ user_id=77,
+ target_tariff_key="basic",
+ months=1,
+ currency="rub",
+ )
+
+
def test_payment_method_keyboard_uses_custom_telegram_text_without_changing_callback(monkeypatch):
monkeypatch.setenv("WATA_ENABLED", "True")
monkeypatch.setenv("PAYMENT_WATA_TELEGRAM_LABEL_EN", "Wata custom")
@@ -502,3 +546,19 @@ def test_yookassa_hwid_metadata_rejects_fractional_device_count():
traffic_gb_raw=None,
hwid_devices_raw="1.9",
)
+
+
+def test_yookassa_saved_card_payload_parser_accepts_new_and_legacy_formats():
+ assert _parse_saved_list_payload("1:100:0:subscription@vip|hwid_renewal") == (
+ 1,
+ 100,
+ 0,
+ "subscription@vip|hwid_renewal",
+ )
+ assert _parse_saved_list_payload("1:100:subscription@vip|hwid_renewal") == (
+ 1,
+ 100,
+ 0,
+ "subscription@vip|hwid_renewal",
+ )
+ assert _parse_saved_list_payload("bad:100:subscription") is None
diff --git a/tests/test_payment_webhook_notifications.py b/tests/test_payment_webhook_notifications.py
new file mode 100644
index 0000000..d0f1067
--- /dev/null
+++ b/tests/test_payment_webhook_notifications.py
@@ -0,0 +1,87 @@
+from types import SimpleNamespace
+from unittest import IsolatedAsyncioTestCase
+from unittest.mock import AsyncMock, patch
+
+from bot.payment_providers.shared import webhooks
+from bot.payment_providers.shared.success import PaymentSuccessRequest, finalize_successful_payment
+
+
+class _PaymentWithLazyUser:
+ payment_id = 12
+ user_id = 42
+
+ @property
+ def user(self):
+ raise RuntimeError("lazy relationship access is not allowed here")
+
+
+class _I18n:
+ def gettext(self, _language, key, **_kwargs):
+ return key
+
+
+class PaymentWebhookNotificationTests(IsolatedAsyncioTestCase):
+ async def test_failed_payment_notification_loads_user_explicitly(self):
+ user = SimpleNamespace(user_id=42, language_code="ru", email="u@example.test")
+ bot = SimpleNamespace(send_message=AsyncMock())
+ settings = SimpleNamespace(DEFAULT_LANGUAGE="en", SUBSCRIPTION_MINI_APP_URL="")
+
+ with (
+ patch.object(
+ webhooks.user_dal,
+ "get_user_by_id",
+ AsyncMock(return_value=user),
+ ) as get_user,
+ patch.object(
+ webhooks,
+ "send_user_notification_email",
+ AsyncMock(),
+ ) as send_email,
+ ):
+ await webhooks.notify_user_payment_failed(
+ bot=bot,
+ settings=settings,
+ i18n=_I18n(),
+ session=AsyncMock(),
+ payment=_PaymentWithLazyUser(),
+ )
+
+ get_user.assert_awaited_once()
+ bot.send_message.assert_awaited_once_with(42, "payment_failed")
+ send_email.assert_awaited_once()
+
+ async def test_finalize_failure_marks_payment_retryable(self):
+ session = AsyncMock()
+ payment = SimpleNamespace(payment_id=12, user_id=42, status="succeeded")
+ subscription_service = SimpleNamespace(
+ activate_subscription=AsyncMock(side_effect=RuntimeError("panel failed"))
+ )
+
+ with patch(
+ "bot.payment_providers.shared.success.payment_dal.update_payment_status_by_db_id",
+ AsyncMock(return_value=payment),
+ ) as update_status:
+ result = await finalize_successful_payment(
+ PaymentSuccessRequest(
+ bot=SimpleNamespace(),
+ settings=SimpleNamespace(DEFAULT_LANGUAGE="en"),
+ i18n=_I18n(),
+ session=session,
+ subscription_service=subscription_service,
+ referral_service=SimpleNamespace(),
+ payment=payment,
+ user_id=42,
+ amount=50,
+ currency="RUB",
+ sale_mode="hwid_devices@standard",
+ months=1,
+ traffic_amount=1,
+ provider_subscription="platega",
+ provider_notification="platega",
+ )
+ )
+
+ self.assertIsNone(result)
+ session.rollback.assert_awaited_once()
+ update_status.assert_awaited_once_with(session, 12, "activation_failed")
+ session.commit.assert_awaited_once()
diff --git a/tests/test_subscription_service_behavior.py b/tests/test_subscription_service_behavior.py
index a3eb2a5..5caf06a 100644
--- a/tests/test_subscription_service_behavior.py
+++ b/tests/test_subscription_service_behavior.py
@@ -433,6 +433,103 @@ class SubscriptionServiceActivationDispatchTests(unittest.IsolatedAsyncioTestCas
self.assertEqual(kwargs["tariff_key"], "standard")
self.assertEqual(kwargs["payment_db_id"], 12)
+ async def test_activate_subscription_records_hwid_renewal_without_inflating_tariff_price(self):
+ with tempfile.TemporaryDirectory() as tmpdir:
+ settings = _make_settings(_tariffs_config_payload(), tmpdir)
+ service = _make_service(settings)
+ service._get_or_create_panel_user_link_details = AsyncMock(
+ return_value=("panel-user", "short-uuid", "short", False)
+ )
+ service.panel_service.update_user_details_on_panel = AsyncMock(
+ return_value={"subscriptionUrl": "https://panel/sub", "shortUuid": "short"}
+ )
+ service._send_payment_success_email = AsyncMock()
+ now = datetime.now(timezone.utc)
+ current_end = now + timedelta(days=20)
+ current_sub = SimpleNamespace(
+ subscription_id=10,
+ end_date=current_end,
+ tariff_key="standard",
+ topup_balance_bytes=0,
+ extra_hwid_devices=1,
+ premium_topup_balance_bytes=0,
+ premium_topup_used_bytes=0,
+ premium_used_bytes=0,
+ premium_period_start_at=None,
+ regular_bonus_bytes=0,
+ regular_unlimited_override=False,
+ )
+ updated_sub = SimpleNamespace(subscription_id=10)
+ payment = SimpleNamespace(
+ purchased_hwid_devices=1,
+ hwid_valid_from=current_end,
+ hwid_valid_until=current_end + timedelta(days=30),
+ hwid_full_price=50,
+ hwid_pricing_period_months=1,
+ hwid_proration_ratio=1.0,
+ )
+ db_user = SimpleNamespace(
+ user_id=42,
+ panel_user_uuid="panel-user",
+ telegram_id=42,
+ username="alice",
+ email=None,
+ language_code="en",
+ )
+
+ with (
+ patch(
+ "bot.services.subscription_service_impl.lifecycle.user_dal.get_user_by_id",
+ AsyncMock(return_value=db_user),
+ ),
+ patch(
+ "bot.services.subscription_service_impl.lifecycle.payment_dal.get_payment_by_db_id",
+ AsyncMock(return_value=payment),
+ ),
+ patch(
+ "bot.services.subscription_service_impl.lifecycle.subscription_dal.get_active_subscription_by_user_id",
+ AsyncMock(return_value=current_sub),
+ ),
+ patch(
+ "bot.services.subscription_service_impl.lifecycle.subscription_dal.deactivate_other_active_subscriptions",
+ AsyncMock(),
+ ),
+ patch(
+ "bot.services.subscription_service_impl.lifecycle.subscription_dal.upsert_subscription",
+ AsyncMock(return_value=updated_sub),
+ ) as upsert_subscription,
+ patch(
+ "bot.services.subscription_service_impl.lifecycle.tariff_dal.get_hwid_device_entitlement_summary",
+ AsyncMock(
+ return_value={
+ "active_devices": 1,
+ "active_until": current_end,
+ }
+ ),
+ ),
+ patch(
+ "bot.services.subscription_service_impl.lifecycle.tariff_dal.create_hwid_device_purchase",
+ AsyncMock(),
+ ) as create_hwid_purchase,
+ ):
+ result = await service.activate_subscription(
+ session=AsyncMock(),
+ user_id=42,
+ months=1,
+ payment_amount=150,
+ payment_db_id=99,
+ sale_mode="subscription@standard",
+ )
+
+ self.assertEqual(result["hwid_devices_renewed_count"], 1)
+ sub_payload = upsert_subscription.await_args.args[1]
+ self.assertEqual(sub_payload["effective_monthly_price_rub"], 100)
+ create_hwid_purchase.assert_awaited_once()
+ purchase_kwargs = create_hwid_purchase.await_args.kwargs
+ self.assertEqual(purchase_kwargs["payment_id"], 99)
+ self.assertEqual(purchase_kwargs["purchased_devices"], 1)
+ self.assertEqual(purchase_kwargs["valid_from"], current_end)
+
class SubscriptionServiceBonusExtensionTests(unittest.IsolatedAsyncioTestCase):
async def test_referral_extension_preserves_existing_tariff_limit(self):
@@ -479,6 +576,10 @@ class SubscriptionServiceBonusExtensionTests(unittest.IsolatedAsyncioTestCase):
"bot.services.subscription_service_impl.lifecycle.subscription_dal.update_subscription",
AsyncMock(),
) as update_subscription,
+ patch(
+ "bot.services.subscription_service_impl.lifecycle.tariff_dal.extend_hwid_device_purchases_for_subscription_bonus",
+ AsyncMock(return_value=1),
+ ) as extend_hwid,
):
await service.extend_active_subscription_days(
session=AsyncMock(),
@@ -488,10 +589,68 @@ class SubscriptionServiceBonusExtensionTests(unittest.IsolatedAsyncioTestCase):
)
update_subscription.assert_not_awaited()
+ extend_hwid.assert_awaited_once()
+ self.assertEqual(extend_hwid.await_args.kwargs["subscription_id"], 10)
+ self.assertEqual(extend_hwid.await_args.kwargs["delta"], timedelta(days=3))
payload = service.panel_service.update_user_details_on_panel.await_args.args[1]
self.assertNotIn("trafficLimitBytes", payload)
self.assertNotIn("trafficLimitStrategy", payload)
+ async def test_admin_extension_can_skip_hwid_purchase_extension(self):
+ with tempfile.TemporaryDirectory() as tmpdir:
+ settings = _make_settings(
+ _tariffs_config_payload(),
+ tmpdir,
+ USER_TRAFFIC_LIMIT_GB=999,
+ )
+ service = _make_service(settings)
+ service._get_or_create_panel_user_link_details = AsyncMock(
+ return_value=("panel-user", "short-uuid", "short", False)
+ )
+ service.panel_service.update_user_details_on_panel = AsyncMock(
+ return_value={"ok": True}
+ )
+ active_sub = SimpleNamespace(
+ subscription_id=10,
+ end_date=datetime.now(timezone.utc) + timedelta(days=5),
+ traffic_limit_bytes=100 * GIB,
+ tariff_key="standard",
+ )
+ updated_sub = SimpleNamespace(
+ subscription_id=10,
+ end_date=active_sub.end_date + timedelta(days=3),
+ traffic_limit_bytes=100 * GIB,
+ tariff_key="standard",
+ )
+
+ with (
+ patch(
+ "bot.services.subscription_service_impl.lifecycle.user_dal.get_user_by_id",
+ AsyncMock(return_value=SimpleNamespace(user_id=42)),
+ ),
+ patch(
+ "bot.services.subscription_service_impl.lifecycle.subscription_dal.get_active_subscription_by_user_id",
+ AsyncMock(return_value=active_sub),
+ ),
+ patch(
+ "bot.services.subscription_service_impl.lifecycle.subscription_dal.update_subscription_end_date",
+ AsyncMock(return_value=updated_sub),
+ ),
+ patch(
+ "bot.services.subscription_service_impl.lifecycle.tariff_dal.extend_hwid_device_purchases_for_subscription_bonus",
+ AsyncMock(return_value=1),
+ ) as extend_hwid,
+ ):
+ await service.extend_active_subscription_days(
+ session=AsyncMock(),
+ user_id=42,
+ bonus_days=3,
+ reason="admin_extend_subscription_webapp",
+ extend_hwid_devices=False,
+ )
+
+ extend_hwid.assert_not_awaited()
+
class SubscriptionServiceActiveDetailsTests(unittest.IsolatedAsyncioTestCase):
def _local_active_sub(self) -> SimpleNamespace:
diff --git a/tests/test_user_bot_menu.py b/tests/test_user_bot_menu.py
index 0d43aad..ff48f77 100644
--- a/tests/test_user_bot_menu.py
+++ b/tests/test_user_bot_menu.py
@@ -20,9 +20,11 @@ from bot.keyboards.inline.user_keyboards import (
get_subscription_options_keyboard,
get_tariff_catalog_keyboard,
get_tariff_periods_keyboard,
+ get_yk_autopay_choice_keyboard,
payment_methods_back_callback,
payment_options_back_callback,
)
+from bot.middlewares.i18n import LOCALE_KEY_ALIASES
from config.tariffs_config import TariffsConfig
@@ -31,6 +33,7 @@ class JsonI18nStub:
self.translations = json.loads(Path("locales/en.json").read_text(encoding="utf-8"))
def gettext(self, lang, key, **kwargs):
+ key = LOCALE_KEY_ALIASES.get(key, key)
text = self.translations[key]
return text.format(**kwargs) if kwargs else text
@@ -248,6 +251,63 @@ class UserBotMenuTests(unittest.TestCase):
"subscribe_period:1:bot",
)
+ def test_payment_navigation_context_ignores_hwid_renewal_token(self):
+ self.assertEqual(
+ payment_options_back_callback("subscription@basic|bot|hwid_renewal"),
+ "tariff:select:basic:bot",
+ )
+ self.assertEqual(
+ payment_methods_back_callback("1", "subscription@basic|bot|hwid_renewal"),
+ "tariff:period:basic:1:bot",
+ )
+
+ def test_payment_method_keyboard_adds_hwid_renewal_toggle(self):
+ settings = SimpleNamespace(payment_methods_order=[])
+ quote = {"device_count": 2, "price": 50}
+
+ selected = get_payment_method_keyboard(
+ 1,
+ 100,
+ None,
+ "RUB",
+ "en",
+ self.i18n,
+ settings,
+ sale_mode="subscription@basic|bot",
+ hwid_renewal_quote=quote,
+ hwid_renewal_selected=True,
+ )
+ disabled = get_payment_method_keyboard(
+ 1,
+ 100,
+ None,
+ "RUB",
+ "en",
+ self.i18n,
+ settings,
+ sale_mode="subscription@basic|bot",
+ hwid_renewal_quote=quote,
+ hwid_renewal_selected=False,
+ )
+
+ self.assertIn("tariff:period:basic:1:bot:no_hwid", self._callback_data(selected))
+ self.assertIn("tariff:period:basic:1:bot:hwid", self._callback_data(disabled))
+
+ def test_yookassa_saved_card_choice_keeps_sale_mode_after_page_token(self):
+ markup = get_yk_autopay_choice_keyboard(
+ 1,
+ 100,
+ "en",
+ self.i18n,
+ has_saved_cards=True,
+ sale_mode="subscription@basic|hwid_renewal",
+ )
+
+ self.assertIn(
+ "pay_yk_saved_list:1:100:0:subscription@basic|hwid_renewal",
+ self._callback_data(markup),
+ )
+
def test_tariff_back_buttons_return_to_previous_level(self):
tariff = SimpleNamespace(
key="basic",
diff --git a/tests/test_webapp_device_topup_options.py b/tests/test_webapp_device_topup_options.py
index 09caeba..46d1f02 100644
--- a/tests/test_webapp_device_topup_options.py
+++ b/tests/test_webapp_device_topup_options.py
@@ -2,7 +2,7 @@ import json
from datetime import datetime, timezone
from types import SimpleNamespace
from unittest import IsolatedAsyncioTestCase
-from unittest.mock import AsyncMock, patch
+from unittest.mock import ANY, AsyncMock, patch
import bot.app.web.subscription_webapp # noqa: F401
from bot.app.web.webapp import billing as billing_module
@@ -95,6 +95,101 @@ class WebAppDeviceTopupOptionsTests(IsolatedAsyncioTestCase):
self.assertEqual(payload["plans"][0]["valid_from"], valid_from.isoformat())
self.assertEqual(payload["plans"][0]["valid_until"], active_until.isoformat())
+ async def test_offers_only_immediate_topup_when_existing_extra_expires_early(self):
+ current_extra_until = datetime(2099, 1, 16, tzinfo=timezone.utc)
+ subscription_until = datetime(2099, 2, 1, tzinfo=timezone.utc)
+ tariff = SimpleNamespace(
+ key="standard",
+ billing_model="period",
+ hwid_device_packages=SimpleNamespace(
+ rub=[SimpleNamespace(count=1)],
+ stars=[],
+ ),
+ name=lambda lang: "Standard",
+ )
+ settings = SimpleNamespace(
+ MY_DEVICES_SECTION_ENABLED=True,
+ tariffs_config=SimpleNamespace(require=lambda key: tariff),
+ DEFAULT_LANGUAGE="en",
+ DEFAULT_CURRENCY_SYMBOL="RUB",
+ )
+
+ async def quote_hwid_device_topup(*args, **kwargs):
+ if kwargs.get("renewal"):
+ return {
+ "price": 25,
+ "valid_from": current_extra_until,
+ "valid_until": subscription_until,
+ "proration_ratio": 0.5,
+ }
+ return {
+ "price": 50,
+ "valid_from": datetime(2099, 1, 1, tzinfo=timezone.utc),
+ "valid_until": subscription_until,
+ "proration_ratio": 1.0,
+ }
+
+ subscription_service = SimpleNamespace(
+ get_active_subscription_details=AsyncMock(
+ return_value={
+ "max_devices": 3,
+ "extra_hwid_devices": 1,
+ "extra_hwid_devices_valid_until": current_extra_until,
+ "extra_hwid_devices_valid_until_text": "16.01.2099 00:00",
+ "device_topup_renewal_available": True,
+ }
+ ),
+ quote_hwid_device_topup=AsyncMock(side_effect=quote_hwid_device_topup),
+ )
+ request = SimpleNamespace(
+ app={
+ "settings": settings,
+ "async_session_factory": _SessionFactory(),
+ "subscription_service": subscription_service,
+ }
+ )
+ db_user = SimpleNamespace(
+ is_banned=False,
+ panel_user_uuid="panel-user",
+ language_code="en",
+ )
+ sub = SimpleNamespace(
+ tariff_key="standard",
+ extra_hwid_devices=1,
+ )
+
+ with (
+ patch.object(billing_module, "_require_user_id", return_value=42),
+ patch.object(
+ billing_module.user_dal,
+ "get_user_by_id",
+ AsyncMock(return_value=db_user),
+ ),
+ patch.object(
+ billing_module.subscription_dal,
+ "get_active_subscription_by_user_id",
+ AsyncMock(return_value=sub),
+ ),
+ ):
+ response = await billing_module.device_topup_options_route(request)
+
+ self.assertEqual(response.status, 200)
+ payload = json.loads(response.text)
+ self.assertEqual([plan["sale_mode"] for plan in payload["plans"]], ["hwid_devices"])
+ self.assertEqual([plan["price"] for plan in payload["plans"]], [50])
+ self.assertFalse(payload["plans"][0]["renewal"])
+ self.assertEqual(payload["plans"][0]["valid_until"], subscription_until.isoformat())
+ self.assertEqual(payload["renewal_available"], False)
+ self.assertEqual(payload["renewal_recommended_count"], 0)
+ renewal_flags = [
+ call.kwargs.get("renewal")
+ for call in subscription_service.quote_hwid_device_topup.await_args_list
+ ]
+ self.assertEqual(
+ renewal_flags,
+ [False],
+ )
+
async def test_create_payment_route_quotes_hwid_with_app_subscription_service(self):
tariff = SimpleNamespace(
key="standard",
@@ -197,6 +292,110 @@ class WebAppDeviceTopupOptionsTests(IsolatedAsyncioTestCase):
subscription_service.quote_hwid_device_topup.assert_awaited_once()
create_payment.assert_awaited_once()
+ async def test_create_payment_route_adds_hwid_renewal_to_subscription_payment(self):
+ tariff = SimpleNamespace(
+ key="standard",
+ billing_model="period",
+ enabled_periods=[1],
+ hwid_device_packages=SimpleNamespace(
+ rub=[SimpleNamespace(count=1)],
+ stars=[],
+ ),
+ period_price=lambda months, currency: 100 if currency == "rub" else None,
+ )
+ settings = SimpleNamespace(
+ traffic_sale_mode=False,
+ tariffs_config=SimpleNamespace(require=lambda key: tariff),
+ DEFAULT_LANGUAGE="en",
+ DEFAULT_CURRENCY_SYMBOL="RUB",
+ ADMIN_IDS=[],
+ )
+ hwid_quote = {
+ "price": 50,
+ "device_count": 1,
+ "valid_from": datetime(2099, 1, 1, tzinfo=timezone.utc),
+ "valid_until": datetime(2099, 2, 1, tzinfo=timezone.utc),
+ "pricing_period_months": 1,
+ "proration_ratio": 1.0,
+ "full_price": 50,
+ }
+ subscription_service = SimpleNamespace(
+ quote_hwid_device_renewal_for_subscription=AsyncMock(return_value=hwid_quote)
+ )
+ request = SimpleNamespace(
+ app={
+ "settings": settings,
+ "async_session_factory": _SessionFactory(),
+ "subscription_service": subscription_service,
+ }
+ )
+ db_user = SimpleNamespace(
+ is_banned=False,
+ panel_user_uuid="panel-user",
+ language_code="en",
+ telegram_id=42,
+ )
+
+ async def _fake_create_payment(**kwargs):
+ return billing_module.web.json_response(
+ {
+ "ok": True,
+ "price": kwargs["price"],
+ "hwid_device_count": kwargs["hwid_quote"]["device_count"],
+ }
+ )
+
+ with (
+ patch.object(billing_module, "_require_user_id", return_value=42),
+ patch.object(
+ billing_module,
+ "_enforce_webapp_rate_limit",
+ AsyncMock(return_value=None),
+ ),
+ patch.object(
+ billing_module,
+ "_read_json",
+ AsyncMock(
+ return_value={
+ "method": "yookassa",
+ "months": 1,
+ "tariff_key": "standard",
+ "sale_mode": "subscription",
+ "renew_hwid_devices": True,
+ }
+ ),
+ ),
+ patch.object(
+ billing_module,
+ "_get_cached_webapp_settings",
+ return_value={"subscription_options": {}, "stars_subscription_options": {}},
+ ),
+ patch.object(
+ billing_module.user_dal,
+ "get_user_by_id",
+ AsyncMock(return_value=db_user),
+ ),
+ patch.object(
+ billing_module,
+ "_create_subscription_payment",
+ AsyncMock(side_effect=_fake_create_payment),
+ ) as create_payment,
+ ):
+ response = await billing_module.create_payment_route(request)
+
+ self.assertEqual(response.status, 200)
+ payload = json.loads(response.text)
+ self.assertEqual(payload["price"], 150)
+ self.assertEqual(payload["hwid_device_count"], 1)
+ subscription_service.quote_hwid_device_renewal_for_subscription.assert_awaited_once_with(
+ ANY,
+ user_id=42,
+ target_tariff_key="standard",
+ months=1,
+ currency="rub",
+ )
+ create_payment.assert_awaited_once()
+
async def test_create_payment_route_rejects_fractional_hwid_device_count(self):
tariff = SimpleNamespace(
key="standard",
diff --git a/tests/test_webapp_payment_status.py b/tests/test_webapp_payment_status.py
index ff15414..d2bcebd 100644
--- a/tests/test_webapp_payment_status.py
+++ b/tests/test_webapp_payment_status.py
@@ -164,7 +164,7 @@ class WebAppPaymentStatusTests(IsolatedAsyncioTestCase):
):
response = await billing_module.payment_status_route(request)
- invalidate_cache.assert_awaited_once_with(settings, 1001)
+ invalidate_cache.assert_awaited_once_with(settings, 1001, include_devices=True)
self.assertEqual(response.status, 200)
async def test_wata_pending_payment_refresh_delegates_to_provider_service(self):
diff --git a/tests/test_yookassa_hwid_webhook.py b/tests/test_yookassa_hwid_webhook.py
index 435e147..fc90ea9 100644
--- a/tests/test_yookassa_hwid_webhook.py
+++ b/tests/test_yookassa_hwid_webhook.py
@@ -1,3 +1,4 @@
+from datetime import datetime, timezone
from types import SimpleNamespace
from unittest import IsolatedAsyncioTestCase
from unittest.mock import AsyncMock, patch
@@ -15,6 +16,81 @@ class _I18n:
class YooKassaHwidWebhookTests(IsolatedAsyncioTestCase):
+ async def test_telegram_subscription_hwid_quote_is_stored_in_yookassa_metadata(self):
+ valid_from = datetime(2099, 2, 1, tzinfo=timezone.utc)
+ valid_until = datetime(2099, 3, 1, tzinfo=timezone.utc)
+ session = AsyncMock()
+ callback = SimpleNamespace(
+ from_user=SimpleNamespace(id=42),
+ message=SimpleNamespace(edit_text=AsyncMock()),
+ )
+ service = SimpleNamespace(
+ config=SimpleNamespace(DEFAULT_RECEIPT_EMAIL="receipt@example.test"),
+ create_payment=AsyncMock(
+ return_value={
+ "id": "yk-pay-1",
+ "status": "pending",
+ "confirmation_url": "https://pay.example.test/1",
+ }
+ ),
+ )
+ hwid_quote = {
+ "device_count": 2,
+ "valid_from": valid_from,
+ "valid_until": valid_until,
+ "pricing_period_months": 1,
+ "proration_ratio": 1.0,
+ "full_price": 50.0,
+ }
+ payment = SimpleNamespace(payment_id=123)
+
+ with (
+ patch.object(
+ yookassa.payment_dal,
+ "create_payment_record",
+ AsyncMock(return_value=payment),
+ ) as create_record,
+ patch.object(
+ yookassa.payment_dal,
+ "update_payment_status_by_db_id",
+ AsyncMock(),
+ ),
+ ):
+ result = await yookassa._initiate_yk_payment(
+ callback,
+ settings=SimpleNamespace(),
+ session=session,
+ yookassa_service=service,
+ i18n=_I18n(),
+ current_lang="en",
+ get_text=lambda key, **kwargs: key,
+ user_id=42,
+ months=1,
+ price_rub=150,
+ currency_code_for_yk="RUB",
+ save_payment_method=False,
+ back_callback="tariff:period:standard:1",
+ sale_mode="subscription@standard|hwid_renewal",
+ hwid_quote=hwid_quote,
+ )
+
+ assert result is True
+ record_payload = create_record.await_args.args[1]
+ assert record_payload["sale_mode"] == "subscription@standard|hwid_renewal"
+ assert record_payload["tariff_key"] == "standard"
+ assert record_payload["purchased_hwid_devices"] == 2
+ assert record_payload["hwid_valid_from"] == valid_from
+ assert record_payload["hwid_valid_until"] == valid_until
+ metadata = service.create_payment.await_args.kwargs["metadata"]
+ assert metadata["sale_mode"] == "subscription@standard|hwid_renewal"
+ assert metadata["hwid_devices"] == "2"
+ assert metadata["hwid_valid_from"] == valid_from.isoformat()
+ assert metadata["hwid_valid_until"] == valid_until.isoformat()
+ assert metadata["hwid_pricing_period_months"] == "1"
+ assert metadata["hwid_proration_ratio"] == "1.0"
+ assert metadata["hwid_full_price"] == "50.0"
+ assert service.create_payment.await_args.kwargs["amount"] == 150
+
async def test_webapp_hwid_metadata_activates_device_count_without_end_date(self):
payment = SimpleNamespace(payment_id=5, status="pending_yookassa", tariff_key="standard")
updated_payment = SimpleNamespace(payment_id=5, status="succeeded", tariff_key="standard")
@@ -99,3 +175,110 @@ class YooKassaHwidWebhookTests(IsolatedAsyncioTestCase):
assert activation_kwargs["traffic_gb"] is None
update_status.assert_awaited_once()
send_success.assert_awaited_once()
+
+ async def test_auto_renew_hwid_metadata_is_persisted_for_activation(self):
+ valid_from = datetime(2099, 2, 1, tzinfo=timezone.utc)
+ valid_until = datetime(2099, 3, 1, tzinfo=timezone.utc)
+ payment = SimpleNamespace(payment_id=5, status="pending_yookassa", tariff_key="standard")
+ updated_payment = SimpleNamespace(payment_id=5, status="succeeded", tariff_key="standard")
+ db_user = SimpleNamespace(
+ user_id=42,
+ username="alice",
+ language_code="en",
+ referred_by_id=None,
+ )
+ subscription_service = SimpleNamespace(
+ activate_subscription=AsyncMock(
+ return_value={
+ "subscription_id": 11,
+ "end_date": valid_until,
+ "hwid_devices_renewed_count": 2,
+ "hwid_devices_valid_until": valid_until,
+ }
+ )
+ )
+ referral_service = SimpleNamespace(
+ apply_referral_bonuses_for_payment=AsyncMock(return_value={})
+ )
+ settings = SimpleNamespace(
+ traffic_sale_mode=False,
+ yookassa_autopayments_active=False,
+ DEFAULT_LANGUAGE="en",
+ DEFAULT_CURRENCY_SYMBOL="RUB",
+ LKNPD_RECEIPT_NAME_TRAFFIC="{gb} GB",
+ LKNPD_RECEIPT_NAME_SUBSCRIPTION="{months} months",
+ )
+ payment_info = {
+ "id": "yk-auto-hwid-1",
+ "status": "succeeded",
+ "paid": True,
+ "amount": {"value": "449.00", "currency": "RUB"},
+ "metadata": {
+ "user_id": "42",
+ "subscription_months": "1",
+ "auto_renew_for_subscription_id": "555",
+ "sale_mode": "subscription@standard",
+ "hwid_devices": "2",
+ "hwid_valid_from": valid_from.isoformat(),
+ "hwid_valid_until": valid_until.isoformat(),
+ "hwid_pricing_period_months": "1",
+ "hwid_proration_ratio": "1.0",
+ "hwid_full_price": "50.0",
+ },
+ "description": "Auto-renewal for 1 months",
+ }
+
+ with (
+ patch.object(
+ yookassa.payment_dal,
+ "get_payment_by_provider_payment_id",
+ AsyncMock(return_value=None),
+ ),
+ patch.object(
+ yookassa.payment_dal,
+ "ensure_payment_with_provider_id",
+ AsyncMock(return_value=payment),
+ ) as ensure_payment,
+ patch.object(
+ yookassa.payment_dal,
+ "get_payment_by_db_id",
+ AsyncMock(return_value=payment),
+ ),
+ patch.object(
+ yookassa.payment_dal,
+ "update_payment_status_by_db_id",
+ AsyncMock(return_value=updated_payment),
+ ),
+ patch.object(yookassa.user_dal, "get_user_by_id", AsyncMock(return_value=db_user)),
+ patch.object(
+ yookassa,
+ "prepare_config_links",
+ AsyncMock(return_value=("link", "https://example.test/sub")),
+ ),
+ patch.object(yookassa, "send_success_message_to_user", AsyncMock()) as send_success,
+ patch.object(yookassa, "notify_admins_payment_received", AsyncMock()),
+ ):
+ await yookassa.process_successful_payment(
+ AsyncMock(),
+ AsyncMock(),
+ payment_info,
+ _I18n(),
+ settings,
+ AsyncMock(),
+ subscription_service,
+ referral_service,
+ )
+
+ ensure_payment.assert_awaited_once()
+ ensure_kwargs = ensure_payment.await_args.kwargs
+ assert ensure_kwargs["sale_mode"] == "subscription@standard"
+ assert ensure_kwargs["tariff_key"] == "standard"
+ assert ensure_kwargs["purchased_hwid_devices"] == 2
+ assert ensure_kwargs["hwid_valid_from"] == valid_from
+ assert ensure_kwargs["hwid_valid_until"] == valid_until
+ assert ensure_kwargs["hwid_pricing_period_months"] == 1
+ assert ensure_kwargs["hwid_proration_ratio"] == 1.0
+ assert ensure_kwargs["hwid_full_price"] == 50.0
+ activation_kwargs = subscription_service.activate_subscription.await_args.kwargs
+ assert activation_kwargs["sale_mode"] == "subscription@standard"
+ send_success.assert_awaited_once()
From 5f22c2081e7ee82220bac5152913985e51073c49 Mon Sep 17 00:00:00 2001
From: 3252a8 <3252a8@proton.me>
Date: Wed, 3 Jun 2026 23:55:08 +0300
Subject: [PATCH 43/58] docs: document payment provider webhook URLs
---
docs/features/payments.md | 49 ++++++++++++++++++++++++++++-----------
1 file changed, 36 insertions(+), 13 deletions(-)
diff --git a/docs/features/payments.md b/docs/features/payments.md
index e8d07b1..ce8d4ee 100644
--- a/docs/features/payments.md
+++ b/docs/features/payments.md
@@ -18,6 +18,23 @@
- [Тарифы](tariffs.md) описывают цены, Telegram Stars и сценарии покупки.
- [Логи](../troubleshooting/logs.md) помогают проверить webhook и создание платежных ссылок.
+## Webhook URL провайдеров
+
+Все платежные webhook URL строятся от `WEBHOOK_BASE_URL` - публичного HTTPS-адреса backend/webhook-домена. Это должен быть домен, который проксируется на backend-сервер вебхуков (`backend:8080`), а не `SUBSCRIPTION_MINI_APP_URL` frontend/Mini App. Если `WEBHOOK_BASE_URL=https://bot.example.com`, то полный адрес получается как `https://bot.example.com` + путь из таблицы.
+
+| Провайдер | Что указать в кабинете провайдера | Комментарий |
+| --- | --- | --- |
+| YooKassa | `WEBHOOK_BASE_URL` + `/webhook/yookassa` | Например `https://bot.example.com/webhook/yookassa`. |
+| FreeKassa | `WEBHOOK_BASE_URL` + `/webhook/freekassa` | Используйте как notification/webhook URL; при IP-фильтрации заполните `FREEKASSA_TRUSTED_IPS`. |
+| Platega | `WEBHOOK_BASE_URL` + `/webhook/platega` | Один общий webhook для основной, СБП/карты и crypto-кнопки Platega. |
+| SeverPay | `WEBHOOK_BASE_URL` + `/webhook/severpay` | Укажите как callback/webhook URL, если поле есть в кабинете мерчанта. |
+| Wata | `WEBHOOK_BASE_URL` + `/webhook/wata` | Если включена проверка подписи, настройте `WATA_WEBHOOK_VERIFY_SIGNATURE` и `WATA_PUBLIC_KEY`. |
+| CryptoPay | `WEBHOOK_BASE_URL` + `/webhook/cryptopay` | Указывается в настройках Crypto Bot / CryptoPay webhook. |
+| Heleket | `WEBHOOK_BASE_URL` + `/webhook/heleket` | При необходимости включите `HELEKET_VERIFY_WEBHOOK_SIGNATURE` и `HELEKET_TRUSTED_IPS`. |
+| Telegram Stars | Отдельный платежный webhook не нужен | Stars-события приходят через webhook Telegram-бота: `WEBHOOK_BASE_URL` + `/tg/webhook`. |
+
+После настройки сделайте тестовый платеж и проверьте, что в логах `backend` видно входящий `POST` на нужный путь. Если провайдер сообщает, что адрес недоступен, сначала проверьте DNS/HTTPS и reverse proxy для `WEBHOOK_BASE_URL`, затем убедитесь, что путь начинается ровно с `/webhook/...` без `/api`, `/auth` и frontend-домена.
+
## YooKassa
YooKassa используется для рублевых оплат и может участвовать в сценариях автопродления period-подписок.
@@ -26,7 +43,7 @@ YooKassa используется для рублевых оплат и може
- включение провайдера: `YOOKASSA_ENABLED`;
- идентификаторы и секреты магазина;
-- URL вебхука на backend-домен;
+- URL вебхука: `WEBHOOK_BASE_URL` + `/webhook/yookassa`;
- отображение кнопки оплаты и порядок платежных методов.
Справочник переменных: [YooKassa](../configuration/env-vars.md#yookassa).
@@ -40,7 +57,7 @@ FreeKassa подключается как отдельный платежный
- включение провайдера: `FREEKASSA_ENABLED`;
- ID магазина, API/secret-ключи и настройки подписи;
- список доверенных IP, если используется;
-- публичный URL вебхука на `WEBHOOK_BASE_URL`.
+- публичный URL вебхука: `WEBHOOK_BASE_URL` + `/webhook/freekassa`.
Справочник переменных: [FreeKassa](../configuration/env-vars.md#freekassa).
@@ -60,8 +77,9 @@ Platega подключается как отдельный платежный п
1. Укажите `PLATEGA_BASE_URL`, `PLATEGA_MERCHANT_ID` и `PLATEGA_SECRET`.
2. Заполните `PLATEGA_SBP_METHOD` и/или `PLATEGA_CRYPTO_METHOD`, если используете отдельные кнопки.
3. Проверьте `PLATEGA_RETURN_URL` и `PLATEGA_FAILED_URL`.
-4. Настройте тексты и иконки кнопок через `PAYMENT_PLATEGA_SBP_*` и `PAYMENT_PLATEGA_CRYPTO_*`.
-5. Добавьте нужные методы в `PAYMENT_METHODS_ORDER`.
+4. Укажите URL вебхука: `WEBHOOK_BASE_URL` + `/webhook/platega`.
+5. Настройте тексты и иконки кнопок через `PAYMENT_PLATEGA_SBP_*` и `PAYMENT_PLATEGA_CRYPTO_*`.
+6. Добавьте нужные методы в `PAYMENT_METHODS_ORDER`.
Справочник переменных: [Platega](../configuration/env-vars.md#platega).
@@ -75,8 +93,9 @@ SeverPay подключается как отдельный платежный
2. Укажите `SEVERPAY_BASE_URL`.
3. Заполните `SEVERPAY_MID` и `SEVERPAY_TOKEN`.
4. Настройте `SEVERPAY_RETURN_URL`.
-5. При необходимости задайте `SEVERPAY_LIFETIME_MINUTES`.
-6. Добавьте `severpay` в `PAYMENT_METHODS_ORDER`.
+5. Укажите URL вебхука: `WEBHOOK_BASE_URL` + `/webhook/severpay`.
+6. При необходимости задайте `SEVERPAY_LIFETIME_MINUTES`.
+7. Добавьте `severpay` в `PAYMENT_METHODS_ORDER`.
Справочник переменных: [SeverPay](../configuration/env-vars.md#severpay).
@@ -90,9 +109,10 @@ Wata подключается как отдельный провайдер с be
2. Укажите `WATA_BASE_URL` и `WATA_API_TOKEN`.
3. Проверьте `WATA_RETURN_URL` и `WATA_FAILED_URL`.
4. Настройте `WATA_LINK_TTL_MINUTES`: минимум 15 минут, максимум 43200.
-5. Если включаете проверку подписи, задайте `WATA_WEBHOOK_VERIFY_SIGNATURE` и при необходимости `WATA_PUBLIC_KEY`.
-6. Для дополнительной защиты заполните `WATA_TRUSTED_IPS`.
-7. Добавьте `wata` в `PAYMENT_METHODS_ORDER`.
+5. Укажите URL вебхука: `WEBHOOK_BASE_URL` + `/webhook/wata`.
+6. Если включаете проверку подписи, задайте `WATA_WEBHOOK_VERIFY_SIGNATURE` и при необходимости `WATA_PUBLIC_KEY`.
+7. Для дополнительной защиты заполните `WATA_TRUSTED_IPS`.
+8. Добавьте `wata` в `PAYMENT_METHODS_ORDER`.
Справочник переменных: [Wata](../configuration/env-vars.md#wata).
@@ -107,7 +127,8 @@ CryptoPay используется для криптовалютных плат
3. Выберите `CRYPTOPAY_NETWORK`: `mainnet` или `testnet`.
4. Задайте `CRYPTOPAY_CURRENCY_TYPE`: `fiat` или `crypto`.
5. Проверьте `CRYPTOPAY_ASSET`, например `RUB`, `USDT` или `BTC`.
-6. Добавьте `cryptopay` в `PAYMENT_METHODS_ORDER`.
+6. Укажите URL вебхука: `WEBHOOK_BASE_URL` + `/webhook/cryptopay`.
+7. Добавьте `cryptopay` в `PAYMENT_METHODS_ORDER`.
Для тестов используйте соответствующую сеть: testnet-токен не должен попадать в mainnet-настройки. Если сумма или asset выглядят неверно, проверьте сочетание `CRYPTOPAY_CURRENCY_TYPE` и `CRYPTOPAY_ASSET`.
@@ -125,9 +146,10 @@ Heleket используется для крипто-инвойсов с отд
4. При необходимости задайте `HELEKET_TO_CURRENCY` и `HELEKET_NETWORK`.
5. Проверьте `HELEKET_RETURN_URL` и `HELEKET_SUCCESS_URL`.
6. Настройте `HELEKET_LIFETIME_SECONDS`: допустимый диапазон 300..43200.
-7. Если включаете проверку webhook, задайте `HELEKET_VERIFY_WEBHOOK_SIGNATURE`.
-8. Для IP-фильтрации заполните `HELEKET_TRUSTED_IPS`.
-9. Добавьте `heleket` в `PAYMENT_METHODS_ORDER`.
+7. Укажите URL вебхука: `WEBHOOK_BASE_URL` + `/webhook/heleket`.
+8. Если включаете проверку webhook, задайте `HELEKET_VERIFY_WEBHOOK_SIGNATURE`.
+9. Для IP-фильтрации заполните `HELEKET_TRUSTED_IPS`.
+10. Добавьте `heleket` в `PAYMENT_METHODS_ORDER`.
Справочник переменных: [Heleket](../configuration/env-vars.md#heleket).
@@ -145,6 +167,7 @@ Telegram Stars используются напрямую и поддержива
Что проверить:
- `STARS_ENABLED`;
+- отдельный платежный webhook не настраивается: Telegram Stars приходят через webhook Telegram-бота `WEBHOOK_BASE_URL` + `/tg/webhook`;
- Stars-цены в legacy-настройках или JSON-каталоге;
- корректное округление цены до целого количества Stars;
- сценарии смены тарифа: XTR/Stars-докупки не конвертируются без явного курса.
From 33707b7257046dbbcad16b384c07ed66d009820a Mon Sep 17 00:00:00 2001
From: 3252a8 <3252a8@proton.me>
Date: Thu, 4 Jun 2026 00:03:28 +0300
Subject: [PATCH 44/58] chore: remove terms of service setting
Keep privacy policy and user agreement links as the supported legal documents.
Refresh the admin settings manifest, demo dataset, locales, docs, and bot menu tests.
---
.../bot/app/web/admin_settings_manifest.py | 1 -
backend/bot/app/web/webapp/assets.py | 2 -
backend/bot/handlers/user/start.py | 2 +-
.../bot/keyboards/inline/user_keyboards.py | 6 +-
.../bot/services/locale_override_service.py | 1 -
backend/config/settings.py | 1 -
docs/configuration/env-vars.md | 1 -
frontend/src/lib/webapp/demoDataset.js | 80 ++-----------------
.../webapp/settingsManifest.generated.json | 19 -----
locales/en.json | 2 -
locales/ru.json | 2 -
tests/test_user_bot_menu.py | 1 -
12 files changed, 9 insertions(+), 109 deletions(-)
diff --git a/backend/bot/app/web/admin_settings_manifest.py b/backend/bot/app/web/admin_settings_manifest.py
index 8c06835..eb58fb0 100644
--- a/backend/bot/app/web/admin_settings_manifest.py
+++ b/backend/bot/app/web/admin_settings_manifest.py
@@ -65,7 +65,6 @@ SETTINGS_MANIFEST: List[SettingField] = [
"SUPPORT_LINK", "url", "general", "Ссылка поддержки", "Куда вести пользователей за помощью."
),
SettingField("SERVER_STATUS_URL", "url", "general", "Ссылка на статус серверов"),
- SettingField("TERMS_OF_SERVICE_URL", "url", "general", "Условия использования"),
SettingField("PRIVACY_POLICY_URL", "url", "general", "Политика конфиденциальности"),
SettingField("USER_AGREEMENT_URL", "url", "general", "Пользовательское соглашение"),
SettingField("DISABLE_WELCOME_MESSAGE", "bool", "general", "Скрыть приветствие /start"),
diff --git a/backend/bot/app/web/webapp/assets.py b/backend/bot/app/web/webapp/assets.py
index c691343..dec98bd 100644
--- a/backend/bot/app/web/webapp/assets.py
+++ b/backend/bot/app/web/webapp/assets.py
@@ -765,7 +765,6 @@ def _get_cached_webapp_settings(request: web.Request) -> Dict[str, Any]:
"traffic_packages": settings.traffic_packages,
"stars_traffic_packages": settings.stars_traffic_packages,
"support_url": settings.SUPPORT_LINK or "",
- "terms_url": settings.TERMS_OF_SERVICE_URL or "",
"privacy_policy_url": settings.PRIVACY_POLICY_URL or "",
"user_agreement_url": settings.USER_AGREEMENT_URL or "",
"currency": settings.DEFAULT_CURRENCY_SYMBOL or "RUB",
@@ -1018,7 +1017,6 @@ def _build_webapp_bootstrap_payload(request: web.Request) -> Dict[str, Any]:
"telegramOAuthClientId": _resolve_telegram_oauth_client_id(settings) or 0,
"telegramOAuthRequestAccess": _resolve_telegram_oauth_request_access(settings),
"supportUrl": cached["support_url"],
- "termsUrl": cached["terms_url"],
"privacyPolicyUrl": cached["privacy_policy_url"],
"userAgreementUrl": cached["user_agreement_url"],
"currency": cached["currency"],
diff --git a/backend/bot/handlers/user/start.py b/backend/bot/handlers/user/start.py
index 5328a8c..18dcace 100644
--- a/backend/bot/handlers/user/start.py
+++ b/backend/bot/handlers/user/start.py
@@ -1184,7 +1184,7 @@ async def main_action_callback_handler(
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
privacy_url = settings.PRIVACY_POLICY_URL
- user_agreement_url = settings.USER_AGREEMENT_URL or settings.TERMS_OF_SERVICE_URL
+ user_agreement_url = settings.USER_AGREEMENT_URL
if not privacy_url and not user_agreement_url:
await safe_answer_callback(
diff --git a/backend/bot/keyboards/inline/user_keyboards.py b/backend/bot/keyboards/inline/user_keyboards.py
index 0489ff7..064f786 100644
--- a/backend/bot/keyboards/inline/user_keyboards.py
+++ b/backend/bot/keyboards/inline/user_keyboards.py
@@ -166,8 +166,7 @@ def get_main_menu_inline_keyboard(
InlineKeyboardButton(text=_(key="menu_support_button"), url=settings.SUPPORT_LINK)
)
- user_agreement_url = settings.USER_AGREEMENT_URL or settings.TERMS_OF_SERVICE_URL
- if settings.PRIVACY_POLICY_URL or user_agreement_url:
+ if settings.PRIVACY_POLICY_URL or settings.USER_AGREEMENT_URL:
builder.row(
InlineKeyboardButton(text=_(key="menu_info_button"), callback_data="main_action:info")
)
@@ -234,8 +233,7 @@ def get_bot_interface_inline_keyboard(
InlineKeyboardButton(text=_(key="menu_support_button"), url=settings.SUPPORT_LINK)
)
- user_agreement_url = settings.USER_AGREEMENT_URL or settings.TERMS_OF_SERVICE_URL
- if settings.PRIVACY_POLICY_URL or user_agreement_url:
+ if settings.PRIVACY_POLICY_URL or settings.USER_AGREEMENT_URL:
builder.row(
InlineKeyboardButton(
text=_(key="menu_info_button"), callback_data="main_action:bot_info"
diff --git a/backend/bot/services/locale_override_service.py b/backend/bot/services/locale_override_service.py
index f502a44..7b50740 100644
--- a/backend/bot/services/locale_override_service.py
+++ b/backend/bot/services/locale_override_service.py
@@ -205,7 +205,6 @@ LOCALE_GROUPS = [
"admin_settings_field_subscription_mini_app_url",
"admin_settings_field_support_link",
"admin_settings_field_server_status_url",
- "admin_settings_field_terms_",
"admin_settings_field_privacy_",
"admin_settings_field_user_agreement_",
"appearance_",
diff --git a/backend/config/settings.py b/backend/config/settings.py
index 6d8f5ae..1a3f65b 100644
--- a/backend/config/settings.py
+++ b/backend/config/settings.py
@@ -148,7 +148,6 @@ class Settings(BaseSettings):
SUPPORT_LINK: Optional[str] = Field(default=None)
SERVER_STATUS_URL: Optional[str] = Field(default=None)
- TERMS_OF_SERVICE_URL: Optional[str] = Field(default=None)
PRIVACY_POLICY_URL: Optional[str] = Field(default=None)
USER_AGREEMENT_URL: Optional[str] = Field(default=None)
REQUIRED_CHANNEL_ID: Optional[int] = Field(
diff --git a/docs/configuration/env-vars.md b/docs/configuration/env-vars.md
index ca5b671..47827fe 100644
--- a/docs/configuration/env-vars.md
+++ b/docs/configuration/env-vars.md
@@ -105,7 +105,6 @@
| `DEFAULT_CURRENCY_SYMBOL` | Символ/код валюты в интерфейсе. |
| `SUPPORT_LINK` | Внешняя ссылка поддержки. |
| `SERVER_STATUS_URL` | Страница статуса сервиса. |
-| `TERMS_OF_SERVICE_URL` | Условия использования. |
| `PRIVACY_POLICY_URL` | Политика конфиденциальности. |
| `USER_AGREEMENT_URL` | Пользовательское соглашение. |
| `REQUIRED_CHANNEL_ID` | ID обязательного Telegram-канала. Используется для проверки подписки и автоматического получения ссылки кнопки, если бот видит канал. |
diff --git a/frontend/src/lib/webapp/demoDataset.js b/frontend/src/lib/webapp/demoDataset.js
index 734167f..08675b1 100644
--- a/frontend/src/lib/webapp/demoDataset.js
+++ b/frontend/src/lib/webapp/demoDataset.js
@@ -15,14 +15,14 @@ export const DEMO_DATASET = {
logs: 1600,
supportTickets: 3,
translationKeys: 1994,
- settingsFields: 223,
+ settingsFields: 222,
},
},
config: {
title: "/minishop",
supportUrl: "https://t.me/demo_minishop_support",
privacyPolicyUrl: "https://demo.example/privacy",
- userAgreementUrl: "https://demo.example/terms",
+ userAgreementUrl: "https://demo.example/agreement",
telegramLoginBotUsername: "demo_minishop_bot",
telegramLoginBotId: 1234567890,
telegramOAuthClientId: 1234567890,
@@ -68425,8 +68425,8 @@ export const DEMO_DATASET = {
telegram_username: "login_please",
telegram_first_name: "",
email: "",
- event_type: "command:/terms",
- content: "Demo event: command:/terms Account #910327.",
+ event_type: "command:/agreement",
+ content: "Demo event: command:/agreement Account #910327.",
is_admin_event: false,
target_user_id: null,
timestamp: "2026-05-21T07:34:39Z",
@@ -69385,8 +69385,8 @@ export const DEMO_DATASET = {
telegram_username: "login_please",
telegram_first_name: "",
email: "",
- event_type: "command:/terms",
- content: "Demo event: command:/terms Account #910327.",
+ event_type: "command:/agreement",
+ content: "Demo event: command:/agreement Account #910327.",
is_admin_event: false,
target_user_id: null,
timestamp: "2026-05-19T13:37:32Z",
@@ -86121,26 +86121,6 @@ export const DEMO_DATASET = {
overridden: false,
updated_at: null,
},
- {
- key: "TERMS_OF_SERVICE_URL",
- type: "url",
- section: "general",
- section_order: 1,
- subsection: null,
- label: "Условия использования",
- description: "",
- i18n_label_key: "admin_settings_field_terms_of_service_url_label",
- i18n_description_key: null,
- i18n_subsection_key: null,
- i18n_placeholder_key: null,
- placeholder: "https://demo.example/terms_of_service_url",
- optional: true,
- secret: false,
- default: "https://demo.example/terms_of_service_url",
- value: "https://demo.example/terms_of_service_url",
- overridden: false,
- updated_at: null,
- },
{
key: "PRIVACY_POLICY_URL",
type: "url",
@@ -114599,30 +114579,6 @@ export const DEMO_DATASET = {
},
},
},
- {
- key: "admin_settings_field_terms_of_service_url_label",
- audience: "internal",
- values: {
- ru: {
- base: "Условия использования",
- fallback: "Условия использования",
- effective: "Условия использования",
- override: "",
- overridden: false,
- updated_at: null,
- updated_by: null,
- },
- en: {
- base: "Terms Of Service URL",
- fallback: "Условия использования",
- effective: "Terms Of Service URL",
- override: "",
- overridden: false,
- updated_at: null,
- updated_by: null,
- },
- },
- },
{
key: "admin_settings_field_user_agreement_url_label",
audience: "internal",
@@ -133711,30 +133667,6 @@ export const DEMO_DATASET = {
},
},
},
- {
- key: "menu_terms_button",
- audience: "user",
- values: {
- ru: {
- base: "📄 Условия сервиса",
- fallback: "📄 Условия сервиса",
- effective: "📄 Условия сервиса",
- override: "",
- overridden: false,
- updated_at: null,
- updated_by: null,
- },
- en: {
- base: "📄 Terms of Service",
- fallback: "📄 Условия сервиса",
- effective: "📄 Terms of Service",
- override: "",
- overridden: false,
- updated_at: null,
- updated_by: null,
- },
- },
- },
],
},
{
diff --git a/frontend/src/lib/webapp/settingsManifest.generated.json b/frontend/src/lib/webapp/settingsManifest.generated.json
index 838a3d2..2771d39 100644
--- a/frontend/src/lib/webapp/settingsManifest.generated.json
+++ b/frontend/src/lib/webapp/settingsManifest.generated.json
@@ -98,25 +98,6 @@
"overridden": false,
"updated_at": null
},
- {
- "key": "TERMS_OF_SERVICE_URL",
- "type": "url",
- "section": "general",
- "section_order": 1,
- "subsection": null,
- "label": "Условия использования",
- "description": "",
- "i18n_label_key": "admin_settings_field_terms_of_service_url_label",
- "i18n_description_key": null,
- "i18n_subsection_key": null,
- "i18n_placeholder_key": null,
- "placeholder": "",
- "optional": true,
- "secret": false,
- "value": "",
- "overridden": false,
- "updated_at": null
- },
{
"key": "PRIVACY_POLICY_URL",
"type": "url",
diff --git a/locales/en.json b/locales/en.json
index bcde007..0463372 100644
--- a/locales/en.json
+++ b/locales/en.json
@@ -23,7 +23,6 @@
"menu_language_settings_inline": "🌐 Language",
"menu_server_status_button": "📊 Status",
"menu_support_button": "💬 Admin contact",
- "menu_terms_button": "📄 Terms of Service",
"menu_info_button": "ℹ️ Information",
"bot_interface_menu_title": "Bot interface",
"bot_interface_menu_webapp_hint": "The Personal Account is the easier place to manage the service.",
@@ -1591,7 +1590,6 @@
"admin_settings_field_support_link_label": "Support Link",
"admin_settings_field_support_link_description": "Controls the 'Support Link' setting in admin overrides.",
"admin_settings_field_server_status_url_label": "Server Status URL",
- "admin_settings_field_terms_of_service_url_label": "Terms Of Service URL",
"admin_settings_field_privacy_policy_url_label": "Privacy Policy URL",
"admin_settings_field_user_agreement_url_label": "User Agreement URL",
"admin_settings_field_disable_welcome_message_label": "Disable Welcome Message",
diff --git a/locales/ru.json b/locales/ru.json
index 736a2b7..8af3127 100644
--- a/locales/ru.json
+++ b/locales/ru.json
@@ -23,7 +23,6 @@
"menu_language_settings_inline": "🌐 Язык",
"menu_server_status_button": "📊 Статус",
"menu_support_button": "💬 Связаться в telegram",
- "menu_terms_button": "📄 Условия сервиса",
"menu_info_button": "ℹ️ Информация",
"bot_interface_menu_title": "Интерфейс в боте",
"bot_interface_menu_webapp_hint": "В личном кабинете взаимодействовать с сервисом будет удобнее.",
@@ -1591,7 +1590,6 @@
"admin_settings_field_support_link_label": "Ссылка поддержки",
"admin_settings_field_support_link_description": "Куда вести пользователей за помощью.",
"admin_settings_field_server_status_url_label": "Ссылка на статус серверов",
- "admin_settings_field_terms_of_service_url_label": "Условия использования",
"admin_settings_field_privacy_policy_url_label": "Политика конфиденциальности",
"admin_settings_field_user_agreement_url_label": "Пользовательское соглашение",
"admin_settings_field_disable_welcome_message_label": "Скрыть приветствие /start",
diff --git a/tests/test_user_bot_menu.py b/tests/test_user_bot_menu.py
index ff48f77..588176c 100644
--- a/tests/test_user_bot_menu.py
+++ b/tests/test_user_bot_menu.py
@@ -46,7 +46,6 @@ class UserBotMenuTests(unittest.TestCase):
SUPPORT_LINK="https://t.me/support",
PRIVACY_POLICY_URL="https://example.com/privacy",
USER_AGREEMENT_URL="https://example.com/agreement",
- TERMS_OF_SERVICE_URL="",
TRIAL_ENABLED=True,
SERVER_STATUS_URL="",
SUBSCRIPTION_GUIDES_ENABLED=True,
From 2ad6b145134427a7411251d0cdab09c64660220a Mon Sep 17 00:00:00 2001
From: 3252a8 <3252a8@proton.me>
Date: Thu, 4 Jun 2026 10:51:58 +0300
Subject: [PATCH 45/58] feat: add telegram guardrails for trials and referrals
---
.../bot/app/web/admin_settings_manifest.py | 56 ++-
backend/bot/app/web/webapp/_runtime.py | 2 +-
backend/bot/app/web/webapp/auth.py | 124 +++++-
backend/bot/app/web/webapp/billing.py | 8 +
backend/bot/app/web/webapp/routes.py | 1 +
backend/bot/app/web/webapp/serializers.py | 36 +-
backend/bot/services/email_auth_service.py | 28 ++
backend/config/settings.py | 144 +++++-
docs/configuration/env-vars.md | 3 +
docs/features/tariffs.md | 4 +-
frontend/src/App.svelte | 226 +++++++++-
.../src/admin/sections/TariffsSection.svelte | 416 +++++++++++++++++-
frontend/src/lib/webapp/demoDataset.js | 10 +-
frontend/src/lib/webapp/mockApi.js | 202 +++++++++
frontend/src/lib/webapp/previewMock.js | 140 ++++++
.../webapp/settingsManifest.generated.json | 179 +++++---
.../src/lib/webapp/stores/billingStore.js | 3 +-
frontend/src/styles/webapp.css | 12 +
frontend/src/webapp/PaymentDialogs.svelte | 8 +-
frontend/src/webapp/screens/HomeScreen.svelte | 152 +++++--
.../screens/TrialActivationScreen.svelte | 45 ++
locales/en.json | 37 +-
locales/ru.json | 37 +-
tests/test_admin_settings_manifest_i18n.py | 26 ++
tests/test_webapp_referral_welcome_bonus.py | 78 ++++
tests/test_webapp_trial_activation.py | 92 ++++
26 files changed, 1944 insertions(+), 125 deletions(-)
create mode 100644 tests/test_webapp_referral_welcome_bonus.py
diff --git a/backend/bot/app/web/admin_settings_manifest.py b/backend/bot/app/web/admin_settings_manifest.py
index eb58fb0..e990f05 100644
--- a/backend/bot/app/web/admin_settings_manifest.py
+++ b/backend/bot/app/web/admin_settings_manifest.py
@@ -407,6 +407,18 @@ SETTINGS_MANIFEST: List[SettingField] = [
optional=False,
subsection="trial",
),
+ SettingField(
+ "TRIAL_WITHOUT_TELEGRAM_ENABLED",
+ "bool",
+ "pricing",
+ "Триал без Telegram",
+ (
+ "Если выключено, email-only пользователю нужно привязать Telegram для "
+ "активации триала. Disposable email домены всегда требуют Telegram."
+ ),
+ optional=False,
+ subsection="trial",
+ ),
SettingField(
"TRIAL_SQUAD_UUIDS",
"string",
@@ -417,12 +429,50 @@ SETTINGS_MANIFEST: List[SettingField] = [
),
# ─── Referral program ──────────────────────────────────────────
SettingField(
- "REFERRAL_ONE_BONUS_PER_REFEREE", "bool", "referral", "Один бонус на приглашённого"
+ "REFERRAL_ONE_BONUS_PER_REFEREE",
+ "bool",
+ "pricing",
+ "Один бонус на приглашённого",
+ subsection="referral",
),
SettingField(
- "REFERRAL_WELCOME_BONUS_DAYS", "int", "referral", "Приветственный бонус (дней)", min=0
+ "REFERRAL_WELCOME_BONUS_DAYS",
+ "int",
+ "pricing",
+ "Приветственный бонус (дней)",
+ min=0,
+ subsection="referral",
+ ),
+ SettingField(
+ "REFERRAL_WELCOME_BONUS_WITHOUT_TELEGRAM_ENABLED",
+ "bool",
+ "pricing",
+ "Приветственный бонус без Telegram",
+ (
+ "Если выключено, email-only пользователю нужно привязать Telegram для получения "
+ "реферального приветственного бонуса. Disposable email домены всегда требуют Telegram."
+ ),
+ subsection="referral",
+ ),
+ SettingField(
+ "LEGACY_REFS",
+ "bool",
+ "pricing",
+ "Поддержка старых ref-ссылок",
+ subsection="referral",
+ ),
+ SettingField(
+ "DISPOSABLE_EMAIL_DOMAINS",
+ "text",
+ "pricing",
+ "Disposable email домены",
+ (
+ "Домены по одному на строку или через запятую. Пользователи без Telegram с такими "
+ "email не смогут получить trial или реферальный приветственный бонус."
+ ),
+ placeholder="mailinator.com\ntemp-mail.org\nyopmail.com",
+ subsection="referral",
),
- SettingField("LEGACY_REFS", "bool", "referral", "Поддержка старых ref-ссылок"),
SettingField(
"MIGRATION_REMNASHOP_REFERRAL_CODE_COMPAT_ENABLED",
"bool",
diff --git a/backend/bot/app/web/webapp/_runtime.py b/backend/bot/app/web/webapp/_runtime.py
index 2a578f1..3e6a9f2 100644
--- a/backend/bot/app/web/webapp/_runtime.py
+++ b/backend/bot/app/web/webapp/_runtime.py
@@ -42,7 +42,7 @@ from bot.app.web.webapp_auth import (
verify_webapp_session_token,
)
from bot.infra.redis import cache_delete, cache_get_json, cache_set_json, get_redis, redis_key
-from bot.services.email_auth_service import EmailAuthService, normalize_email
+from bot.services.email_auth_service import EmailAuthService, is_disposable_email, normalize_email
from bot.services.email_templates import render_account_merged
from bot.services.promo_code_service import PromoCodeService
from bot.services.referral_service import ReferralService
diff --git a/backend/bot/app/web/webapp/auth.py b/backend/bot/app/web/webapp/auth.py
index e1a7f2c..88f6de4 100644
--- a/backend/bot/app/web/webapp/auth.py
+++ b/backend/bot/app/web/webapp/auth.py
@@ -1012,13 +1012,53 @@ async def _request_email_code(
def _telegram_id_for_user(user: User) -> Optional[int]:
- if user.telegram_id:
- return int(user.telegram_id)
- if user.user_id and int(user.user_id) > 0:
- return int(user.user_id)
+ telegram_id = getattr(user, "telegram_id", None)
+ if telegram_id:
+ return int(telegram_id)
+ user_id = getattr(user, "user_id", None)
+ if user_id and int(user_id) > 0:
+ return int(user_id)
return None
+def _user_has_linked_telegram(user: User) -> bool:
+ return bool(getattr(user, "telegram_id", None))
+
+
+def _email_only_telegram_required_reason(
+ settings: Settings,
+ user: User,
+ *,
+ without_telegram_enabled_attr: str,
+) -> Optional[str]:
+ if _user_has_linked_telegram(user):
+ return None
+ if is_disposable_email(getattr(user, "email", None), settings):
+ return "disposable_email"
+ if not bool(getattr(settings, without_telegram_enabled_attr, True)):
+ return "telegram_required"
+ return None
+
+
+def _trial_telegram_required_reason(settings: Settings, user: User) -> Optional[str]:
+ return _email_only_telegram_required_reason(
+ settings,
+ user,
+ without_telegram_enabled_attr="TRIAL_WITHOUT_TELEGRAM_ENABLED",
+ )
+
+
+def _referral_welcome_telegram_required_reason(
+ settings: Settings,
+ user: User,
+) -> Optional[str]:
+ return _email_only_telegram_required_reason(
+ settings,
+ user,
+ without_telegram_enabled_attr="REFERRAL_WELCOME_BONUS_WITHOUT_TELEGRAM_ENABLED",
+ )
+
+
def _panel_description_for_user(user: User) -> str:
return panel_description_from_profile(
user.username,
@@ -1433,6 +1473,21 @@ async def _apply_referral_welcome_bonus_if_needed(
if not raw_referral_param or not user.referred_by_id:
return None
+ settings: Settings = request.app["settings"]
+ if _referral_welcome_telegram_required_reason(settings, user):
+ return None
+
+ return await _grant_referral_welcome_bonus_if_eligible(request, session, user)
+
+
+async def _grant_referral_welcome_bonus_if_eligible(
+ request: web.Request,
+ session: AsyncSession,
+ user: User,
+) -> Optional[datetime]:
+ if not user.referred_by_id:
+ return None
+
settings: Settings = request.app["settings"]
referral_welcome_days = max(
0,
@@ -1456,6 +1511,67 @@ async def _apply_referral_welcome_bonus_if_needed(
)
+def _webapp_datetime_text(value: Optional[datetime]) -> Optional[str]:
+ if not value:
+ return None
+ normalized = value if value.tzinfo else value.replace(tzinfo=timezone.utc)
+ return normalized.strftime("%d.%m.%Y %H:%M")
+
+
+async def referral_welcome_bonus_claim_route(request: web.Request) -> web.Response:
+ user_id = _require_user_id(request)
+ rate_limit_response = await _enforce_webapp_rate_limit(
+ request,
+ user_id=user_id,
+ action="referral_welcome_claim",
+ )
+ if rate_limit_response:
+ return rate_limit_response
+
+ settings: Settings = request.app["settings"]
+ async_session_factory: sessionmaker = request.app["async_session_factory"]
+ async with async_session_factory() as session:
+ try:
+ db_user = await user_dal.get_user_by_id(session, user_id)
+ if not db_user or db_user.is_banned:
+ await session.rollback()
+ return _json_error(403, "access_denied", "Access denied")
+
+ reason = _referral_welcome_telegram_required_reason(settings, db_user)
+ if reason:
+ await session.rollback()
+ return _json_error(400, "referral_welcome_telegram_required", reason)
+
+ end_date = await _grant_referral_welcome_bonus_if_eligible(
+ request,
+ session,
+ db_user,
+ )
+ if not end_date:
+ await session.rollback()
+ return _json_error(
+ 400,
+ "referral_welcome_unavailable",
+ "Referral welcome bonus is not available",
+ )
+
+ await session.commit()
+ except Exception:
+ await session.rollback()
+ logger.exception("Referral welcome bonus claim failed")
+ return _json_error(500, "referral_welcome_failed", "Referral welcome bonus failed")
+
+ await _invalidate_webapp_user_caches(settings, user_id, include_devices=True)
+ return web.json_response(
+ {
+ "ok": True,
+ "claimed": True,
+ "end_date": end_date.isoformat() if isinstance(end_date, datetime) else None,
+ "end_date_text": _webapp_datetime_text(end_date),
+ }
+ )
+
+
async def _ensure_user_from_telegram(
session: AsyncSession,
telegram_user: Dict[str, Any],
diff --git a/backend/bot/app/web/webapp/billing.py b/backend/bot/app/web/webapp/billing.py
index f91b8f5..9408c74 100644
--- a/backend/bot/app/web/webapp/billing.py
+++ b/backend/bot/app/web/webapp/billing.py
@@ -1,6 +1,7 @@
# ruff: noqa: F401,F403,F405,I001
from ._runtime import * # noqa: F403,F405
+from bot.app.web.webapp.auth import _trial_telegram_required_reason
from bot.app.web.webapp.cache_helpers import invalidate_webapp_user_caches
from db.dal import message_log_dal
@@ -390,6 +391,13 @@ async def activate_trial_route(request: web.Request) -> web.Response:
db_user = await user_dal.get_user_by_id(session, user_id)
if not db_user or db_user.is_banned:
return _json_error(403, "access_denied", "Access denied")
+ telegram_required_reason = _trial_telegram_required_reason(settings, db_user)
+ if telegram_required_reason:
+ return _json_error(
+ 400,
+ "trial_telegram_required",
+ telegram_required_reason,
+ )
activation_result = await subscription_service.activate_trial_subscription(session, user_id)
if not activation_result or not activation_result.get("activated"):
diff --git a/backend/bot/app/web/webapp/routes.py b/backend/bot/app/web/webapp/routes.py
index e6535b4..3cec44b 100644
--- a/backend/bot/app/web/webapp/routes.py
+++ b/backend/bot/app/web/webapp/routes.py
@@ -85,6 +85,7 @@ def setup_subscription_webapp_routes(app: web.Application) -> None:
"/api/account/telegram/notifications/probe",
account_telegram_notifications_probe_route,
)
+ app.router.add_post("/api/referral/welcome-bonus/claim", referral_welcome_bonus_claim_route)
app.router.add_post("/api/promo/apply", apply_promo_route)
app.router.add_post("/api/trial/activate", activate_trial_route)
app.router.add_get("/api/devices", devices_route)
diff --git a/backend/bot/app/web/webapp/serializers.py b/backend/bot/app/web/webapp/serializers.py
index 1c3294b..b292679 100644
--- a/backend/bot/app/web/webapp/serializers.py
+++ b/backend/bot/app/web/webapp/serializers.py
@@ -1,6 +1,11 @@
# ruff: noqa: F401,F403,F405,I001
from ._runtime import * # noqa: F403,F405
+from bot.app.web.webapp.auth import (
+ _referral_welcome_telegram_required_reason,
+ _trial_telegram_required_reason,
+ _user_has_linked_telegram,
+)
from config.subscription_guides_config import subscription_guides_available
from config.webapp_themes_config import public_themes_catalog_payload
from bot.services.telegram_notifications import (
@@ -64,11 +69,15 @@ async def _build_user_payload(request: web.Request, user_id: int) -> Dict[str, A
if active and local_sub
else None
)
- trial_available = bool(
+ trial_base_available = bool(
settings.TRIAL_ENABLED
and settings.TRIAL_DURATION_DAYS > 0
and not await subscription_service.has_trial_blocking_subscription(session, user_id)
)
+ trial_telegram_required_reason = (
+ _trial_telegram_required_reason(settings, db_user) if trial_base_available else None
+ )
+ trial_available = bool(trial_base_available and not trial_telegram_required_reason)
lang = _normalize_language(db_user.language_code or settings.DEFAULT_LANGUAGE)
plans_payload = _serialize_plans(
settings,
@@ -95,6 +104,15 @@ async def _build_user_payload(request: web.Request, user_id: int) -> Dict[str, A
admin_ids = {int(x) for x in (settings.ADMIN_IDS or [])}
is_admin = bool(db_user.telegram_id and int(db_user.telegram_id) in admin_ids)
+ telegram_linked = _user_has_linked_telegram(db_user)
+ referral_welcome_days = max(
+ 0, int(getattr(settings, "REFERRAL_WELCOME_BONUS_DAYS", 0) or 0)
+ )
+ referral_welcome_telegram_required_reason = (
+ _referral_welcome_telegram_required_reason(settings, db_user)
+ if db_user.referred_by_id and not active and referral_welcome_days > 0
+ else None
+ )
telegram_notifications_status = normalize_telegram_notification_status(
getattr(db_user, "telegram_notifications_status", None)
)
@@ -111,7 +129,7 @@ async def _build_user_payload(request: web.Request, user_id: int) -> Dict[str, A
db_user.email and db_user.email_verified_at and db_user.password_hash
),
"telegram_id": db_user.telegram_id,
- "telegram_linked": bool(_telegram_id_for_user(db_user)),
+ "telegram_linked": telegram_linked,
"telegram_notifications_status": telegram_notifications_status,
"telegram_notifications_enabled": (
telegram_notifications_status == TELEGRAM_NOTIFICATIONS_ENABLED
@@ -137,9 +155,14 @@ async def _build_user_payload(request: web.Request, user_id: int) -> Dict[str, A
"webapp_link": webapp_referral_link,
"invited_count": referral_stats.get("invited_count", 0),
"purchased_count": referral_stats.get("purchased_count", 0),
- "welcome_bonus_days": max(
- 0, int(getattr(settings, "REFERRAL_WELCOME_BONUS_DAYS", 0) or 0)
+ "welcome_bonus_days": referral_welcome_days,
+ "welcome_bonus_without_telegram_enabled": bool(
+ getattr(settings, "REFERRAL_WELCOME_BONUS_WITHOUT_TELEGRAM_ENABLED", True)
),
+ "welcome_bonus_requires_telegram": bool(
+ referral_welcome_telegram_required_reason and not telegram_linked
+ ),
+ "welcome_bonus_block_reason": referral_welcome_telegram_required_reason,
"one_bonus_per_referee": bool(
getattr(settings, "REFERRAL_ONE_BONUS_PER_REFEREE", False)
),
@@ -174,6 +197,11 @@ async def _build_user_payload(request: web.Request, user_id: int) -> Dict[str, A
),
"trial_enabled": bool(settings.TRIAL_ENABLED),
"trial_available": trial_available,
+ "trial_without_telegram_enabled": bool(
+ getattr(settings, "TRIAL_WITHOUT_TELEGRAM_ENABLED", True)
+ ),
+ "trial_requires_telegram": bool(trial_telegram_required_reason and not telegram_linked),
+ "trial_block_reason": trial_telegram_required_reason,
"trial_duration_days": int(settings.TRIAL_DURATION_DAYS or 0),
"trial_traffic_limit_gb": float(settings.TRIAL_TRAFFIC_LIMIT_GB or 0),
"trial_traffic_strategy": getattr(settings, "TRIAL_TRAFFIC_STRATEGY", "NO_RESET"),
diff --git a/backend/bot/services/email_auth_service.py b/backend/bot/services/email_auth_service.py
index 034f780..82e9ab9 100644
--- a/backend/bot/services/email_auth_service.py
+++ b/backend/bot/services/email_auth_service.py
@@ -61,11 +61,39 @@ def normalize_email(value: str) -> str:
return (value or "").strip().lower()
+def email_domain(value: Optional[str]) -> str:
+ email = normalize_email(value or "")
+ if "@" not in email:
+ return ""
+ return email.rsplit("@", 1)[1].strip().lower().rstrip(".")
+
+
def is_valid_email(value: str) -> bool:
email = normalize_email(value)
return bool(email and len(email) <= 254 and EMAIL_RE.match(email))
+def _split_disposable_domain_values(value: str) -> list[str]:
+ return [item.strip() for item in re.split(r"[,;\s]+", value or "") if item.strip()]
+
+
+def is_disposable_email(value: Optional[str], settings: Settings) -> bool:
+ domain = email_domain(value)
+ if not domain:
+ return False
+ blocked_domains = getattr(settings, "disposable_email_domains", None)
+ if blocked_domains is None:
+ blocked_domains = _split_disposable_domain_values(
+ str(getattr(settings, "DISPOSABLE_EMAIL_DOMAINS", "") or "")
+ )
+ blocked_domains = blocked_domains or []
+ for blocked in blocked_domains:
+ normalized = str(blocked or "").strip().lower().lstrip("@.")
+ if normalized and (domain == normalized or domain.endswith(f".{normalized}")):
+ return True
+ return False
+
+
def _email_throttle_identifier(email: str, purpose: str, target_user_id: Optional[int]) -> str:
target_part = "none" if target_user_id is None else str(target_user_id)
return f"{purpose}:{target_part}:{email}"
diff --git a/backend/config/settings.py b/backend/config/settings.py
index 1a3f65b..0908818 100644
--- a/backend/config/settings.py
+++ b/backend/config/settings.py
@@ -1,5 +1,6 @@
import logging
import os
+import re
import secrets
from typing import Any, Dict, List, Optional
@@ -25,7 +26,117 @@ DEFAULT_SUBSCRIPTION_PURCHASE_DESCRIPTION_EN = (
def _split_csv(value: Optional[str]) -> List[str]:
if not value:
return []
- return [item.strip() for item in value.split(",") if item.strip()]
+ return [item.strip() for item in re.split(r"[,;\r\n]+", value) if item.strip()]
+
+
+DEFAULT_DISPOSABLE_EMAIL_DOMAINS = "\n".join(
+ [
+ "10minutemail.com",
+ "10minutemail.net",
+ "10minutemail.org",
+ "20minutemail.com",
+ "33mail.com",
+ "anonbox.net",
+ "anonymbox.com",
+ "armyspy.com",
+ "byom.de",
+ "crazymailing.com",
+ "cuvox.de",
+ "dayrep.com",
+ "deadaddress.com",
+ "dispostable.com",
+ "dodgeit.com",
+ "dodgit.com",
+ "dropmail.me",
+ "easytrashmail.com",
+ "emailfake.com",
+ "emailondeck.com",
+ "emailtemporanea.com",
+ "emailtemporanea.net",
+ "einrot.com",
+ "fakeinbox.com",
+ "filzmail.com",
+ "fleckens.hu",
+ "generator.email",
+ "getairmail.com",
+ "getnada.com",
+ "grr.la",
+ "guerrillamail.biz",
+ "guerrillamail.com",
+ "guerrillamail.de",
+ "guerrillamail.info",
+ "guerrillamail.net",
+ "guerrillamail.org",
+ "guerrillamailblock.com",
+ "gustr.com",
+ "hmamail.com",
+ "incognitomail.org",
+ "inboxbear.com",
+ "jetable.org",
+ "jourrapide.com",
+ "kasmail.com",
+ "mail-temp.com",
+ "mailcatch.com",
+ "maildrop.cc",
+ "mailexpire.com",
+ "mailinator.com",
+ "mailinator.net",
+ "mailinator.org",
+ "mailmetrash.com",
+ "mailnesia.com",
+ "mailnull.com",
+ "mailpoof.com",
+ "mailtothis.com",
+ "mail.tm",
+ "mintemail.com",
+ "mohmal.com",
+ "moakt.com",
+ "mytemp.email",
+ "mytrashmail.com",
+ "nada.email",
+ "no-spam.ws",
+ "pookmail.com",
+ "rhyta.com",
+ "sharklasers.com",
+ "sofort-mail.de",
+ "spam4.me",
+ "spambog.com",
+ "spamdecoy.net",
+ "spamfree24.org",
+ "spamgourmet.com",
+ "spamhole.com",
+ "spam.la",
+ "spammotel.com",
+ "superrito.com",
+ "teleworm.us",
+ "tempail.com",
+ "temp-mail.io",
+ "temp-mail.org",
+ "tempmail.com",
+ "tempmail.dev",
+ "tempmail.net",
+ "tempmailo.com",
+ "temporaryemail.net",
+ "temporary-mail.net",
+ "tempr.email",
+ "throwawaymail.com",
+ "trash-mail.com",
+ "trash-mail.de",
+ "trashmail.com",
+ "trashmail.me",
+ "trashmail.net",
+ "trashmailer.com",
+ "trashymail.com",
+ "weg-werf-email.de",
+ "wegwerfmail.de",
+ "wegwerfmail.net",
+ "wegwerfmail.org",
+ "yomail.info",
+ "yopmail.com",
+ "yopmail.fr",
+ "yopmail.net",
+ ]
+)
class DBSettings(BaseModel):
@@ -282,6 +393,13 @@ class Settings(BaseSettings):
default=3,
description="Welcome bonus days granted to a newly registered user who joined via referral link.", # noqa: E501
)
+ REFERRAL_WELCOME_BONUS_WITHOUT_TELEGRAM_ENABLED: bool = Field(
+ default=True,
+ description=(
+ "Allow referral welcome bonus grants for users who have not linked Telegram. "
+ "Disposable email domains are still blocked until Telegram is linked."
+ ),
+ )
LEGACY_REFS: bool = Field(
default=True,
description="Allow legacy referral links like ref_ to continue working. Defaults to True when unset.", # noqa: E501
@@ -346,6 +464,13 @@ class Settings(BaseSettings):
TRIAL_DURATION_DAYS: int = Field(default=3)
TRIAL_TRAFFIC_LIMIT_GB: Optional[float] = Field(default=5.0)
TRIAL_TRAFFIC_STRATEGY: str = Field(default="NO_RESET")
+ TRIAL_WITHOUT_TELEGRAM_ENABLED: bool = Field(
+ default=True,
+ description=(
+ "Allow trial activation for users who have not linked Telegram. "
+ "Disposable email domains are still blocked until Telegram is linked."
+ ),
+ )
TRIAL_SQUAD_UUIDS: Optional[str] = Field(
default=None,
description=(
@@ -449,6 +574,13 @@ class Settings(BaseSettings):
SMTP_PASSWORD: Optional[str] = Field(default=None)
SMTP_FROM_EMAIL: Optional[str] = Field(default=None)
SMTP_FROM_NAME: Optional[str] = Field(default=None)
+ DISPOSABLE_EMAIL_DOMAINS: str = Field(
+ default=DEFAULT_DISPOSABLE_EMAIL_DOMAINS,
+ description=(
+ "Disposable email domains treated as requiring Telegram for trial and "
+ "referral welcome bonus abuse protection. Accepts commas or one domain per line."
+ ),
+ )
SMTP_STARTTLS: bool = Field(default=True)
SMTP_USE_SSL: bool = Field(default=False)
EMAIL_CODE_TTL_SECONDS: int = Field(default=10 * 60)
@@ -633,6 +765,16 @@ class Settings(BaseSettings):
return trial_squads
return self.parsed_user_squad_uuids
+ @computed_field
+ @property
+ def disposable_email_domains(self) -> List[str]:
+ domains: List[str] = []
+ for domain in _split_csv(self.DISPOSABLE_EMAIL_DOMAINS):
+ normalized = domain.strip().lower().lstrip("@.")
+ if normalized and normalized not in domains:
+ domains.append(normalized)
+ return domains
+
@computed_field
@property
def parsed_user_external_squad_uuid(self) -> Optional[str]:
diff --git a/docs/configuration/env-vars.md b/docs/configuration/env-vars.md
index 47827fe..7b5e03e 100644
--- a/docs/configuration/env-vars.md
+++ b/docs/configuration/env-vars.md
@@ -386,10 +386,13 @@ PAYMENT_HELEKET_TELEGRAM_EMOJI
| `TRIAL_DURATION_DAYS` | Длительность пробного периода. |
| `TRIAL_TRAFFIC_LIMIT_GB` | Лимит трафика пробного периода. |
| `TRIAL_TRAFFIC_STRATEGY` | Стратегия лимита пробного периода. |
+| `TRIAL_WITHOUT_TELEGRAM_ENABLED` | Разрешает активацию trial пользователям без привязанного Telegram. Disposable email домены всё равно требуют Telegram. |
| `TRIAL_SQUAD_UUIDS` | Internal Squads для trial через запятую. Если пусто, используется `USER_SQUAD_UUIDS`. |
| `REFERRAL_ONE_BONUS_PER_REFEREE` | Ограничить бонусы одним успешным платежом приглашенного. |
| `REFERRAL_WELCOME_BONUS_DAYS` | Приветственный бонус пришедшему по реферальной ссылке. |
+| `REFERRAL_WELCOME_BONUS_WITHOUT_TELEGRAM_ENABLED` | Разрешает начислять реферальный приветственный бонус пользователям без привязанного Telegram. Disposable email домены всё равно требуют Telegram. |
| `LEGACY_REFS` | Разрешить ссылки `ref_`. |
+| `DISPOSABLE_EMAIL_DOMAINS` | Домены одноразовой почты через запятую. Для таких email trial и реферальный welcome bonus доступны только после привязки Telegram. |
| `REFERRAL_BONUS_DAYS_1_MONTH`, `REFERRAL_BONUS_DAYS_3_MONTHS`, `REFERRAL_BONUS_DAYS_6_MONTHS`, `REFERRAL_BONUS_DAYS_12_MONTHS` | Legacy-бонусы пригласившему без JSON-каталога. В JSON-тарифах используйте `referral_bonus_days_inviter`. |
| `REFEREE_BONUS_DAYS_1_MONTH`, `REFEREE_BONUS_DAYS_3_MONTHS`, `REFEREE_BONUS_DAYS_6_MONTHS`, `REFEREE_BONUS_DAYS_12_MONTHS` | Legacy-бонусы приглашенному без JSON-каталога. В JSON-тарифах используйте `referral_bonus_days_referee`. |
| `SUBSCRIPTION_NOTIFICATIONS_ENABLED` | Включает напоминания о подписке. |
diff --git a/docs/features/tariffs.md b/docs/features/tariffs.md
index dd335f8..8780c26 100644
--- a/docs/features/tariffs.md
+++ b/docs/features/tariffs.md
@@ -350,12 +350,14 @@ Remnawave ограничивает доступ при достижении `tra
Автопродление через YooKassa применяется к подпискам на срок. Для режима продажи трафика без JSON-каталога автопродление пропускается. Для traffic-тарифов JSON-каталога покупка является пакетом трафика, а не периодической подпиской.
-Пробный период использует настройки `TRIAL_DURATION_DAYS`, `TRIAL_TRAFFIC_LIMIT_GB`, `TRIAL_TRAFFIC_STRATEGY` и `TRIAL_SQUAD_UUIDS`. Он не выбирает тариф из JSON-каталога, но его можно настроить на странице **Система → Тарифы** рядом с каталогом продаж. Если `TRIAL_SQUAD_UUIDS` пустой, для trial применяются squads из `USER_SQUAD_UUIDS`.
+Пробный период использует настройки `TRIAL_DURATION_DAYS`, `TRIAL_TRAFFIC_LIMIT_GB`, `TRIAL_TRAFFIC_STRATEGY` и `TRIAL_SQUAD_UUIDS`. Он не выбирает тариф из JSON-каталога, но его можно настроить на странице **Система → Тарифы** рядом с каталогом продаж. Если `TRIAL_SQUAD_UUIDS` пустой, для trial применяются squads из `USER_SQUAD_UUIDS`. Переключатель `TRIAL_WITHOUT_TELEGRAM_ENABLED` управляет активацией trial для аккаунтов без Telegram, а домены из `DISPOSABLE_EMAIL_DOMAINS` требуют привязки Telegram независимо от этого переключателя.
Промокоды с бонусными днями применяются к покупке period-подписки.
Реферальные бонусы за оплату в JSON-каталоге задаются прямо в period-тарифе рядом с ценами периода: `referral_bonus_days_inviter` для пригласившего и `referral_bonus_days_referee` для приглашенного. Ключи этих словарей - месяцы периода (`"1"`, `"3"`, `"6"`, `"12"` или любые другие периоды тарифа, например `"2"`, `"4"`, `"8"`, `"16"`). Для `traffic`-тарифов такие бонусы не применяются.
+Приветственный бонус приглашённому (`REFERRAL_WELCOME_BONUS_DAYS`) настраивается в отдельном блоке **Реферальная программа** на странице тарифов. `REFERRAL_WELCOME_BONUS_WITHOUT_TELEGRAM_ENABLED` разрешает или запрещает выдачу этого бонуса аккаунтам без Telegram; disposable email домены из `DISPOSABLE_EMAIL_DOMAINS` всегда требуют Telegram перед начислением.
+
Если приглашенный покупает один тариф, а пригласивший находится на другом, размер бонуса берется из тарифа и периода, который купил приглашенный. При этом подписка пригласившего только продлевается на бонусные дни: лимиты, Internal Squads и другие параметры его текущего тарифа не пересчитываются под тариф приглашенного.
В Web App и Telegram-меню подробные строки по периодам показываются только для legacy-режима или когда активен один period-тариф. Если включено несколько period-тарифов, Web App показывает сообщение, что бонус зависит от тарифа и периода оплаты друга, затем список тарифов с диапазонами "от N до N дней" и раскрытием подробностей по иконке вопроса. Telegram-меню в этом случае показывает только диапазоны по каждому тарифу.
diff --git a/frontend/src/App.svelte b/frontend/src/App.svelte
index 9457e17..4670111 100644
--- a/frontend/src/App.svelte
+++ b/frontend/src/App.svelte
@@ -74,6 +74,10 @@
const ACTIVATION_PENDING_WATCH_MAX_ATTEMPTS = 45;
const ACTIVATION_RESUME_CHECK_COOLDOWN_MS = 1500;
const TELEGRAM_NOTIFICATIONS_RESUME_REFRESH_COOLDOWN_MS = 1500;
+ const TELEGRAM_LINK_PENDING_ACTION_STORAGE_KEY = "rw_webapp_telegram_link_pending_action_v1";
+ const TELEGRAM_LINK_PENDING_TTL_MS = 10 * 60 * 1000;
+ const TELEGRAM_LINK_ACTION_TRIAL = "trial";
+ const TELEGRAM_LINK_ACTION_REFERRAL_WELCOME = "referral_welcome";
import {
activationPaymentFailed,
createActivationHandoff,
@@ -171,6 +175,7 @@
let telegramNotificationsBotOpenedAt = 0;
let telegramNotificationsResumeRefreshBusy = false;
let telegramNotificationsResumeLastCheckAt = 0;
+ let telegramLinkPendingActionBusy = false;
let promoCode = "";
let promoBusy = false;
let promoStatus = "";
@@ -1183,6 +1188,161 @@
}
}
+ function currentTelegramLinkPendingUserId() {
+ const currentUser = data?.user || user || {};
+ const id = currentUser.user_id ?? currentUser.id;
+ return id == null ? "" : String(id);
+ }
+
+ function isTelegramLinkPendingAction(action) {
+ return [TELEGRAM_LINK_ACTION_TRIAL, TELEGRAM_LINK_ACTION_REFERRAL_WELCOME].includes(action);
+ }
+
+ function rememberTelegramLinkPendingAction(action) {
+ if (typeof window === "undefined" || !isTelegramLinkPendingAction(action)) return;
+ try {
+ window.sessionStorage.setItem(
+ TELEGRAM_LINK_PENDING_ACTION_STORAGE_KEY,
+ JSON.stringify({
+ action,
+ userId: currentTelegramLinkPendingUserId(),
+ createdAt: Date.now(),
+ })
+ );
+ } catch (_error) {
+ void _error;
+ }
+ }
+
+ function clearTelegramLinkPendingAction() {
+ if (typeof window === "undefined") return;
+ try {
+ window.sessionStorage.removeItem(TELEGRAM_LINK_PENDING_ACTION_STORAGE_KEY);
+ } catch (_error) {
+ void _error;
+ }
+ }
+
+ function readTelegramLinkPendingAction() {
+ if (typeof window === "undefined") return null;
+ try {
+ const raw = window.sessionStorage.getItem(TELEGRAM_LINK_PENDING_ACTION_STORAGE_KEY);
+ if (!raw) return null;
+ const payload = JSON.parse(raw);
+ const action = String(payload?.action || "");
+ const createdAt = Number(payload?.createdAt || 0);
+ const pendingUserId = String(payload?.userId || "");
+ const currentUserId = currentTelegramLinkPendingUserId();
+ if (
+ !isTelegramLinkPendingAction(action) ||
+ !createdAt ||
+ Date.now() - createdAt > TELEGRAM_LINK_PENDING_TTL_MS ||
+ (pendingUserId && currentUserId && pendingUserId !== currentUserId)
+ ) {
+ clearTelegramLinkPendingAction();
+ return null;
+ }
+ return action;
+ } catch (_error) {
+ clearTelegramLinkPendingAction();
+ return null;
+ }
+ }
+
+ async function runTelegramLinkedAction(action) {
+ if (action === TELEGRAM_LINK_ACTION_TRIAL) {
+ await activateTrial();
+ return true;
+ }
+ if (action === TELEGRAM_LINK_ACTION_REFERRAL_WELCOME) {
+ await claimReferralWelcomeBonus();
+ return true;
+ }
+ return false;
+ }
+
+ async function continueTelegramLinkPendingAction() {
+ if (telegramLinkPendingActionBusy) return false;
+ const currentUser = data?.user || user || {};
+ if (!currentUser?.telegram_linked) return false;
+ const action = readTelegramLinkPendingAction();
+ if (!action) return false;
+ telegramLinkPendingActionBusy = true;
+ clearTelegramLinkPendingAction();
+ try {
+ return await runTelegramLinkedAction(action);
+ } finally {
+ telegramLinkPendingActionBusy = false;
+ }
+ }
+
+ async function linkTelegramWithPayloadForPendingAction(payload) {
+ accountStore.update((s) => ({ ...s, linkTelegramBusy: true }));
+ try {
+ const response = await api("/account/telegram/link", {
+ method: "POST",
+ body: JSON.stringify(payload),
+ });
+ if (!response?.ok) throw response;
+ if (response?.csrf_token) setToken("", response.csrf_token);
+ await loadData({ fresh: true, preserveView: true });
+ const handled = await continueTelegramLinkPendingAction();
+ if (!handled) {
+ clearTelegramLinkPendingAction();
+ showToast(t("wa_settings_linked"));
+ }
+ } catch (error) {
+ clearTelegramLinkPendingAction();
+ showToast(error?.message || t("wa_auth_telegram_not_confirmed"));
+ } finally {
+ accountStore.update((s) => ({ ...s, linkTelegramBusy: false }));
+ }
+ }
+
+ async function linkTelegramForPendingAction(action) {
+ if (!isTelegramLinkPendingAction(action) || linkTelegramBusy || telegramLinkPendingActionBusy) {
+ return;
+ }
+ const currentUser = data?.user || user || {};
+ if (currentUser?.telegram_linked) {
+ await runTelegramLinkedAction(action);
+ return;
+ }
+
+ rememberTelegramLinkPendingAction(action);
+ if (demoAuthLogin) {
+ await linkTelegramWithPayloadForPendingAction({ auth_data: demoTelegramAuthPayload() });
+ return;
+ }
+
+ const isTelegramMiniAppAttempt = hasTelegramLaunchParams();
+ if (isTelegramMiniAppAttempt) {
+ await telegramSdk.ensureForAction();
+ }
+ const initData =
+ telegramMiniAppInitData || tg?.initData || readTelegramMiniAppInitDataFromLocation();
+ if (initData) {
+ await linkTelegramWithPayloadForPendingAction({ init_data: initData });
+ return;
+ }
+ if (!telegramOAuthClientId) {
+ clearTelegramLinkPendingAction();
+ showToast(t("wa_auth_telegram_not_configured"));
+ return;
+ }
+ await accountStore.linkTelegramAccount(
+ () => telegramMiniAppInitData || tg?.initData || readTelegramMiniAppInitDataFromLocation()
+ );
+ }
+
+ function linkTelegramAndActivateTrial() {
+ return linkTelegramForPendingAction(TELEGRAM_LINK_ACTION_TRIAL);
+ }
+
+ function linkTelegramAndClaimReferralWelcome() {
+ return linkTelegramForPendingAction(TELEGRAM_LINK_ACTION_REFERRAL_WELCOME);
+ }
+
function openTelegramNotificationsBot() {
const link = telegramNotificationsStartLink;
telegramNotificationsBotOpenedAt = Date.now();
@@ -1449,9 +1609,12 @@
getCsrfToken: () => csrfToken,
});
if (mode === "app" && screen !== "admin") {
- if (hasPendingActivationHandoff()) await loadData({ fresh: true });
- const shown = await maybeShowActivationSuccessDialog({ source: "boot" });
- if (!shown) startPendingActivationWatch();
+ const telegramActionHandled = await continueTelegramLinkPendingAction();
+ if (!telegramActionHandled) {
+ if (hasPendingActivationHandoff()) await loadData({ fresh: true });
+ const shown = await maybeShowActivationSuccessDialog({ source: "boot" });
+ if (!shown) startPendingActivationWatch();
+ }
}
}
@@ -1901,6 +2064,55 @@
}
}
+ function trialActivationFailureMessage(error) {
+ if (
+ error?.error === "trial_telegram_required" ||
+ error?.message === "telegram_required" ||
+ error?.message === "disposable_email"
+ ) {
+ return t(
+ "wa_trial_telegram_required_error",
+ {},
+ "Для активации пробного периода привяжите Telegram."
+ );
+ }
+ return error?.message || t("wa_trial_activation_failed");
+ }
+
+ function referralWelcomeFailureMessage(error) {
+ if (
+ error?.error === "referral_welcome_telegram_required" ||
+ error?.message === "telegram_required" ||
+ error?.message === "disposable_email"
+ ) {
+ return t(
+ "wa_referral_welcome_telegram_required_error",
+ {},
+ "Для получения реферального бонуса привяжите Telegram."
+ );
+ }
+ return error?.message || t("wa_referral_welcome_claim_failed");
+ }
+
+ async function claimReferralWelcomeBonus() {
+ try {
+ const response = await api("/referral/welcome-bonus/claim", {
+ method: "POST",
+ body: JSON.stringify({}),
+ });
+ if (!response.ok) throw response;
+ showToast(
+ response.end_date_text
+ ? t("wa_referral_welcome_claimed_until", { date: response.end_date_text })
+ : t("wa_referral_welcome_claimed")
+ );
+ await loadData({ fresh: true });
+ await maybeShowActivationSuccessDialog({ source: "referral_welcome", force: true });
+ } catch (error) {
+ showToast(referralWelcomeFailureMessage(error));
+ }
+ }
+
async function activateTrial() {
if (trialBusy) return;
trialBusy = true;
@@ -1917,7 +2129,7 @@
await loadData({ fresh: true });
await maybeShowActivationSuccessDialog({ source: "trial", force: true });
} catch (error) {
- const message = error?.message || t("wa_trial_activation_failed");
+ const message = trialActivationFailureMessage(error);
trialActivationError = message;
showToast(message);
} finally {
@@ -2280,7 +2492,9 @@
{premiumTrafficTopupUnlocked}
{regularTrafficTopupBarClickable}
{regularTrafficTopupUnlocked}
+ {referral}
{subscription}
+ {linkTelegramBusy}
{telegramNotificationsNeedPrompt}
{telegramNotificationsStartLink}
{telegramNotificationsStatus}
@@ -2288,6 +2502,8 @@
{trafficMode}
{trialBusy}
{activateTrial}
+ {linkTelegramAndActivateTrial}
+ {linkTelegramAndClaimReferralWelcome}
{openTelegramNotificationsBot}
openConnectLink={openInstallOrConnect}
{openPaymentModal}
@@ -2317,9 +2533,11 @@
{brandTitle}
{subscription}
{trialBusy}
+ {linkTelegramBusy}
trialResult={trialActivationResult}
trialError={trialActivationError}
{activateTrial}
+ {linkTelegramAndActivateTrial}
openInstallOrConnect={openTrialInstallOrConnect}
{goHome}
{t}
diff --git a/frontend/src/admin/sections/TariffsSection.svelte b/frontend/src/admin/sections/TariffsSection.svelte
index 0eee329..03dc217 100644
--- a/frontend/src/admin/sections/TariffsSection.svelte
+++ b/frontend/src/admin/sections/TariffsSection.svelte
@@ -1,5 +1,5 @@