From d1b9990bac4dc025249e237a778f9a46a8fef3c1 Mon Sep 17 00:00:00 2001 From: 3252a8 <3252a8@proton.me> Date: Sun, 24 May 2026 18:46:23 +0300 Subject: [PATCH] feat: manage trial settings on tariffs page --- .../bot/app/web/admin_settings_manifest.py | 36 +- .../subscription_service_impl/trial.py | 8 + backend/config/settings.py | 18 + docs/env-vars.md | 1 + docs/tariffs.md | 2 +- frontend/src/admin/AdminPanel.svelte | 2 +- .../src/admin/sections/SettingsSection.svelte | 17 + .../src/admin/sections/TariffsSection.svelte | 359 +++++++++++++++++- frontend/src/styles/admin.css | 69 ++++ tests/test_admin_settings_manifest_i18n.py | 4 + tests/test_subscription_service_behavior.py | 52 ++- 11 files changed, 558 insertions(+), 10 deletions(-) diff --git a/backend/bot/app/web/admin_settings_manifest.py b/backend/bot/app/web/admin_settings_manifest.py index a3aa1ee..30f68ee 100644 --- a/backend/bot/app/web/admin_settings_manifest.py +++ b/backend/bot/app/web/admin_settings_manifest.py @@ -266,10 +266,38 @@ SETTINGS_MANIFEST: List[SettingField] = [ subsection="common", ), # ─── Trial ───────────────────────────────────────────────────── - SettingField("TRIAL_ENABLED", "bool", "trial", "Триал включён"), - SettingField("TRIAL_DURATION_DAYS", "int", "trial", "Длительность триала (дней)", min=0), - SettingField("TRIAL_TRAFFIC_LIMIT_GB", "float", "trial", "Лимит трафика триала (ГБ)", min=0), - SettingField("TRIAL_TRAFFIC_STRATEGY", "string", "trial", "Стратегия сброса трафика триала"), + SettingField("TRIAL_ENABLED", "bool", "pricing", "Триал включён", subsection="trial"), + SettingField( + "TRIAL_DURATION_DAYS", + "int", + "pricing", + "Длительность триала (дней)", + min=0, + subsection="trial", + ), + SettingField( + "TRIAL_TRAFFIC_LIMIT_GB", + "float", + "pricing", + "Лимит трафика триала (ГБ)", + min=0, + subsection="trial", + ), + SettingField( + "TRIAL_TRAFFIC_STRATEGY", + "string", + "pricing", + "Стратегия сброса трафика триала", + subsection="trial", + ), + SettingField( + "TRIAL_SQUAD_UUIDS", + "string", + "pricing", + "Internal Squads для триала", + "UUID через запятую. Если пусто, используется USER_SQUAD_UUIDS.", + subsection="trial", + ), # ─── Referral program ────────────────────────────────────────── SettingField( "REFERRAL_ONE_BONUS_PER_REFEREE", "bool", "referral", "Один бонус на приглашённого" diff --git a/backend/bot/services/subscription_service_impl/trial.py b/backend/bot/services/subscription_service_impl/trial.py index 59f2a50..509d3d9 100644 --- a/backend/bot/services/subscription_service_impl/trial.py +++ b/backend/bot/services/subscription_service_impl/trial.py @@ -83,7 +83,15 @@ class TrialSubscriptionMixin: status="ACTIVE", traffic_limit_bytes=self.settings.trial_traffic_limit_bytes, traffic_limit_strategy=self.settings.TRIAL_TRAFFIC_STRATEGY, + include_default_squads=False, ) + trial_squads = self.settings.parsed_trial_squad_uuids + if trial_squads: + panel_update_payload["activeInternalSquads"] = trial_squads + if self.settings.parsed_user_external_squad_uuid: + panel_update_payload["externalSquadUuid"] = ( + self.settings.parsed_user_external_squad_uuid + ) panel_update_payload.update(self._panel_identity_payload_for_user(db_user)) diff --git a/backend/config/settings.py b/backend/config/settings.py index bdf63f2..74192b8 100644 --- a/backend/config/settings.py +++ b/backend/config/settings.py @@ -273,6 +273,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_SQUAD_UUIDS: Optional[str] = Field( + default=None, + description=( + "Comma-separated UUIDs of internal squads to assign during trial activation. " + "Falls back to USER_SQUAD_UUIDS when empty." + ), + ) CRYPT4_ENABLED: bool = Field( default=False, description="Enable happ crypt4 encryption for subscription URLs" @@ -543,6 +550,17 @@ class Settings(BaseSettings): return [uuid.strip() for uuid in self.USER_SQUAD_UUIDS.split(",") if uuid.strip()] return None + @computed_field + @property + def parsed_trial_squad_uuids(self) -> Optional[List[str]]: + if self.TRIAL_SQUAD_UUIDS: + trial_squads = [ + uuid.strip() for uuid in self.TRIAL_SQUAD_UUIDS.split(",") if uuid.strip() + ] + if trial_squads: + return trial_squads + return self.parsed_user_squad_uuids + @computed_field @property def parsed_user_external_squad_uuid(self) -> Optional[str]: diff --git a/docs/env-vars.md b/docs/env-vars.md index b426a39..ce5dc7e 100644 --- a/docs/env-vars.md +++ b/docs/env-vars.md @@ -355,6 +355,7 @@ PAYMENT_HELEKET_TELEGRAM_EMOJI | `TRIAL_DURATION_DAYS` | Длительность пробного периода. | | `TRIAL_TRAFFIC_LIMIT_GB` | Лимит трафика пробного периода. | | `TRIAL_TRAFFIC_STRATEGY` | Стратегия лимита пробного периода. | +| `TRIAL_SQUAD_UUIDS` | Internal Squads для trial через запятую. Если пусто, используется `USER_SQUAD_UUIDS`. | | `REFERRAL_ONE_BONUS_PER_REFEREE` | Ограничить бонусы одним успешным платежом приглашенного. | | `REFERRAL_WELCOME_BONUS_DAYS` | Приветственный бонус пришедшему по реферальной ссылке. | | `LEGACY_REFS` | Разрешить ссылки `ref_`. | diff --git a/docs/tariffs.md b/docs/tariffs.md index ad9f97e..095c92a 100644 --- a/docs/tariffs.md +++ b/docs/tariffs.md @@ -286,7 +286,7 @@ Remnawave ограничивает доступ при достижении `tra Автопродление через YooKassa применяется к подпискам на срок. Для режима продажи трафика без JSON-каталога автопродление пропускается. Для traffic-тарифов JSON-каталога покупка является пакетом трафика, а не периодической подпиской. -Пробный период использует настройки `TRIAL_DURATION_DAYS`, `TRIAL_TRAFFIC_LIMIT_GB` и `TRIAL_TRAFFIC_STRATEGY`. Он не выбирает тариф из JSON-каталога. +Пробный период использует настройки `TRIAL_DURATION_DAYS`, `TRIAL_TRAFFIC_LIMIT_GB`, `TRIAL_TRAFFIC_STRATEGY` и `TRIAL_SQUAD_UUIDS`. Он не выбирает тариф из JSON-каталога, но его можно настроить на странице **Система → Тарифы** рядом с каталогом продаж. Если `TRIAL_SQUAD_UUIDS` пустой, для trial применяются squads из `USER_SQUAD_UUIDS`. Промокоды с бонусными днями применяются к покупке period-подписки. Реферальные бонусы по периодам также относятся к подпискам на срок; в режиме продажи трафика без JSON-каталога Web App не показывает детализацию бонусов по месяцам. diff --git a/frontend/src/admin/AdminPanel.svelte b/frontend/src/admin/AdminPanel.svelte index 6b54b0b..aaaab41 100644 --- a/frontend/src/admin/AdminPanel.svelte +++ b/frontend/src/admin/AdminPanel.svelte @@ -734,7 +734,7 @@ {/if} {#if active === "tariffs"} - + {/if} {#if active === "appearance"} diff --git a/frontend/src/admin/sections/SettingsSection.svelte b/frontend/src/admin/sections/SettingsSection.svelte index 361c4a9..0630027 100644 --- a/frontend/src/admin/sections/SettingsSection.svelte +++ b/frontend/src/admin/sections/SettingsSection.svelte @@ -42,11 +42,19 @@ const PLATEGA_CRYPTO_KEYS = new Set(["PLATEGA_CRYPTO_ENABLED", "PLATEGA_CRYPTO_METHOD"]); const PLATEGA_LEGACY_KEYS = new Set(["PLATEGA_PAYMENT_METHOD"]); const LEGACY_TARIFF_TRAFFIC_KEYS = new Set(["TRAFFIC_PACKAGES", "STARS_TRAFFIC_PACKAGES"]); + const TRIAL_TARIFF_KEYS = new Set([ + "TRIAL_ENABLED", + "TRIAL_DURATION_DAYS", + "TRIAL_TRAFFIC_LIMIT_GB", + "TRIAL_TRAFFIC_STRATEGY", + "TRIAL_SQUAD_UUIDS", + ]); const SEMANTIC_FIELD_GROUP_ORDER = { platega_common: 1, platega_sbp: 2, platega_crypto: 3, platega_legacy: 4, + trial_tariff_settings: 0, legacy_tariff_periods: 1, legacy_tariff_traffic: 2, }; @@ -283,6 +291,15 @@ function legacyTariffSemanticGroup(field) { const key = String(field?.key || ""); + if (TRIAL_TARIFF_KEYS.has(key)) { + return fieldGroupMeta( + "trial_tariff_settings", + "settings_group_trial_tariff_settings", + "Trial access", + "settings_group_trial_tariff_settings_hint", + "Trial duration, traffic limit, and Remnawave squads are also available on the Tariffs page." + ); + } if (LEGACY_TARIFF_TRAFFIC_KEYS.has(key)) { return fieldGroupMeta( "legacy_tariff_traffic", diff --git a/frontend/src/admin/sections/TariffsSection.svelte b/frontend/src/admin/sections/TariffsSection.svelte index 1effedb..02ab6ff 100644 --- a/frontend/src/admin/sections/TariffsSection.svelte +++ b/frontend/src/admin/sections/TariffsSection.svelte @@ -1,17 +1,73 @@ @@ -69,6 +182,134 @@ +
+
+
+

{at("tariffs_trial_title", {}, "Trial access")}

+ + {at( + "tariffs_trial_subtitle", + {}, + "Configure trial duration, traffic limit, and Remnawave squads from the tariff page." + )} + +
+
+ + {boolValue("TRIAL_ENABLED") + ? at("enabled", {}, "Enabled") + : at("disabled", {}, "Disabled")} + + {#if trialDirtyCount} + + {at("settings_dirty_count", { count: trialDirtyCount }, `Changes: ${trialDirtyCount}`)} + + + + {settingsSaving ? at("btn_saving", {}, "Saving...") : at("btn_save", {}, "Save")} + + {/if} +
+
+
+
+
+ + + +
+
+ + +
+
+
+
+
@@ -192,4 +433,116 @@ {/if}
+ +
+
+
+

{at("tariffs_legacy_title", {}, "Legacy tariff compatibility")}

+ + {at( + "tariffs_legacy_subtitle", + {}, + "Old remnawave-tg-shop periods and traffic packages used only when the JSON tariff catalog is not configured." + )} + +
+
+ {#if legacyDirtyCount} + + {at( + "settings_dirty_count", + { count: legacyDirtyCount }, + `Changes: ${legacyDirtyCount}` + )} + + + + {settingsSaving ? at("btn_saving", {}, "Saving...") : at("btn_save", {}, "Save")} + + {/if} +
+
+
+
+
+ +
+
+ {at("tariffs_legacy_period", {}, "Period")} + {at("tariffs_legacy_enabled", {}, "Enabled")} + {at("payment_rub", {}, "RUB")} + {at("payment_stars", {}, "Stars")} +
+ {#each LEGACY_PERIODS as [months, enabledKey, rubKey, starsKey]} +
+ {months} {at("months_short", {}, "mo")} +
+ setSetting(enabledKey, checked)} + class="admin-switch-root" + > + + +
+ setSetting(rubKey, event.currentTarget.value)} + /> + setSetting(starsKey, event.currentTarget.value)} + /> +
+ {/each} +
+ +
+ + +
+
+
{/if} diff --git a/frontend/src/styles/admin.css b/frontend/src/styles/admin.css index 728243d..3a3f6a4 100644 --- a/frontend/src/styles/admin.css +++ b/frontend/src/styles/admin.css @@ -2814,6 +2814,67 @@ flex-wrap: wrap; } +.admin-tariff-settings-card { + margin-top: 14px; +} + +.admin-tariff-settings-form { + gap: 14px; +} + +.admin-field-label-compact { + gap: 8px; +} + +.admin-field-label-compact .input, +.admin-field-label-compact .admin-select-trigger { + width: 100%; +} + +.admin-legacy-tariff-table { + display: grid; + gap: 0; + overflow: hidden; + border: 1px solid var(--admin-border); + border-radius: 8px; + background: var(--admin-surface-2); +} + +.admin-legacy-tariff-row { + display: grid; + grid-template-columns: minmax(72px, 0.7fr) minmax(92px, 0.8fr) minmax(0, 1fr) minmax(0, 1fr); + gap: 10px; + align-items: center; + min-width: 0; + padding: 10px 12px; + border-top: 1px solid var(--admin-border); +} + +.admin-legacy-tariff-row:first-child { + border-top: 0; +} + +.admin-legacy-tariff-head { + color: var(--admin-muted); + font-size: 11px; + font-weight: 700; + text-transform: uppercase; +} + +.admin-legacy-tariff-row strong { + color: var(--admin-text); + font-size: 13px; +} + +.admin-legacy-tariff-row .input { + width: 100%; + min-width: 0; +} + +.admin-legacy-traffic-row { + margin-top: 14px; +} + .admin-tariff-dialog { width: min(980px, calc(100vw - 32px)); } @@ -3028,6 +3089,14 @@ padding: 12px; } + .admin-legacy-tariff-row { + grid-template-columns: minmax(0, 1fr); + } + + .admin-legacy-tariff-head { + display: none; + } + .admin-tariff-actions .admin-btn { flex: 1 1 calc(50% - 4px); } diff --git a/tests/test_admin_settings_manifest_i18n.py b/tests/test_admin_settings_manifest_i18n.py index e0ff7b1..69249c6 100644 --- a/tests/test_admin_settings_manifest_i18n.py +++ b/tests/test_admin_settings_manifest_i18n.py @@ -121,6 +121,10 @@ def test_legacy_tariff_settings_are_separated_from_payment_settings(): assert payment_method_fields[0]["section"] == "payments" assert manifest["MONTH_1_ENABLED"]["section"] == "pricing" assert manifest["MONTH_1_ENABLED"]["section_order"] == 11 + assert manifest["TRIAL_ENABLED"]["section"] == "pricing" + assert manifest["TRIAL_ENABLED"]["subsection"] == "trial" + assert manifest["TRIAL_SQUAD_UUIDS"]["section"] == "pricing" + assert manifest["TRIAL_SQUAD_UUIDS"]["subsection"] == "trial" def test_platega_settings_share_one_admin_subsection(): diff --git a/tests/test_subscription_service_behavior.py b/tests/test_subscription_service_behavior.py index 52cc953..3820186 100644 --- a/tests/test_subscription_service_behavior.py +++ b/tests/test_subscription_service_behavior.py @@ -198,7 +198,8 @@ class SubscriptionServiceActivationDispatchTests(unittest.IsolatedAsyncioTestCas TRIAL_DURATION_DAYS=3, TRIAL_TRAFFIC_LIMIT_GB=5, TRIAL_TRAFFIC_STRATEGY="WEEK", - USER_SQUAD_UUIDS="trial-squad", + USER_SQUAD_UUIDS="fallback-squad", + TRIAL_SQUAD_UUIDS="trial-squad", ) service = _make_service(settings) service.has_had_any_subscription = AsyncMock(return_value=False) @@ -241,6 +242,55 @@ class SubscriptionServiceActivationDispatchTests(unittest.IsolatedAsyncioTestCas panel_payload = service.panel_service.update_user_details_on_panel.await_args.args[1] self.assertEqual(panel_payload["trafficLimitStrategy"], "WEEK") + self.assertEqual(panel_payload["activeInternalSquads"], ["trial-squad"]) + + async def test_activate_trial_falls_back_to_default_user_squads(self): + with tempfile.TemporaryDirectory() as tmpdir: + settings = _make_settings( + _tariffs_config_payload(), + tmpdir, + TRIAL_ENABLED=True, + TRIAL_DURATION_DAYS=3, + USER_SQUAD_UUIDS="fallback-a,fallback-b", + TRIAL_SQUAD_UUIDS=" , ", + ) + service = _make_service(settings) + service.has_had_any_subscription = AsyncMock(return_value=False) + service._get_or_create_panel_user_link_details = AsyncMock( + return_value=("panel-user", "panel-sub", "short", True) + ) + service.panel_service.update_user_details_on_panel = AsyncMock( + return_value={"subscriptionUrl": "https://example.test/sub", "shortUuid": "short"} + ) + session = AsyncMock() + db_user = SimpleNamespace( + user_id=42, + telegram_id=42, + email=None, + username="trial-user", + first_name="Trial", + last_name="User", + ) + + with ( + patch( + "bot.services.subscription_service_impl.trial.user_dal.get_user_by_id", + AsyncMock(return_value=db_user), + ), + patch( + "bot.services.subscription_service_impl.trial.subscription_dal.deactivate_other_active_subscriptions", + AsyncMock(), + ), + patch( + "bot.services.subscription_service_impl.trial.subscription_dal.upsert_subscription", + AsyncMock(), + ), + ): + result = await service.activate_trial_subscription(session, user_id=42) + + self.assertTrue(result["activated"]) + panel_payload = service.panel_service.update_user_details_on_panel.await_args.args[1] + self.assertEqual(panel_payload["activeInternalSquads"], ["fallback-a", "fallback-b"]) async def test_activate_subscription_dispatches_traffic_sale_mode(self): with tempfile.TemporaryDirectory() as tmpdir: