From 023ab375ec435551334f4b4314fcd2bb1b3ad113 Mon Sep 17 00:00:00 2001 From: 3252a8 <3252a8@proton.me> Date: Mon, 1 Jun 2026 23:22:12 +0300 Subject: [PATCH 01/58] fix(admin): hide users filter toggle on desktop The toggle carried both .admin-btn and .admin-users-filter-toggle, which tied on specificity with the base button display rule and kept the button visible on desktop. Scope the show/hide rules to .admin-btn.admin-users-filter-toggle so it only appears on mobile. --- frontend/src/admin/sections/UsersSection.svelte | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/src/admin/sections/UsersSection.svelte b/frontend/src/admin/sections/UsersSection.svelte index 605651d..b6c2141 100644 --- a/frontend/src/admin/sections/UsersSection.svelte +++ b/frontend/src/admin/sections/UsersSection.svelte @@ -595,7 +595,7 @@ min-width: 82px; } - :global(.admin-users-filter-toggle) { + :global(.admin-btn.admin-users-filter-toggle) { display: none; position: relative; align-items: center; @@ -846,7 +846,7 @@ padding-inline: 10px; } - :global(.admin-users-filter-toggle) { + :global(.admin-btn.admin-users-filter-toggle) { display: inline-flex; min-width: 38px; padding-inline: 10px; From 619a13128c4d163e4be1352b10147b3cacc25052 Mon Sep 17 00:00:00 2001 From: 3252a8 <3252a8@proton.me> Date: Mon, 1 Jun 2026 23:29:50 +0300 Subject: [PATCH 02/58] fix(admin): enlarge mobile extend control and reset trial button to 46px On mobile the extend input/button and the reset-trial button rendered at 36px, which felt cramped for touch. Bump them to 46px in the max-width:640px block. --- frontend/src/styles/admin.css | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/frontend/src/styles/admin.css b/frontend/src/styles/admin.css index 2604b6a..b33de09 100644 --- a/frontend/src/styles/admin.css +++ b/frontend/src/styles/admin.css @@ -4323,12 +4323,16 @@ .admin-extend-control .input, .admin-extend-control .admin-btn { - height: 36px; + height: 46px; } .admin-extend-control .admin-btn { border-width: 1px 0 0; } + + .admin-reset-trial-btn { + height: 46px; + } } /* Two-column variant of admin-form-row */ From 56796d9f22f0f4305c83a8fab6bc4a73fd898b56 Mon Sep 17 00:00:00 2001 From: 3252a8 <3252a8@proton.me> Date: Mon, 1 Jun 2026 23:37:59 +0300 Subject: [PATCH 03/58] feat(admin): add never-subscribed broadcast target with audience counts Add a broadcast audience for users who registered but never had any subscription or trial (no Subscription rows at all), backed by a new get_user_ids_without_any_subscription DAL helper and a 'never' target in the webapp broadcast route. Add GET /api/admin/broadcast/audience-counts so the audience dropdown shows the recipient count next to each option, with graceful fallback when counts are unavailable. --- .../bot/app/web/admin_api_impl/broadcast.py | 21 ++++++++++++++++- backend/bot/app/web/admin_api_impl/routes.py | 1 + backend/db/dal/user_dal.py | 23 +++++++++++++++++++ .../admin/sections/BroadcastSection.svelte | 17 +++++++++++--- .../src/lib/admin/stores/broadcastStore.js | 17 ++++++++++++++ frontend/src/lib/webapp/mockApi.js | 6 +++++ locales/en.json | 1 + locales/ru.json | 1 + tests/test_webapp_route_contract.py | 1 + 9 files changed, 84 insertions(+), 4 deletions(-) diff --git a/backend/bot/app/web/admin_api_impl/broadcast.py b/backend/bot/app/web/admin_api_impl/broadcast.py index b19bc0a..7daae95 100644 --- a/backend/bot/app/web/admin_api_impl/broadcast.py +++ b/backend/bot/app/web/admin_api_impl/broadcast.py @@ -9,7 +9,7 @@ async def admin_broadcast_route(request: web.Request) -> web.Response: target = str(payload.get("target") or "all").strip().lower() if not text: return _error(400, "empty_text") - if target not in {"all", "active", "inactive", "expired"}: + if target not in {"all", "active", "inactive", "expired", "never"}: target = "all" queue_manager = get_queue_manager() @@ -24,6 +24,8 @@ async def admin_broadcast_route(request: web.Request) -> web.Response: user_ids = await user_dal.get_user_ids_without_active_subscription(session) elif target == "expired": user_ids = await user_dal.get_user_ids_with_expired_subscription(session) + elif target == "never": + user_ids = await user_dal.get_user_ids_without_any_subscription(session) else: user_ids = await user_dal.get_all_active_user_ids_for_broadcast(session) @@ -54,3 +56,20 @@ async def admin_broadcast_route(request: web.Request) -> web.Response: ) return _ok({"queued": sent, "failed": failed, "target": target}) + + +async def admin_broadcast_audience_counts_route(request: web.Request) -> web.Response: + """Return how many users each broadcast audience currently resolves to.""" + _require_admin_user_id(request) + + async_session_factory: sessionmaker = request.app["async_session_factory"] + async with async_session_factory() as session: + counts = { + "all": len(await user_dal.get_all_active_user_ids_for_broadcast(session)), + "active": len(await user_dal.get_user_ids_with_active_subscription(session)), + "inactive": len(await user_dal.get_user_ids_without_active_subscription(session)), + "expired": len(await user_dal.get_user_ids_with_expired_subscription(session)), + "never": len(await user_dal.get_user_ids_without_any_subscription(session)), + } + + return _ok({"counts": counts}) diff --git a/backend/bot/app/web/admin_api_impl/routes.py b/backend/bot/app/web/admin_api_impl/routes.py index 6b0a10c..a7e418c 100644 --- a/backend/bot/app/web/admin_api_impl/routes.py +++ b/backend/bot/app/web/admin_api_impl/routes.py @@ -57,6 +57,7 @@ def setup_admin_routes(app: web.Application) -> None: router.add_post("/api/admin/support/tickets/{id:\\d+}/read", admin_support_ticket_read_route) router.add_get("/api/admin/support/stats", admin_support_stats_route) + router.add_get("/api/admin/broadcast/audience-counts", admin_broadcast_audience_counts_route) router.add_post("/api/admin/broadcast", admin_broadcast_route) router.add_post("/api/admin/sync", admin_sync_route) diff --git a/backend/db/dal/user_dal.py b/backend/db/dal/user_dal.py index 6a3c1af..bc58eef 100644 --- a/backend/db/dal/user_dal.py +++ b/backend/db/dal/user_dal.py @@ -857,6 +857,29 @@ async def get_user_ids_without_active_subscription(session: AsyncSession) -> Lis return result.scalars().all() +async def get_user_ids_without_any_subscription(session: AsyncSession) -> List[int]: + """Return non-banned user IDs who never had any subscription or trial. + + These are users who registered but have no ``Subscription`` rows at all — + no active, no expired and no trial history. In other words, accounts that + signed up and never did anything. + """ + any_sub = aliased(Subscription) + + stmt = ( + select(User.user_id) + .outerjoin(any_sub, any_sub.user_id == User.user_id) + .where( + and_( + User.is_banned == False, + any_sub.user_id.is_(None), + ) + ) + ) + result = await session.execute(stmt) + return result.scalars().all() + + def _expired_subscription_exists_for_user(now: datetime): expired_subs = aliased(Subscription) normalized_status = func.lower(func.coalesce(expired_subs.status_from_panel, "")) diff --git a/frontend/src/admin/sections/BroadcastSection.svelte b/frontend/src/admin/sections/BroadcastSection.svelte index 5c6c79d..849bac0 100644 --- a/frontend/src/admin/sections/BroadcastSection.svelte +++ b/frontend/src/admin/sections/BroadcastSection.svelte @@ -1,16 +1,27 @@
@@ -24,7 +35,7 @@ {at("broadcast_label_audience", {}, "Аудитория")} broadcastStore.updateField({ broadcastTarget: value })} /> diff --git a/frontend/src/lib/admin/stores/broadcastStore.js b/frontend/src/lib/admin/stores/broadcastStore.js index 7e7e208..3a1af9f 100644 --- a/frontend/src/lib/admin/stores/broadcastStore.js +++ b/frontend/src/lib/admin/stores/broadcastStore.js @@ -6,6 +6,7 @@ export function createBroadcastStore({ api, onToast, at }) { broadcastText: "", broadcastBusy: false, broadcastResult: null, + broadcastCounts: null, }); const BROADCAST_TARGET_OPTIONS = [ @@ -13,8 +14,23 @@ export function createBroadcastStore({ api, onToast, at }) { { value: "active", label: at("broadcast_target_active", {}, "С подпиской") }, { value: "inactive", label: at("broadcast_target_inactive", {}, "Без подписки") }, { value: "expired", label: at("broadcast_target_expired", {}, "Expired subscription") }, + { + value: "never", + label: at("broadcast_target_never", {}, "Без подписки и без истории"), + }, ]; + async function loadCounts() { + try { + const res = await api("/admin/broadcast/audience-counts"); + if (res?.ok && res.counts) { + state.update((s) => ({ ...s, broadcastCounts: res.counts })); + } + } catch { + // Counts are advisory; ignore failures and keep plain labels. + } + } + async function runBroadcast() { let text = ""; let target = ""; @@ -56,6 +72,7 @@ export function createBroadcastStore({ api, onToast, at }) { update: state.update, runBroadcast, updateField, + loadCounts, BROADCAST_TARGET_OPTIONS, }; } diff --git a/frontend/src/lib/webapp/mockApi.js b/frontend/src/lib/webapp/mockApi.js index a2a279f..cfbdf45 100644 --- a/frontend/src/lib/webapp/mockApi.js +++ b/frontend/src/lib/webapp/mockApi.js @@ -658,6 +658,12 @@ function demoApiResponse(path, cleanPath, options, context) { const params = queryParams(path); if (cleanPath === "/admin/stats") return clone(DEMO_DATASET.stats); + if (cleanPath === "/admin/broadcast/audience-counts") { + return { + ok: true, + counts: { all: 1280, active: 742, inactive: 538, expired: 311, never: 227 }, + }; + } if (cleanPath === "/admin/sync") return { ok: true, status: "queued" }; if (cleanPath === "/admin/payments") { diff --git a/locales/en.json b/locales/en.json index 4929475..cf3ffff 100644 --- a/locales/en.json +++ b/locales/en.json @@ -1143,6 +1143,7 @@ "admin_broadcast_target_active": "With subscription", "admin_broadcast_target_inactive": "No subscription", "admin_broadcast_target_expired": "Expired subscription", + "admin_broadcast_target_never": "No subscription, no history", "admin_expired_at": "Expired {date}", "admin_expired_badge": "Expired {date}", "admin_stats_error": "Failed to load statistics: {error}", diff --git a/locales/ru.json b/locales/ru.json index 3e21e15..4ce0660 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -1143,6 +1143,7 @@ "admin_broadcast_target_active": "С подпиской", "admin_broadcast_target_inactive": "Без подписки", "admin_broadcast_target_expired": "С просроченной подпиской", + "admin_broadcast_target_never": "Без подписки и без истории", "admin_expired_at": "Истекла {date}", "admin_expired_badge": "Expired {date}", "admin_stats_error": "Не удалось загрузить статистику: {error}", diff --git a/tests/test_webapp_route_contract.py b/tests/test_webapp_route_contract.py index 80418fd..f1378ac 100644 --- a/tests/test_webapp_route_contract.py +++ b/tests/test_webapp_route_contract.py @@ -183,6 +183,7 @@ class WebAppRouteContractTests(unittest.TestCase): ("PATCH", "/api/admin/promos/{promo_id}"): "admin_promo_update_route", ("DELETE", "/api/admin/promos/{promo_id}"): "admin_promo_delete_route", ("GET", "/api/admin/logs"): "admin_logs_route", + ("GET", "/api/admin/broadcast/audience-counts"): "admin_broadcast_audience_counts_route", ("POST", "/api/admin/broadcast"): "admin_broadcast_route", ("POST", "/api/admin/sync"): "admin_sync_route", ("GET", "/api/admin/ads"): "admin_ads_list_route", From 5e47a1496bbd4d0e42d8bd90f2a2da377971279f Mon Sep 17 00:00:00 2001 From: 3252a8 <3252a8@proton.me> Date: Tue, 2 Jun 2026 00:13:54 +0300 Subject: [PATCH 04/58] feat: add Remnashop migration import Add the Remnashop legacy importer, compatibility tables, admin toggles, referral and promo lookup compatibility, and tests for the migration flow. --- .../bot/app/web/admin_settings_manifest.py | 33 + backend/bot/app/web/webapp/auth.py | 92 +- backend/bot/handlers/user/start.py | 89 +- backend/bot/services/promo_code_service.py | 18 +- backend/config/settings.py | 18 + backend/db/dal/promo_code_dal.py | 48 +- backend/db/dal/user_dal.py | 99 +- backend/db/migrator.py | 78 ++ backend/db/models.py | 33 +- backend/scripts/__init__.py | 2 + backend/scripts/import_legacy.py | 1164 +++++++++++++++++ .../src/admin/sections/SettingsSection.svelte | 1 + locales/en.json | 10 + locales/ru.json | 10 + tests/test_admin_settings_manifest_i18n.py | 28 + tests/test_remnashop_import.py | 44 + tests/test_support_migration.py | 23 +- tests/test_user_dal.py | 4 + 18 files changed, 1731 insertions(+), 63 deletions(-) create mode 100644 backend/scripts/__init__.py create mode 100644 backend/scripts/import_legacy.py create mode 100644 tests/test_remnashop_import.py diff --git a/backend/bot/app/web/admin_settings_manifest.py b/backend/bot/app/web/admin_settings_manifest.py index 9c86789..cd8680e 100644 --- a/backend/bot/app/web/admin_settings_manifest.py +++ b/backend/bot/app/web/admin_settings_manifest.py @@ -402,6 +402,38 @@ SETTINGS_MANIFEST: List[SettingField] = [ "REFERRAL_WELCOME_BONUS_DAYS", "int", "referral", "Приветственный бонус (дней)", min=0 ), SettingField("LEGACY_REFS", "bool", "referral", "Поддержка старых ref-ссылок"), + SettingField( + "MIGRATION_REMNASHOP_REFERRAL_CODE_COMPAT_ENABLED", + "bool", + "migrations", + "Старые ref-ссылки Remnashop", + "Принимать импортированные ref-коды Remnashop вместе с текущими кодами пользователей.", + subsection="Remnashop", + ), + SettingField( + "MIGRATION_REMNASHOP_PROMO_CODE_COMPAT_ENABLED", + "bool", + "migrations", + "Старые промокоды Remnashop", + "Пробовать точное совпадение промокода перед обычной uppercase-нормализацией.", + subsection="Remnashop", + ), + SettingField( + "MIGRATION_REMNASHOP_IMPORTED_AT", + "string", + "migrations", + "Последний импорт Remnashop", + "Заполняется скриптом импорта. Можно очистить, если отметка больше не нужна.", + subsection="Remnashop", + ), + SettingField( + "MIGRATION_REMNASHOP_NOTES", + "text", + "migrations", + "Заметки по миграции Remnashop", + "Внутренние заметки оператора по перенесенному инстансу.", + subsection="Remnashop", + ), # ─── Notifications ───────────────────────────────────────────── SettingField( "SUBSCRIPTION_NOTIFICATIONS_ENABLED", @@ -734,6 +766,7 @@ def manifest_payload() -> List[dict]: "devices": 10, "subscription_guides": 10, "system": 12, + "migrations": 13, } exclusive_map = { key: opposite diff --git a/backend/bot/app/web/webapp/auth.py b/backend/bot/app/web/webapp/auth.py index f1bfbe5..e1a7f2c 100644 --- a/backend/bot/app/web/webapp/auth.py +++ b/backend/bot/app/web/webapp/auth.py @@ -713,6 +713,7 @@ async def email_auth_verify_route(request: web.Request) -> web.Response: session, referral_param, current_user_id=None, + settings=settings, ) db_user, _ = await user_dal.create_email_user( session, @@ -821,6 +822,7 @@ async def email_auth_magic_route(request: web.Request) -> web.Response: session, referral_param, current_user_id=None, + settings=settings, ) db_user, _ = await user_dal.create_email_user( session, @@ -1292,17 +1294,35 @@ async def _link_telegram_to_user( return current_user -def _normalize_referral_param(raw: Optional[str]) -> Optional[str]: +def _remnashop_referral_compat_enabled(settings: Optional[Settings]) -> bool: + if settings is None: + return False + return bool(getattr(settings, "MIGRATION_REMNASHOP_REFERRAL_CODE_COMPAT_ENABLED", False)) + + +def _strip_referral_param_prefix( + raw: Optional[str], + *, + preserve_current_u_prefix: bool, +) -> str: value = (raw or "").strip() if not value: - return None + return "" value_lower = value.lower() - if value_lower.startswith("ref_u"): + if value_lower.startswith("ref_u") and not preserve_current_u_prefix: value = value[5:] elif value_lower.startswith("ref_"): value = value[4:] - elif value and value[0].lower() == "u" and len(value) == 10: + return value + + +def _normalize_referral_param(raw: Optional[str]) -> Optional[str]: + value = _strip_referral_param_prefix(raw, preserve_current_u_prefix=False) + if not value: + return None + + if value and value[0].lower() == "u" and len(value) == 10: value = value[1:] if not re.fullmatch(r"[A-Za-z0-9]{1,32}", value): @@ -1310,26 +1330,64 @@ def _normalize_referral_param(raw: Optional[str]) -> Optional[str]: return value.upper() +def _referral_param_lookup_candidates( + raw: Optional[str], + *, + remnashop_compat: bool, +) -> List[str]: + if not remnashop_compat: + normalized = _normalize_referral_param(raw) + return [normalized] if normalized else [] + + value = _strip_referral_param_prefix(raw, preserve_current_u_prefix=True) + if not value or not re.fullmatch(r"[A-Za-z0-9._:-]{1,128}", value): + return [] + + candidates = [value] + if value and value[0].lower() == "u": + candidates.append(value[1:]) + + unique: List[str] = [] + for candidate in candidates: + if candidate and candidate not in unique: + unique.append(candidate) + return unique + + async def _resolve_referrer_id( session: AsyncSession, raw_referral_param: Optional[str], *, current_user_id: Optional[int], + settings: Optional[Settings] = None, ) -> Optional[int]: - normalized = _normalize_referral_param(raw_referral_param) - if not normalized: + remnashop_compat = _remnashop_referral_compat_enabled(settings) + candidates = _referral_param_lookup_candidates( + raw_referral_param, + remnashop_compat=remnashop_compat, + ) + if not candidates: return None - ref_user = None - if normalized.isdigit(): - ref_user = await user_dal.get_user_by_id(session, int(normalized)) - if not ref_user: - ref_user = await user_dal.get_user_by_referral_code(session, normalized) - if not ref_user: - return None - if current_user_id is not None and int(ref_user.user_id) == int(current_user_id): - return None - return int(ref_user.user_id) + for normalized in candidates: + ref_user = None + if normalized.isdigit() and not remnashop_compat: + ref_user = await user_dal.get_user_by_id(session, int(normalized)) + if not ref_user: + ref_user = await user_dal.get_user_by_referral_code( + session, + normalized, + include_legacy=remnashop_compat, + ) + if not ref_user and normalized.isdigit() and remnashop_compat: + ref_user = await user_dal.get_user_by_id(session, int(normalized)) + if not ref_user: + continue + if current_user_id is not None and int(ref_user.user_id) == int(current_user_id): + continue + return int(ref_user.user_id) + + return None async def _apply_referral_to_existing_user( @@ -1345,6 +1403,7 @@ async def _apply_referral_to_existing_user( session, raw_referral_param, current_user_id=int(user.user_id), + settings=request.app["settings"], ) if not referred_by_id: return False @@ -1427,6 +1486,7 @@ async def _ensure_user_from_telegram( session, referral_param or telegram_user.get("start_param"), current_user_id=user_id, + settings=settings, ) db_user, created = await user_dal.create_user( session, diff --git a/backend/bot/handlers/user/start.py b/backend/bot/handlers/user/start.py index 44cc598..79425a8 100644 --- a/backend/bot/handlers/user/start.py +++ b/backend/bot/handlers/user/start.py @@ -40,6 +40,67 @@ from db.models import User router = Router(name="user_start_router") +def _remnashop_referral_compat_enabled(settings: Settings) -> bool: + return bool(getattr(settings, "MIGRATION_REMNASHOP_REFERRAL_CODE_COMPAT_ENABLED", False)) + + +def _referral_code_lookup_candidates( + raw_ref_value: str, + *, + remnashop_compat: bool, +) -> list[str]: + value = str(raw_ref_value or "").strip() + if not value: + return [] + + candidates = [value] + if value and value[0].lower() == "u": + stripped_current_prefix = value[1:] + if remnashop_compat: + candidates.append(stripped_current_prefix) + else: + candidates = [stripped_current_prefix] + + unique: list[str] = [] + for candidate in candidates: + candidate = candidate.strip() + if candidate and candidate not in unique: + unique.append(candidate) + return unique + + +async def _resolve_referrer_from_start_ref( + session: AsyncSession, + raw_ref_value: str, + *, + settings: Settings, + current_user_id: int, +) -> Optional[int]: + ref_user: Optional[User] = None + if raw_ref_value.isdigit() and settings.LEGACY_REFS: + potential_referrer_id = int(raw_ref_value) + if potential_referrer_id != current_user_id: + ref_user = await user_dal.get_user_by_id(session, potential_referrer_id) + + include_legacy = _remnashop_referral_compat_enabled(settings) + if not ref_user: + for code in _referral_code_lookup_candidates( + raw_ref_value, + remnashop_compat=include_legacy, + ): + ref_user = await user_dal.get_user_by_referral_code( + session, + code, + include_legacy=include_legacy, + ) + if ref_user: + break + + if ref_user and ref_user.user_id != current_user_id: + return int(ref_user.user_id) + return None + + async def should_show_trial_button( settings: Settings, subscription_service: SubscriptionService, @@ -412,12 +473,10 @@ async def ensure_required_channel_subscription( @router.message(CommandStart()) @router.message( CommandStart( - magic=F.args.regexp(r"^ref_((?:[uU][A-Za-z0-9]{9})|(?:[A-Za-z0-9]{9})|\d+)$").as_( - "ref_match" - ) + magic=F.args.regexp(r"^ref_([A-Za-z0-9_-]{1,64})$").as_("ref_match") ) ) -@router.message(CommandStart(magic=F.args.regexp(r"^promo_(\w+)$").as_("promo_match"))) +@router.message(CommandStart(magic=F.args.regexp(r"^promo_([A-Za-z0-9_-]{1,100})$").as_("promo_match"))) @router.message(CommandStart(magic=F.args.regexp(r"^admin_user_(\d+)$").as_("admin_user_match"))) @router.message(CommandStart(magic=F.args.regexp(r"^ticket_(\d+)$").as_("ticket_match"))) @router.message(CommandStart(magic=F.args.regexp(r"^notifications$").as_("notifications_match"))) @@ -534,22 +593,12 @@ async def start_command_handler( if ref_match: raw_ref_value = ref_match.group(1) - if raw_ref_value.isdigit(): - if settings.LEGACY_REFS: - potential_referrer_id = int(raw_ref_value) - if potential_referrer_id != user_id and await user_dal.get_user_by_id( - session, potential_referrer_id - ): - referred_by_user_id = potential_referrer_id - else: - normalized_code = raw_ref_value.strip() - if normalized_code and normalized_code[0].lower() == "u": - normalized_code = normalized_code[1:] - ref_user = None - if normalized_code: - ref_user = await user_dal.get_user_by_referral_code(session, normalized_code) - if ref_user and ref_user.user_id != user_id: - referred_by_user_id = ref_user.user_id + referred_by_user_id = await _resolve_referrer_from_start_ref( + session, + raw_ref_value, + settings=settings, + current_user_id=user_id, + ) elif promo_match: promo_code_to_apply = promo_match.group(1) logging.info(f"User {user_id} started with promo code: {promo_code_to_apply}") diff --git a/backend/bot/services/promo_code_service.py b/backend/bot/services/promo_code_service.py index b486595..6f6a63d 100644 --- a/backend/bot/services/promo_code_service.py +++ b/backend/bot/services/promo_code_service.py @@ -38,8 +38,12 @@ class PromoCodeService: user_lang: str, ) -> Tuple[bool, datetime | str]: _ = lambda k, **kw: self.i18n.gettext(user_lang, k, **kw) - code_input_upper = (code_input or "").strip().upper()[:100] - code_display = html_escape(code_input_upper[:100], quote=False) + preserve_case = bool( + getattr(self.settings, "MIGRATION_REMNASHOP_PROMO_CODE_COMPAT_ENABLED", False) + ) + code_input_clean = (code_input or "").strip()[:100] + lookup_code = code_input_clean if preserve_case else code_input_clean.upper() + code_display = html_escape(lookup_code[:100], quote=False) throttle_identifier = self._throttle_identifier(user_id) throttle = await security_dal.check_throttle( @@ -54,7 +58,7 @@ class PromoCodeService: ) promo_data = await promo_code_dal.get_active_promo_code_by_code_str( - session, code_input_upper + session, lookup_code, preserve_case=preserve_case ) if not promo_data: @@ -71,9 +75,11 @@ class PromoCodeService: "promo_code_too_many_attempts", seconds=throttle_result.retry_after or max(1, int(self.settings.BRUTE_FORCE_LOCK_SECONDS)), - ) + ) return False, _("promo_code_not_found", code=code_display) + applied_code = str(promo_data.code or lookup_code) + code_display = html_escape(applied_code[:100], quote=False) existing_activation = await promo_code_dal.get_user_activation_for_promo( session, promo_data.promo_code_id, user_id ) @@ -86,7 +92,7 @@ class PromoCodeService: session=session, user_id=user_id, bonus_days=bonus_days, - reason=f"promo code {code_input_upper}", + reason=f"promo code {applied_code}", ) if new_end_date: @@ -109,7 +115,7 @@ class PromoCodeService: user = await user_dal.get_user_by_id(session, user_id) await notification_service.notify_promo_activation( user_id=user_id, - promo_code=code_input_upper, + promo_code=applied_code, bonus_days=bonus_days, username=user.username if user else None, email=getattr(user, "email", None) if user else None, diff --git a/backend/config/settings.py b/backend/config/settings.py index bd09406..3c856c7 100644 --- a/backend/config/settings.py +++ b/backend/config/settings.py @@ -286,6 +286,24 @@ class Settings(BaseSettings): default=True, description="Allow legacy referral links like ref_ to continue working. Defaults to True when unset.", # noqa: E501 ) + MIGRATION_REMNASHOP_REFERRAL_CODE_COMPAT_ENABLED: bool = Field( + default=False, + description=( + "Accept referral links imported from snoups/remnashop via legacy_referral_codes." + ), + ) + MIGRATION_REMNASHOP_PROMO_CODE_COMPAT_ENABLED: bool = Field( + default=False, + description="Try exact legacy Remnashop promo codes before uppercase normalization.", + ) + MIGRATION_REMNASHOP_IMPORTED_AT: Optional[str] = Field( + default=None, + description="Timestamp of the latest Remnashop import run, managed by the import script.", + ) + MIGRATION_REMNASHOP_NOTES: Optional[str] = Field( + default=None, + description="Operator notes for instances migrated from Remnashop.", + ) APP_RUNTIME_MODE: str = Field( default="production", diff --git a/backend/db/dal/promo_code_dal.py b/backend/db/dal/promo_code_dal.py index 8047792..41a376b 100644 --- a/backend/db/dal/promo_code_dal.py +++ b/backend/db/dal/promo_code_dal.py @@ -22,24 +22,46 @@ async def get_promo_code_by_id(session: AsyncSession, promo_code_id: int) -> Opt return await session.get(PromoCode, promo_code_id) -async def get_promo_code_by_code(session: AsyncSession, code_str: str) -> Optional[PromoCode]: +def _promo_lookup_candidates(code_str: str, *, preserve_case: bool) -> List[str]: + code = str(code_str or "").strip() + if not code: + return [] + candidates = [code] if preserve_case else [] + upper_code = code.upper() + if upper_code not in candidates: + candidates.append(upper_code) + return candidates + + +async def get_promo_code_by_code( + session: AsyncSession, code_str: str, *, preserve_case: bool = False +) -> Optional[PromoCode]: """Get promo code by code string (regardless of active status)""" - stmt = select(PromoCode).where(PromoCode.code == code_str.upper()) - result = await session.execute(stmt) - return result.scalar_one_or_none() + for candidate in _promo_lookup_candidates(code_str, preserve_case=preserve_case): + stmt = select(PromoCode).where(PromoCode.code == candidate) + result = await session.execute(stmt) + promo = result.scalar_one_or_none() + if promo: + return promo + return None async def get_active_promo_code_by_code_str( - session: AsyncSession, code_str: str + session: AsyncSession, code_str: str, *, preserve_case: bool = False ) -> Optional[PromoCode]: - stmt = select(PromoCode).where( - PromoCode.code == code_str.upper(), - PromoCode.is_active == True, - PromoCode.current_activations < PromoCode.max_activations, - or_(PromoCode.valid_until == None, PromoCode.valid_until > datetime.now(timezone.utc)), - ) - result = await session.execute(stmt) - return result.scalar_one_or_none() + now = datetime.now(timezone.utc) + for candidate in _promo_lookup_candidates(code_str, preserve_case=preserve_case): + stmt = select(PromoCode).where( + PromoCode.code == candidate, + PromoCode.is_active == True, + PromoCode.current_activations < PromoCode.max_activations, + or_(PromoCode.valid_until == None, PromoCode.valid_until > now), + ) + result = await session.execute(stmt) + promo = result.scalar_one_or_none() + if promo: + return promo + return None async def get_all_active_promo_codes( diff --git a/backend/db/dal/user_dal.py b/backend/db/dal/user_dal.py index bc58eef..0acc533 100644 --- a/backend/db/dal/user_dal.py +++ b/backend/db/dal/user_dal.py @@ -4,7 +4,7 @@ import string from datetime import datetime, timedelta, timezone from typing import Any, Dict, List, Optional, Tuple -from sqlalchemy import and_, case, delete, desc, func, or_, update +from sqlalchemy import String, and_, case, cast, delete, desc, func, or_, update from sqlalchemy.dialects.postgresql import insert as pg_insert from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.future import select @@ -14,6 +14,8 @@ from ..models import ( AdAttribution, EmailVerificationCode, HwidDevicePurchase, + LegacyImportMapping, + LegacyReferralCode, MessageLog, Payment, PromoCodeActivation, @@ -76,7 +78,7 @@ async def ensure_referral_code(session: AsyncSession, user: User) -> str: Returns the existing or newly generated code. """ if user.referral_code: - normalized = user.referral_code.strip().upper() + normalized = user.referral_code.strip() if normalized != user.referral_code: user.referral_code = normalized await session.flush() @@ -210,7 +212,7 @@ async def create_user(session: AsyncSession, user_data: Dict[str, Any]) -> Tuple if not user_data.get("referral_code"): user_data["referral_code"] = await generate_unique_referral_code(session) else: - user_data["referral_code"] = user_data["referral_code"].strip().upper() + user_data["referral_code"] = user_data["referral_code"].strip() # Use PostgreSQL upsert to avoid IntegrityError on concurrent inserts stmt = ( @@ -567,6 +569,19 @@ async def merge_users( await session.execute( update(model).where(model.user_id == source_user_id).values(user_id=target_user_id) ) + await session.execute( + update(LegacyReferralCode) + .where(LegacyReferralCode.user_id == source_user_id) + .values(user_id=target_user_id) + ) + await session.execute( + update(LegacyImportMapping) + .where( + LegacyImportMapping.target_table == "users", + LegacyImportMapping.target_id == str(source_user_id), + ) + .values(target_id=str(target_user_id)) + ) await session.execute( update(MessageLog) @@ -590,13 +605,60 @@ async def merge_users( return target -async def get_user_by_referral_code(session: AsyncSession, referral_code: str) -> Optional[User]: - normalized = referral_code.strip().upper() +async def get_user_by_referral_code( + session: AsyncSession, + referral_code: str, + *, + include_legacy: bool = False, +) -> Optional[User]: + normalized = referral_code.strip() if not normalized: return None + stmt = select(User).where(User.referral_code == normalized) result = await session.execute(stmt) - return result.scalar_one_or_none() + user = result.scalar_one_or_none() + if user: + return user + + upper_normalized = normalized.upper() + if upper_normalized != normalized: + stmt = select(User).where(User.referral_code == upper_normalized) + result = await session.execute(stmt) + user = result.scalar_one_or_none() + if user: + return user + + if not include_legacy: + return None + + stmt = ( + select(User) + .join(LegacyReferralCode, LegacyReferralCode.user_id == User.user_id) + .where(LegacyReferralCode.code == normalized, LegacyReferralCode.is_active == True) + .limit(1) + ) + result = await session.execute(stmt) + user = result.scalar_one_or_none() + if user: + return user + + if upper_normalized != normalized: + stmt = ( + select(User) + .join(LegacyReferralCode, LegacyReferralCode.user_id == User.user_id) + .where( + LegacyReferralCode.code == upper_normalized, + LegacyReferralCode.is_active == True, + ) + .limit(1) + ) + result = await session.execute(stmt) + user = result.scalar_one_or_none() + if user: + return user + + return None async def update_user( @@ -1020,6 +1082,31 @@ async def delete_user_and_relations(session: AsyncSession, user_id: int) -> bool await session.execute(delete(UserBilling).where(UserBilling.user_id == user_id)) await session.execute(delete(AdAttribution).where(AdAttribution.user_id == user_id)) await session.execute(delete(UserTelegramAvatar).where(UserTelegramAvatar.user_id == user_id)) + await session.execute(delete(LegacyReferralCode).where(LegacyReferralCode.user_id == user_id)) + await session.execute( + delete(LegacyImportMapping).where( + or_( + and_( + LegacyImportMapping.target_table == "users", + LegacyImportMapping.target_id == str(user_id), + ), + and_( + LegacyImportMapping.target_table == "subscriptions", + LegacyImportMapping.target_id.in_( + select(cast(Subscription.subscription_id, String)).where( + Subscription.user_id == user_id + ) + ), + ), + and_( + LegacyImportMapping.target_table == "payments", + LegacyImportMapping.target_id.in_( + select(cast(Payment.payment_id, String)).where(Payment.user_id == user_id) + ), + ), + ) + ) + ) await session.execute(delete(Payment).where(Payment.user_id == user_id)) await session.execute(delete(Subscription).where(Subscription.user_id == user_id)) diff --git a/backend/db/migrator.py b/backend/db/migrator.py index 73b3e3c..18a76fa 100644 --- a/backend/db/migrator.py +++ b/backend/db/migrator.py @@ -1070,6 +1070,79 @@ def _migration_0033_add_trial_eligibility_reset_marker(connection: Connection) - ) +def _migration_0034_add_legacy_import_compatibility(connection: Connection) -> None: + inspector = inspect(connection) + table_names = set(inspector.get_table_names()) + + if "users" in table_names: + columns = {col["name"]: col for col in inspector.get_columns("users")} + referral_column = columns.get("referral_code") + length = getattr(referral_column.get("type"), "length", None) if referral_column else None + if referral_column and (length is None or int(length) < 64): + connection.execute( + text("ALTER TABLE users ALTER COLUMN referral_code TYPE VARCHAR(64)") + ) + + connection.execute( + text( + """ + CREATE TABLE IF NOT EXISTS legacy_referral_codes ( + legacy_code_id SERIAL PRIMARY KEY, + source VARCHAR(64) NOT NULL DEFAULT 'remnashop', + code VARCHAR(128) NOT NULL, + user_id BIGINT NOT NULL REFERENCES users(user_id), + is_active BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NULL, + CONSTRAINT uq_legacy_referral_source_code UNIQUE (source, code) + ) + """ + ) + ) + for stmt in [ + ( + "CREATE INDEX IF NOT EXISTS ix_legacy_referral_codes_source " + "ON legacy_referral_codes (source)" + ), + "CREATE INDEX IF NOT EXISTS ix_legacy_referral_codes_code ON legacy_referral_codes (code)", + ( + "CREATE INDEX IF NOT EXISTS ix_legacy_referral_codes_user_id " + "ON legacy_referral_codes (user_id)" + ), + ( + "CREATE INDEX IF NOT EXISTS ix_legacy_referral_codes_is_active " + "ON legacy_referral_codes (is_active)" + ), + ]: + connection.execute(text(stmt)) + + connection.execute( + text( + """ + CREATE TABLE IF NOT EXISTS legacy_import_mappings ( + source VARCHAR(64) NOT NULL, + entity_type VARCHAR(64) NOT NULL, + source_id VARCHAR(128) NOT NULL, + target_table VARCHAR(128) NOT NULL, + target_id VARCHAR(128) NOT NULL, + metadata_json TEXT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NULL, + PRIMARY KEY (source, entity_type, source_id) + ) + """ + ) + ) + connection.execute( + text( + """ + CREATE INDEX IF NOT EXISTS ix_legacy_import_mappings_target + ON legacy_import_mappings (target_table, target_id) + """ + ) + ) + + MIGRATIONS: List[Migration] = [ Migration( id="0001_add_channel_subscription_fields", @@ -1247,6 +1320,11 @@ MIGRATIONS: List[Migration] = [ description="Track admin resets of per-user trial eligibility without deleting history", upgrade=_migration_0033_add_trial_eligibility_reset_marker, ), + Migration( + id="0034_add_legacy_import_compatibility", + description="Store legacy import mappings and referral codes for source-bot migrations", + upgrade=_migration_0034_add_legacy_import_compatibility, + ), ] diff --git a/backend/db/models.py b/backend/db/models.py index 78bb8c3..e524d0d 100644 --- a/backend/db/models.py +++ b/backend/db/models.py @@ -43,7 +43,7 @@ class User(Base): registration_date = Column(DateTime(timezone=True), server_default=func.now()) is_banned = Column(Boolean, default=False) panel_user_uuid = Column(String, nullable=True, unique=True, index=True) - referral_code = Column(String(16), nullable=True, unique=True, index=True) + referral_code = Column(String(64), nullable=True, unique=True, index=True) referred_by_id = Column(BigInteger, ForeignKey("users.user_id"), nullable=True) lifetime_used_traffic_bytes = Column(BigInteger, nullable=True) lifetime_used_traffic_synced_at = Column(DateTime(timezone=True), nullable=True) @@ -396,6 +396,37 @@ class PromoCodeActivation(Base): ) +class LegacyReferralCode(Base): + __tablename__ = "legacy_referral_codes" + + legacy_code_id = Column(Integer, primary_key=True, autoincrement=True) + source = Column(String(64), nullable=False, default="remnashop", index=True) + code = Column(String(128), nullable=False, index=True) + user_id = Column(BigInteger, ForeignKey("users.user_id"), nullable=False, index=True) + is_active = Column(Boolean, nullable=False, default=True, index=True) + created_at = Column(DateTime(timezone=True), server_default=func.now()) + updated_at = Column(DateTime(timezone=True), onupdate=func.now(), nullable=True) + + user = relationship("User") + + __table_args__ = ( + UniqueConstraint("source", "code", name="uq_legacy_referral_source_code"), + ) + + +class LegacyImportMapping(Base): + __tablename__ = "legacy_import_mappings" + + source = Column(String(64), primary_key=True) + entity_type = Column(String(64), primary_key=True) + source_id = Column(String(128), primary_key=True) + target_table = Column(String(128), nullable=False) + target_id = Column(String(128), nullable=False) + metadata_json = Column(Text, nullable=True) + created_at = Column(DateTime(timezone=True), server_default=func.now()) + updated_at = Column(DateTime(timezone=True), onupdate=func.now(), nullable=True) + + class MessageLog(Base): __tablename__ = "message_logs" diff --git a/backend/scripts/__init__.py b/backend/scripts/__init__.py new file mode 100644 index 0000000..7e7e6c0 --- /dev/null +++ b/backend/scripts/__init__.py @@ -0,0 +1,2 @@ +"""Operational one-shot scripts shipped with the backend image.""" + diff --git a/backend/scripts/import_legacy.py b/backend/scripts/import_legacy.py new file mode 100644 index 0000000..b2fede7 --- /dev/null +++ b/backend/scripts/import_legacy.py @@ -0,0 +1,1164 @@ +"""Import data from legacy source bots into the current shop database. + +Currently supported source: + remnashop + +Example: + python backend/scripts/import_legacy.py \ + --source-type remnashop \ + --source-dsn postgresql://user:pass@localhost:5432/remnashop \ + --dry-run +""" + +from __future__ import annotations + +import argparse +import asyncio +import json +import logging +import re +import sys +from collections import defaultdict +from datetime import datetime, timedelta, timezone +from decimal import Decimal, InvalidOperation +from pathlib import Path +from typing import Any, Iterable, Optional + +from sqlalchemy import inspect, select, text +from sqlalchemy.dialects.postgresql import insert as pg_insert +from sqlalchemy.ext.asyncio import ( + AsyncConnection, + AsyncSession, + async_sessionmaker, + create_async_engine, +) + +BACKEND_ROOT = Path(__file__).resolve().parents[1] +if str(BACKEND_ROOT) not in sys.path: + sys.path.insert(0, str(BACKEND_ROOT)) + +from config.settings import Settings # noqa: E402 +from db.dal import user_dal # noqa: E402 +from db.migrator import run_database_migrations # noqa: E402 +from db.models import ( # noqa: E402 + AppSettingOverride, + Base, + LegacyImportMapping, + LegacyReferralCode, + MessageLog, + Payment, + PromoCode, + PromoCodeActivation, + Subscription, + User, +) + +SOURCE = "remnashop" +GIB = 1024**3 +UUID_RE = re.compile( + r"\b[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-" + r"[0-9a-fA-F]{4}-[0-9a-fA-F]{12}\b" +) +SAFE_SCHEMA_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$") + +logger = logging.getLogger(__name__) + + +def normalize_async_postgres_dsn(dsn: str) -> str: + value = str(dsn or "").strip() + if value.startswith("postgresql+asyncpg://"): + return value + if value.startswith("postgresql://"): + return "postgresql+asyncpg://" + value.removeprefix("postgresql://") + if value.startswith("postgres://"): + return "postgresql+asyncpg://" + value.removeprefix("postgres://") + return value + + +def _json_default(value: Any) -> str: + if isinstance(value, (datetime, Decimal)): + return str(value) + return str(value) + + +def _json_dumps(value: Any) -> str: + return json.dumps(value, ensure_ascii=False, sort_keys=True, default=_json_default) + + +def _safe_schema_name(schema: str) -> str: + value = str(schema or "public").strip() + if not SAFE_SCHEMA_RE.fullmatch(value): + raise ValueError(f"Unsafe PostgreSQL schema name: {schema!r}") + return value + + +def _qtable(schema: str, table: str) -> str: + schema = _safe_schema_name(schema) + return f'"{schema}"."{table}"' + + +def _as_mapping(row: Any) -> dict[str, Any]: + return dict(row._mapping if hasattr(row, "_mapping") else row) + + +def _as_utc(value: Any) -> Optional[datetime]: + if value is None: + return None + if isinstance(value, datetime): + result = value + else: + text_value = str(value).strip() + if not text_value: + return None + try: + result = datetime.fromisoformat(text_value.replace("Z", "+00:00")) + except ValueError: + return None + if result.tzinfo is None: + return result.replace(tzinfo=timezone.utc) + return result.astimezone(timezone.utc) + + +def _to_decimal(value: Any) -> Optional[Decimal]: + if value is None: + return None + try: + return Decimal(str(value)) + except (InvalidOperation, ValueError): + return None + + +def _to_int(value: Any) -> Optional[int]: + number = _to_decimal(value) + if number is None: + return None + try: + return int(number) + except (OverflowError, ValueError): + return None + + +def _split_name(name: Any) -> tuple[Optional[str], Optional[str]]: + value = str(name or "").strip() + if not value: + return None, None + parts = value.split(maxsplit=1) + if len(parts) == 1: + return parts[0][:255], None + return parts[0][:255], parts[1][:255] + + +def _jsonish(value: Any) -> dict[str, Any]: + if isinstance(value, dict): + return value + if isinstance(value, str) and value.strip(): + try: + decoded = json.loads(value) + except ValueError: + return {} + return decoded if isinstance(decoded, dict) else {} + return {} + + +def _listish(value: Any) -> list[Any]: + if value is None: + return [] + if isinstance(value, list): + return value + if isinstance(value, tuple): + return list(value) + return [value] + + +def remnashop_traffic_gb_to_bytes(value: Any) -> Optional[int]: + number = _to_decimal(value) + if number is None: + return None + return int(number * GIB) + + +def remnashop_pricing_amount(pricing: Any) -> float: + data = _jsonish(pricing) + for key in ("final_amount", "total_amount", "amount", "price"): + number = _to_decimal(data.get(key)) + if number is not None: + return float(number) + return 0.0 + + +def remnashop_pricing_currency(pricing: Any, fallback: Any = None) -> str: + data = _jsonish(pricing) + currency = str(data.get("currency") or fallback or "RUB").strip().upper() + return currency or "RUB" + + +def remnashop_transaction_status(status: Any, gateway_type: Any = None) -> str: + source_status = str(status or "").strip().upper() + provider = str(gateway_type or "").strip().lower() + if source_status == "COMPLETED": + return "succeeded" + if source_status == "PENDING": + return f"pending_{provider}" if provider else "pending" + if source_status == "CANCELED": + return "canceled" + if source_status == "REFUNDED": + return "refunded" + if source_status == "FAILED": + return "failed" + return source_status.lower() or "unknown" + + +def remnashop_sale_mode(purchase_type: Any) -> str: + source_type = str(purchase_type or "").strip().upper() + if source_type in {"NEW", "RENEW"}: + return "subscription" + if source_type == "CHANGE": + return "tariff_upgrade" + return source_type.lower() or "subscription" + + +def remnashop_months_from_plan_snapshot( + plan_snapshot: Any, + *, + created_at: Any = None, + expire_at: Any = None, +) -> Optional[int]: + data = _jsonish(plan_snapshot) + for key in ("duration_months", "months", "month"): + months = _to_int(data.get(key)) + if months and months > 0: + return months + + for key in ("duration_days", "days", "duration"): + days = _to_int(data.get(key)) + if days and days > 0: + return max(1, round(days / 30)) + + start = _as_utc(created_at) + end = _as_utc(expire_at) + if start and end and end > start: + return max(1, round((end - start).days / 30)) + return None + + +def remnashop_tariff_key(plan_snapshot: Any, tariff_map: dict[str, str]) -> Optional[str]: + data = _jsonish(plan_snapshot) + candidates = [ + data.get("id"), + data.get("name"), + data.get("tag"), + data.get("public_code"), + ] + for candidate in candidates: + key = str(candidate or "").strip() + if key and key in tariff_map: + return tariff_map[key] + return None + + +def _provider_value(gateway_type: Any) -> str: + value = str(gateway_type or "remnashop").strip().lower() + if value == "telegram_stars": + return "stars" + return value or "remnashop" + + +def _extract_panel_subscription_uuid(url: Any, panel_user_uuid: Optional[str]) -> Optional[str]: + value = str(url or "") + if not value: + return None + panel_user_uuid = str(panel_user_uuid or "").lower() + for match in UUID_RE.finditer(value): + candidate = match.group(0).lower() + if candidate != panel_user_uuid: + return candidate + return None + + +def _legacy_user_metadata(row: dict[str, Any]) -> dict[str, Any]: + keys = ( + "id", + "points", + "personal_discount", + "purchase_discount", + "role", + "is_rules_accepted", + "is_trial_available", + "language", + "current_subscription_id", + ) + return {key: row.get(key) for key in keys if row.get(key) is not None} + + +def _counter() -> dict[str, int]: + return defaultdict(int) + + +class RemnashopImporter: + def __init__( + self, + *, + source: AsyncConnection, + target: AsyncSession, + source_schema: str, + only: set[str], + on_conflict: str, + dry_run: bool, + created_by_admin_id: int, + tariff_map: dict[str, str], + write_admin_compat_overrides: bool, + ) -> None: + self.source = source + self.target = target + self.source_schema = _safe_schema_name(source_schema) + self.only = only + self.on_conflict = on_conflict + self.dry_run = dry_run + self.created_by_admin_id = created_by_admin_id + self.tariff_map = tariff_map + self.write_admin_compat_overrides = write_admin_compat_overrides + self.tables: set[str] = set() + self.user_map: dict[int, int] = {} + self.summary: dict[str, Any] = { + "source": SOURCE, + "dry_run": dry_run, + "on_conflict": on_conflict, + "users": _counter(), + "referrals": _counter(), + "subscriptions": _counter(), + "payments": _counter(), + "promocodes": _counter(), + "settings": _counter(), + "warnings": [], + } + + async def run(self) -> dict[str, Any]: + self.tables = await self._source_tables() + await self._warn_missing_tables() + + if self._should_run("users"): + await self.import_users() + if self._should_run("referrals"): + await self.import_referrals() + if self._should_run("subscriptions"): + await self.import_subscriptions() + if self._should_run("payments"): + await self.import_payments() + if self._should_run("promocodes"): + await self.import_promocodes() + if self._should_run("settings"): + await self.import_settings() + + if self.write_admin_compat_overrides: + await self._write_admin_overrides() + + return self._plain_summary() + + def _plain_summary(self) -> dict[str, Any]: + result = dict(self.summary) + for key, value in list(result.items()): + if isinstance(value, defaultdict): + result[key] = dict(value) + return result + + def _should_run(self, key: str) -> bool: + return not self.only or key in self.only or "all" in self.only + + async def _source_tables(self) -> set[str]: + def load_tables(sync_connection: Any) -> set[str]: + return set(inspect(sync_connection).get_table_names(schema=self.source_schema)) + + return await self.source.run_sync(load_tables) + + async def _warn_missing_tables(self) -> None: + required = {"users", "subscriptions", "transactions", "referrals", "settings"} + missing = sorted(required - self.tables) + if missing: + self.summary["warnings"].append(f"Missing source tables: {', '.join(missing)}") + + async def _fetch_rows(self, table: str, *, order_by: str = "id") -> list[dict[str, Any]]: + if table not in self.tables: + return [] + order_sql = f" ORDER BY {order_by}" if order_by else "" + result = await self.source.execute( + text(f"SELECT * FROM {_qtable(self.source_schema, table)}{order_sql}") + ) + return [_as_mapping(row) for row in result.mappings().all()] + + async def _fetch_one(self, table: str) -> Optional[dict[str, Any]]: + rows = await self._fetch_rows(table, order_by="") + return rows[0] if rows else None + + async def _latest_panel_uuid_by_telegram(self) -> dict[int, str]: + if "subscriptions" not in self.tables: + return {} + result = await self.source.execute( + text( + f""" + SELECT DISTINCT ON (user_telegram_id) + user_telegram_id, + user_remna_id + FROM {_qtable(self.source_schema, "subscriptions")} + WHERE user_remna_id IS NOT NULL + ORDER BY user_telegram_id, updated_at DESC NULLS LAST, id DESC + """ + ) + ) + panel_by_tg: dict[int, str] = {} + for row in result.mappings().all(): + telegram_id = _to_int(row.get("user_telegram_id")) + panel_uuid = str(row.get("user_remna_id") or "").strip() + if telegram_id and panel_uuid: + panel_by_tg[telegram_id] = panel_uuid + return panel_by_tg + + async def _target_user_for_telegram(self, telegram_id: Any) -> Optional[User]: + normalized = _to_int(telegram_id) + if normalized is None: + return None + user = await user_dal.get_user_by_telegram_id(self.target, normalized) + if not user: + user = await user_dal.get_user_by_id(self.target, normalized) + if user: + self.user_map[normalized] = int(user.user_id) + return user + + def _can_overwrite(self) -> bool: + return self.on_conflict == "overwrite" + + def _can_merge_existing(self) -> bool: + return self.on_conflict in {"merge", "overwrite"} + + def _assign_if_allowed(self, model: Any, attr: str, value: Any) -> bool: + if value is None: + return False + current = getattr(model, attr, None) + if self._can_overwrite() or current in (None, ""): + setattr(model, attr, value) + return True + return False + + async def _upsert_mapping( + self, + *, + entity_type: str, + source_id: Any, + target_table: str, + target_id: Any, + metadata: Optional[dict[str, Any]] = None, + ) -> None: + now = datetime.now(timezone.utc) + source_id_value = str(source_id) + target_id_value = str(target_id) + stmt = ( + pg_insert(LegacyImportMapping) + .values( + source=SOURCE, + entity_type=entity_type, + source_id=source_id_value, + target_table=target_table, + target_id=target_id_value, + metadata_json=_json_dumps(metadata or {}), + updated_at=now, + ) + .on_conflict_do_update( + index_elements=[ + LegacyImportMapping.source, + LegacyImportMapping.entity_type, + LegacyImportMapping.source_id, + ], + set_={ + "target_table": target_table, + "target_id": target_id_value, + "metadata_json": _json_dumps(metadata or {}), + "updated_at": now, + }, + ) + ) + await self.target.execute(stmt) + + async def _get_mapping(self, entity_type: str, source_id: Any) -> Optional[LegacyImportMapping]: + stmt = select(LegacyImportMapping).where( + LegacyImportMapping.source == SOURCE, + LegacyImportMapping.entity_type == entity_type, + LegacyImportMapping.source_id == str(source_id), + ) + result = await self.target.execute(stmt) + return result.scalar_one_or_none() + + async def _upsert_setting_override(self, key: str, value: Any) -> None: + now = datetime.now(timezone.utc) + encoded = json.dumps(value, ensure_ascii=False, separators=(",", ":")) + stmt = ( + pg_insert(AppSettingOverride) + .values( + key=key, + value=encoded, + updated_at=now, + updated_by=self.created_by_admin_id or None, + ) + .on_conflict_do_update( + index_elements=[AppSettingOverride.key], + set_={ + "value": encoded, + "updated_at": now, + "updated_by": self.created_by_admin_id or None, + }, + ) + ) + await self.target.execute(stmt) + + async def _upsert_legacy_referral_code(self, *, code: str, user_id: int) -> None: + if len(code) > 128: + self.summary["warnings"].append( + f"Skipped overlong legacy referral code for user {user_id}: {len(code)} chars" + ) + return + now = datetime.now(timezone.utc) + stmt = ( + pg_insert(LegacyReferralCode) + .values( + source=SOURCE, + code=code, + user_id=user_id, + is_active=True, + updated_at=now, + ) + .on_conflict_do_update( + index_elements=[LegacyReferralCode.source, LegacyReferralCode.code], + set_={"user_id": user_id, "is_active": True, "updated_at": now}, + ) + ) + await self.target.execute(stmt) + + async def _record_user_state_note( + self, + *, + telegram_id: int, + user_id: int, + metadata: dict[str, Any], + ) -> None: + if not metadata: + return + if await self._get_mapping("user_state", telegram_id): + return + log = MessageLog( + user_id=None, + target_user_id=user_id, + event_type="legacy_remnashop_user_state", + content=_json_dumps(metadata), + is_admin_event=True, + ) + self.target.add(log) + await self.target.flush() + await self._upsert_mapping( + entity_type="user_state", + source_id=telegram_id, + target_table="message_logs", + target_id=log.log_id, + metadata=metadata, + ) + + async def _source_referral_code_conflicts(self, code: str, user_id: int) -> bool: + existing = await user_dal.get_user_by_referral_code( + self.target, + code, + include_legacy=False, + ) + return bool(existing and int(existing.user_id) != int(user_id)) + + async def import_users(self) -> None: + rows = await self._fetch_rows("users", order_by="telegram_id") + panel_by_tg = await self._latest_panel_uuid_by_telegram() + for row in rows: + telegram_id = _to_int(row.get("telegram_id")) + if telegram_id is None: + self.summary["users"]["skipped"] += 1 + continue + + first_name, last_name = _split_name(row.get("name")) + panel_uuid = panel_by_tg.get(telegram_id) + referral_code = str(row.get("referral_code") or "").strip() or None + created_at = _as_utc(row.get("created_at")) or datetime.now(timezone.utc) + language = str(row.get("language") or "ru").strip().lower()[:8] or "ru" + + existing = await self._target_user_for_telegram(telegram_id) + if existing and self.on_conflict == "skip": + target = existing + self.summary["users"]["skipped"] += 1 + elif existing: + target = existing + if self._can_merge_existing(): + self._assign_if_allowed(target, "username", row.get("username")) + self._assign_if_allowed(target, "first_name", first_name) + self._assign_if_allowed(target, "last_name", last_name) + self._assign_if_allowed(target, "language_code", language) + self._assign_if_allowed(target, "panel_user_uuid", panel_uuid) + if bool(row.get("is_blocked")): + target.is_banned = True + elif self._can_overwrite(): + target.is_banned = False + if bool(row.get("is_bot_blocked")): + target.telegram_notifications_status = "blocked" + target.telegram_notifications_checked_at = datetime.now(timezone.utc) + target.telegram_notifications_blocked_at = datetime.now(timezone.utc) + if referral_code and len(referral_code) <= 64 and not target.referral_code: + if not await self._source_referral_code_conflicts( + referral_code, + int(target.user_id), + ): + target.referral_code = referral_code + self.summary["users"]["updated"] += 1 + else: + new_referral_code = None + if referral_code and len(referral_code) <= 64: + conflict = await self._source_referral_code_conflicts( + referral_code, + telegram_id, + ) + if not conflict: + new_referral_code = referral_code + + target, created = await user_dal.create_user( + self.target, + { + "user_id": telegram_id, + "telegram_id": telegram_id, + "username": row.get("username"), + "first_name": first_name, + "last_name": last_name, + "language_code": language, + "registration_date": created_at, + "is_banned": bool(row.get("is_blocked")), + "panel_user_uuid": panel_uuid, + "referral_code": new_referral_code, + "telegram_notifications_status": "blocked" + if bool(row.get("is_bot_blocked")) + else "unknown", + "telegram_notifications_checked_at": datetime.now(timezone.utc) + if bool(row.get("is_bot_blocked")) + else None, + "telegram_notifications_blocked_at": datetime.now(timezone.utc) + if bool(row.get("is_bot_blocked")) + else None, + }, + ) + self.summary["users"]["created" if created else "updated"] += 1 + + if not target: + self.summary["users"]["skipped"] += 1 + continue + + self.user_map[telegram_id] = int(target.user_id) + if referral_code: + await self._upsert_legacy_referral_code(code=referral_code, user_id=target.user_id) + + metadata = _legacy_user_metadata(row) + if panel_uuid: + metadata["panel_user_uuid"] = panel_uuid + await self._upsert_mapping( + entity_type="user", + source_id=telegram_id, + target_table="users", + target_id=target.user_id, + metadata=metadata, + ) + await self._record_user_state_note( + telegram_id=telegram_id, + user_id=int(target.user_id), + metadata=metadata, + ) + + await self.target.flush() + + async def import_referrals(self) -> None: + rows = await self._fetch_rows("referrals", order_by="id") + for row in rows: + referrer = await self._target_user_for_telegram(row.get("referrer_telegram_id")) + referred = await self._target_user_for_telegram(row.get("referred_telegram_id")) + if not referrer or not referred or referrer.user_id == referred.user_id: + self.summary["referrals"]["skipped"] += 1 + continue + if referred.referred_by_id and not self._can_overwrite(): + self.summary["referrals"]["skipped"] += 1 + continue + referred.referred_by_id = int(referrer.user_id) + self.summary["referrals"]["updated"] += 1 + await self._upsert_mapping( + entity_type="referral", + source_id=row.get("id") or f"{referrer.user_id}:{referred.user_id}", + target_table="users", + target_id=referred.user_id, + metadata={ + "referrer_user_id": referrer.user_id, + "referred_user_id": referred.user_id, + }, + ) + await self.target.flush() + + async def import_subscriptions(self) -> None: + rows = await self._fetch_rows("subscriptions", order_by="id") + now = datetime.now(timezone.utc) + for row in rows: + user = await self._target_user_for_telegram(row.get("user_telegram_id")) + if not user: + self.summary["subscriptions"]["skipped"] += 1 + continue + + panel_user_uuid = str(row.get("user_remna_id") or user.panel_user_uuid or "").strip() + if not panel_user_uuid: + self.summary["subscriptions"]["skipped"] += 1 + continue + if not user.panel_user_uuid or self._can_overwrite(): + user.panel_user_uuid = panel_user_uuid + + source_id = row.get("id") + mapping = await self._get_mapping("subscription", source_id) + existing: Optional[Subscription] = None + if mapping and str(mapping.target_id).isdigit(): + existing = await self.target.get(Subscription, int(mapping.target_id)) + + panel_sub_uuid = _extract_panel_subscription_uuid(row.get("url"), panel_user_uuid) + if not existing and panel_sub_uuid: + existing = ( + await self.target.execute( + select(Subscription).where( + Subscription.panel_subscription_uuid == panel_sub_uuid + ) + ) + ).scalar_one_or_none() + + status = str(row.get("status") or "UNKNOWN").strip().upper() + expire_at = _as_utc(row.get("expire_at")) or now + created_at = _as_utc(row.get("created_at")) or now + plan_snapshot = _jsonish(row.get("plan_snapshot")) + traffic_limit_bytes = remnashop_traffic_gb_to_bytes(row.get("traffic_limit")) + payload = { + "user_id": int(user.user_id), + "panel_user_uuid": panel_user_uuid, + "panel_subscription_uuid": panel_sub_uuid, + "start_date": created_at, + "end_date": expire_at, + "duration_months": remnashop_months_from_plan_snapshot( + plan_snapshot, + created_at=created_at, + expire_at=expire_at, + ), + "is_active": status in {"ACTIVE", "LIMITED"} and expire_at > now, + "status_from_panel": status, + "traffic_limit_bytes": traffic_limit_bytes, + "provider": "trial" if bool(row.get("is_trial")) else SOURCE, + "skip_notifications": True, + "auto_renew_enabled": False, + "tariff_key": remnashop_tariff_key(plan_snapshot, self.tariff_map), + "tier_baseline_bytes": traffic_limit_bytes, + "period_start_at": created_at, + "hwid_device_limit": _to_int(row.get("device_limit")), + } + metadata = { + "source": SOURCE, + "source_subscription_id": source_id, + "traffic_limit_strategy": str(row.get("traffic_limit_strategy") or ""), + "tag": row.get("tag"), + "internal_squads": [str(item) for item in _listish(row.get("internal_squads"))], + "external_squad": str(row.get("external_squad") or "") or None, + "url": row.get("url"), + "plan_snapshot": plan_snapshot, + } + + if existing: + if self.on_conflict == "skip": + self.summary["subscriptions"]["skipped"] += 1 + else: + for key, value in payload.items(): + self._assign_if_allowed(existing, key, value) + self.summary["subscriptions"]["updated"] += 1 + target_subscription_id = existing.subscription_id + else: + subscription = Subscription(**payload) + self.target.add(subscription) + await self.target.flush() + target_subscription_id = subscription.subscription_id + self.summary["subscriptions"]["created"] += 1 + + await self._upsert_mapping( + entity_type="subscription", + source_id=source_id, + target_table="subscriptions", + target_id=target_subscription_id, + metadata=metadata, + ) + + await self.target.flush() + + async def import_payments(self) -> None: + rows = await self._fetch_rows("transactions", order_by="id") + for row in rows: + user = await self._target_user_for_telegram(row.get("user_telegram_id")) + if not user: + self.summary["payments"]["skipped"] += 1 + continue + + provider_payment_id = f"{SOURCE}:{row.get('payment_id') or row.get('id')}" + existing = ( + await self.target.execute( + select(Payment).where(Payment.provider_payment_id == provider_payment_id) + ) + ).scalar_one_or_none() + + provider = _provider_value(row.get("gateway_type")) + plan_snapshot = _jsonish(row.get("plan_snapshot")) + created_at = _as_utc(row.get("created_at")) + payload = { + "user_id": int(user.user_id), + "provider_payment_id": provider_payment_id, + "provider": provider, + "amount": remnashop_pricing_amount(row.get("pricing")), + "currency": remnashop_pricing_currency(row.get("pricing"), row.get("currency")), + "status": remnashop_transaction_status(row.get("status"), provider), + "description": self._payment_description(row), + "subscription_duration_months": remnashop_months_from_plan_snapshot( + plan_snapshot, + created_at=row.get("created_at"), + expire_at=None, + ), + "sale_mode": remnashop_sale_mode(row.get("purchase_type")), + "tariff_key": remnashop_tariff_key(plan_snapshot, self.tariff_map), + "created_at": created_at, + } + payload = {key: value for key, value in payload.items() if value is not None} + + if existing: + if self.on_conflict == "skip": + self.summary["payments"]["skipped"] += 1 + else: + for key, value in payload.items(): + self._assign_if_allowed(existing, key, value) + self.summary["payments"]["updated"] += 1 + target_payment_id = existing.payment_id + else: + payment = Payment(**payload) + self.target.add(payment) + await self.target.flush() + target_payment_id = payment.payment_id + self.summary["payments"]["created"] += 1 + + await self._upsert_mapping( + entity_type="payment", + source_id=row.get("payment_id") or row.get("id"), + target_table="payments", + target_id=target_payment_id, + metadata={ + "source_transaction_id": row.get("id"), + "is_test": row.get("is_test"), + "purchase_type": str(row.get("purchase_type") or ""), + "gateway_type": str(row.get("gateway_type") or ""), + "plan_snapshot": plan_snapshot, + }, + ) + + await self.target.flush() + + def _payment_description(self, row: dict[str, Any]) -> str: + snapshot = _jsonish(row.get("plan_snapshot")) + plan_name = str(snapshot.get("name") or snapshot.get("tag") or "").strip() + purchase_type = str(row.get("purchase_type") or "").strip().upper() + if plan_name: + return f"Remnashop import: {purchase_type} {plan_name}".strip() + return f"Remnashop import: {purchase_type}".strip() + + async def import_promocodes(self) -> None: + if "promocodes" not in self.tables: + self.summary["promocodes"]["missing_source_table"] += 1 + return + + activation_rows_by_code = await self._source_promocode_activation_rows() + rows = await self._fetch_rows("promocodes", order_by="id") + for row in rows: + code = str(row.get("code") or "").strip() + if not code: + self.summary["promocodes"]["skipped"] += 1 + continue + + bonus_days = self._promo_bonus_days(row) + if bonus_days is None or bonus_days <= 0: + self.summary["promocodes"]["unsupported_reward"] += 1 + continue + + existing = ( + await self.target.execute(select(PromoCode).where(PromoCode.code == code)) + ).scalar_one_or_none() + activations = activation_rows_by_code.get(code, []) + valid_until = None + lifetime_days = _to_int(row.get("lifetime")) + if lifetime_days and _as_utc(row.get("created_at")): + valid_until = _as_utc(row.get("created_at")) + if valid_until: + valid_until = valid_until + timedelta(days=lifetime_days) + + payload = { + "code": code, + "bonus_days": int(bonus_days), + "max_activations": _to_int(row.get("max_activations")) or 1_000_000, + "current_activations": len(activations), + "is_active": bool(row.get("is_active")), + "created_by_admin_id": self.created_by_admin_id, + "created_at": _as_utc(row.get("created_at")), + "valid_until": valid_until, + } + payload = {key: value for key, value in payload.items() if value is not None} + + if existing: + if self.on_conflict == "skip": + self.summary["promocodes"]["skipped"] += 1 + else: + for key, value in payload.items(): + self._assign_if_allowed(existing, key, value) + self.summary["promocodes"]["updated"] += 1 + promo = existing + else: + promo = PromoCode(**payload) + self.target.add(promo) + await self.target.flush() + self.summary["promocodes"]["created"] += 1 + + await self._upsert_mapping( + entity_type="promocode", + source_id=row.get("id") or code, + target_table="promo_codes", + target_id=promo.promo_code_id, + metadata={ + "reward_type": str(row.get("reward_type") or ""), + "reward": row.get("reward"), + "plan": _jsonish(row.get("plan")), + "lifetime": row.get("lifetime"), + }, + ) + await self._import_promocode_activations(promo, activations) + + await self.target.flush() + + async def _source_promocode_activation_rows(self) -> dict[str, list[dict[str, Any]]]: + if "promocode_activations" not in self.tables: + return {} + result = await self.source.execute( + text( + f""" + SELECT a.*, p.code + FROM {_qtable(self.source_schema, "promocode_activations")} a + JOIN {_qtable(self.source_schema, "promocodes")} p + ON p.id = a.promocode_id + ORDER BY a.id + """ + ) + ) + by_code: dict[str, list[dict[str, Any]]] = defaultdict(list) + for row in result.mappings().all(): + mapping = _as_mapping(row) + code = str(mapping.get("code") or "").strip() + if code: + by_code[code].append(mapping) + return by_code + + def _promo_bonus_days(self, row: dict[str, Any]) -> Optional[int]: + reward_type = str(row.get("reward_type") or "").strip().upper() + if reward_type == "DURATION": + return _to_int(row.get("reward")) + if reward_type == "SUBSCRIPTION": + plan = _jsonish(row.get("plan")) + return ( + _to_int(plan.get("duration_days")) + or _to_int(plan.get("days")) + or _to_int(row.get("reward")) + ) + return None + + async def _import_promocode_activations( + self, + promo: PromoCode, + activations: Iterable[dict[str, Any]], + ) -> None: + for activation in activations: + user = await self._target_user_for_telegram(activation.get("user_telegram_id")) + if not user: + self.summary["promocodes"]["activation_skipped"] += 1 + continue + stmt = ( + pg_insert(PromoCodeActivation) + .values( + promo_code_id=promo.promo_code_id, + user_id=user.user_id, + activated_at=_as_utc(activation.get("activated_at")) + or datetime.now(timezone.utc), + ) + .on_conflict_do_nothing( + index_elements=[ + PromoCodeActivation.promo_code_id, + PromoCodeActivation.user_id, + ] + ) + ) + await self.target.execute(stmt) + self.summary["promocodes"]["activation_imported"] += 1 + + async def import_settings(self) -> None: + source_settings = await self._fetch_one("settings") + plans = ( + await self._fetch_rows("plans", order_by="order_index") + if "plans" in self.tables + else [] + ) + notes = { + "default_currency": ( + source_settings.get("default_currency") if source_settings else None + ), + "settings": { + key: source_settings.get(key) + for key in ("access", "requirements", "notifications", "referral", "menu") + if source_settings and source_settings.get(key) is not None + }, + "plans_count": len(plans), + "plans": [ + { + "id": plan.get("id"), + "name": plan.get("name"), + "type": str(plan.get("type") or ""), + "traffic_limit": plan.get("traffic_limit"), + "device_limit": plan.get("device_limit"), + "tag": plan.get("tag"), + } + for plan in plans[:100] + ], + } + await self._upsert_mapping( + entity_type="settings", + source_id="singleton", + target_table="app_setting_overrides", + target_id="MIGRATION_REMNASHOP_NOTES", + metadata=notes, + ) + self.summary["settings"]["captured"] += 1 + + async def _write_admin_overrides(self) -> None: + now = datetime.now(timezone.utc).isoformat() + plain_summary = self._plain_summary() + await self._upsert_setting_override( + "MIGRATION_REMNASHOP_REFERRAL_CODE_COMPAT_ENABLED", + True, + ) + await self._upsert_setting_override( + "MIGRATION_REMNASHOP_PROMO_CODE_COMPAT_ENABLED", + "promocodes" in self.tables, + ) + await self._upsert_setting_override("MIGRATION_REMNASHOP_IMPORTED_AT", now) + await self._upsert_setting_override( + "MIGRATION_REMNASHOP_NOTES", + _json_dumps(plain_summary), + ) + self.summary["settings"]["admin_overrides_written"] += 1 + + +def parse_only(value: str) -> set[str]: + if not value: + return set() + return {item.strip().lower() for item in value.split(",") if item.strip()} + + +def parse_tariff_map(value: Optional[str]) -> dict[str, str]: + if not value: + return {} + path = Path(value) + raw = path.read_text(encoding="utf-8") if path.exists() else value + decoded = json.loads(raw) + if not isinstance(decoded, dict): + raise ValueError("--tariff-map-json must be a JSON object or a path to one") + return {str(key): str(mapped) for key, mapped in decoded.items()} + + +def build_arg_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description="Import legacy bot data into this shop.") + parser.add_argument("--source-type", choices=[SOURCE], default=SOURCE) + parser.add_argument("--source-dsn", required=True) + parser.add_argument("--source-schema", default="public") + parser.add_argument("--target-dsn") + parser.add_argument( + "--only", + default="all", + help=( + "Comma-separated sections: " + "all,users,referrals,subscriptions,payments,promocodes,settings" + ), + ) + parser.add_argument( + "--on-conflict", + choices=["merge", "skip", "overwrite"], + default="merge", + ) + parser.add_argument("--dry-run", action="store_true") + parser.add_argument("--created-by-admin-id", type=int, default=0) + parser.add_argument( + "--tariff-map-json", + help="JSON object or path mapping remnashop plan id/name/tag to local tariff_key.", + ) + parser.add_argument( + "--no-admin-compat-overrides", + action="store_true", + help="Do not enable migration compatibility toggles in admin settings.", + ) + return parser + + +async def _prepare_target_schema(engine: Any) -> None: + async with engine.begin() as connection: + await connection.run_sync(Base.metadata.create_all) + await connection.run_sync(run_database_migrations) + + +async def run_import(args: argparse.Namespace) -> dict[str, Any]: + settings = Settings() + source_engine = create_async_engine(normalize_async_postgres_dsn(args.source_dsn)) + target_engine = create_async_engine( + normalize_async_postgres_dsn(args.target_dsn or settings.DATABASE_URL) + ) + await _prepare_target_schema(target_engine) + + session_factory = async_sessionmaker( + bind=target_engine, + class_=AsyncSession, + expire_on_commit=False, + autocommit=False, + autoflush=False, + ) + + async with source_engine.connect() as source, session_factory() as target: + importer = RemnashopImporter( + source=source, + target=target, + source_schema=args.source_schema, + only=parse_only(args.only), + on_conflict=args.on_conflict, + dry_run=bool(args.dry_run), + created_by_admin_id=args.created_by_admin_id, + tariff_map=parse_tariff_map(args.tariff_map_json), + write_admin_compat_overrides=not args.no_admin_compat_overrides, + ) + summary = await importer.run() + if args.dry_run: + await target.rollback() + else: + await target.commit() + + await source_engine.dispose() + await target_engine.dispose() + return summary + + +def main() -> None: + logging.basicConfig(level=logging.INFO, format="%(levelname)s %(message)s") + args = build_arg_parser().parse_args() + summary = asyncio.run(run_import(args)) + print(_json_dumps(summary)) + + +if __name__ == "__main__": + main() diff --git a/frontend/src/admin/sections/SettingsSection.svelte b/frontend/src/admin/sections/SettingsSection.svelte index 3443016..8d151fb 100644 --- a/frontend/src/admin/sections/SettingsSection.svelte +++ b/frontend/src/admin/sections/SettingsSection.svelte @@ -348,6 +348,7 @@ devices: "Устройства", subscription_guides: "Connection guides", system: "Система", + migrations: "Миграции", }; return adminText(`settings_section_${id}`, {}, map[id] || id); } diff --git a/locales/en.json b/locales/en.json index cf3ffff..c6ae75d 100644 --- a/locales/en.json +++ b/locales/en.json @@ -1108,11 +1108,13 @@ "admin_settings_section_devices": "Devices", "admin_settings_section_support": "Support", "admin_settings_section_system": "System", + "admin_settings_section_migrations": "Migrations", "admin_settings_field_telemetry_enabled_label": "Anonymous install analytics", "admin_settings_field_telemetry_enabled_description": "Sends one anonymous heartbeat per day (version, OS, locale, user-count range). No personal data, tokens or domains. Helps gauge how many installs are active and which versions are in use. Toggling this off takes effect without a restart.", "admin_settings_subsection_common": "Common", "admin_settings_subsection_checkout": "Checkout", "admin_settings_subsection_remnawave": "Remnawave", + "admin_settings_subsection_remnashop": "Remnashop", "admin_settings_subsection_telegram_stars": "Telegram Stars", "admin_settings_subsection_yookassa": "YooKassa", "admin_settings_subsection_freekassa": "FreeKassa", @@ -1127,6 +1129,14 @@ "admin_settings_provider_webhook_base_missing": "Set WEBHOOK_BASE_URL in .env to show the full URL for {path}.", "admin_settings_provider_admin_only_label": "Only for admins", "admin_settings_provider_admin_only_description": "Shows this provider only to admins. Webhooks and payment status handling remain active for test payments.", + "admin_settings_field_migration_remnashop_referral_code_compat_enabled_label": "Remnashop ref-code compatibility", + "admin_settings_field_migration_remnashop_referral_code_compat_enabled_description": "Allows old Remnashop referral codes to resolve without changing their case or format. The importer enables it automatically.", + "admin_settings_field_migration_remnashop_promo_code_compat_enabled_label": "Remnashop promo-code compatibility", + "admin_settings_field_migration_remnashop_promo_code_compat_enabled_description": "Looks up promo codes in the original case first, then falls back to the current rules. Regular promo codes keep working as before.", + "admin_settings_field_migration_remnashop_imported_at_label": "Remnashop import date", + "admin_settings_field_migration_remnashop_imported_at_description": "Operational marker for the latest Remnashop import, stored as an ISO timestamp.", + "admin_settings_field_migration_remnashop_notes_label": "Remnashop import notes", + "admin_settings_field_migration_remnashop_notes_description": "Short importer summary: migrated entities and enabled compatibility modes.", "admin_settings_validation_errors": "Errors: {errors}", "admin_settings_save_error": "Error: {error}", "admin_sync_started": "Synchronization started", diff --git a/locales/ru.json b/locales/ru.json index 4ce0660..e7d5047 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -1108,11 +1108,13 @@ "admin_settings_section_devices": "Устройства", "admin_settings_section_support": "Поддержка", "admin_settings_section_system": "Система", + "admin_settings_section_migrations": "Миграции", "admin_settings_field_telemetry_enabled_label": "Анонимная статистика установки", "admin_settings_field_telemetry_enabled_description": "Раз в сутки отправляет обезличенный сигнал: версия, ОС, локаль и число пользователей в виде диапазона. Без персональных данных, токенов и доменов. Помогает оценить число активных установок и используемые версии. Отключение применяется без перезапуска.", "admin_settings_subsection_common": "Общие", "admin_settings_subsection_checkout": "Оформление оплаты", "admin_settings_subsection_remnawave": "Remnawave", + "admin_settings_subsection_remnashop": "Remnashop", "admin_settings_subsection_telegram_stars": "Telegram Stars", "admin_settings_subsection_yookassa": "YooKassa", "admin_settings_subsection_freekassa": "FreeKassa", @@ -1127,6 +1129,14 @@ "admin_settings_provider_webhook_base_missing": "Укажите WEBHOOK_BASE_URL в .env, чтобы увидеть полный адрес для {path}.", "admin_settings_provider_admin_only_label": "Только для админов", "admin_settings_provider_admin_only_description": "Показывает провайдер только администраторам. Вебхуки и обработка статусов остаются активными для тестовых платежей.", + "admin_settings_field_migration_remnashop_referral_code_compat_enabled_label": "Совместимость ref-кодов Remnashop", + "admin_settings_field_migration_remnashop_referral_code_compat_enabled_description": "Разрешает вход по старым ref-кодам Remnashop без изменения их регистра и формата. Включается импортёром автоматически.", + "admin_settings_field_migration_remnashop_promo_code_compat_enabled_label": "Совместимость промокодов Remnashop", + "admin_settings_field_migration_remnashop_promo_code_compat_enabled_description": "Ищет промокоды сначала в исходном регистре, затем по текущим правилам. Обычные промокоды продолжают работать как раньше.", + "admin_settings_field_migration_remnashop_imported_at_label": "Дата импорта Remnashop", + "admin_settings_field_migration_remnashop_imported_at_description": "Служебная отметка последнего импорта Remnashop в ISO-формате.", + "admin_settings_field_migration_remnashop_notes_label": "Заметки импорта Remnashop", + "admin_settings_field_migration_remnashop_notes_description": "Краткая сводка импортёра: какие сущности перенесены и какие совместимые режимы включены.", "admin_settings_validation_errors": "Ошибки: {errors}", "admin_settings_save_error": "Ошибка: {error}", "admin_sync_started": "Синхронизация запущена", diff --git a/tests/test_admin_settings_manifest_i18n.py b/tests/test_admin_settings_manifest_i18n.py index 2144100..21ecbed 100644 --- a/tests/test_admin_settings_manifest_i18n.py +++ b/tests/test_admin_settings_manifest_i18n.py @@ -44,6 +44,13 @@ BACKUP_SETTINGS = ( "BACKUP_COMPOSE_ENABLED", ) +REMNASHOP_MIGRATION_SETTINGS = ( + "MIGRATION_REMNASHOP_REFERRAL_CODE_COMPAT_ENABLED", + "MIGRATION_REMNASHOP_PROMO_CODE_COMPAT_ENABLED", + "MIGRATION_REMNASHOP_IMPORTED_AT", + "MIGRATION_REMNASHOP_NOTES", +) + ADMIN_TARIFF_SETTINGS_PAGE_KEYS = { "admin_tariffs_trial_title", "admin_tariffs_trial_subtitle", @@ -204,6 +211,27 @@ def test_backup_settings_i18n_keys_exist(): assert field["i18n_description_key"] in messages +def test_remnashop_migration_settings_i18n_keys_exist(): + manifest = _manifest_by_key() + + for setting_key in REMNASHOP_MIGRATION_SETTINGS: + field = manifest[setting_key] + assert field["section"] == "migrations" + assert field["section_order"] == 13 + assert field["subsection"] == "Remnashop" + assert field["i18n_subsection_key"] == "admin_settings_subsection_remnashop" + + for language in ("ru", "en"): + messages = _locale(language) + + assert "admin_settings_section_migrations" in messages + assert "admin_settings_subsection_remnashop" in messages + for setting_key in REMNASHOP_MIGRATION_SETTINGS: + field = manifest[setting_key] + assert field["i18n_label_key"] in messages + assert field["i18n_description_key"] in messages + + def test_backup_required_numeric_settings_reject_empty_values(): with pytest.raises(ValueError): coerce_value(get_field_by_key("BACKUP_INTERVAL_SECONDS"), "") diff --git a/tests/test_remnashop_import.py b/tests/test_remnashop_import.py new file mode 100644 index 0000000..fb4a16b --- /dev/null +++ b/tests/test_remnashop_import.py @@ -0,0 +1,44 @@ +from datetime import datetime, timezone + +from scripts.import_legacy import ( + remnashop_months_from_plan_snapshot, + remnashop_pricing_amount, + remnashop_pricing_currency, + remnashop_sale_mode, + remnashop_traffic_gb_to_bytes, + remnashop_transaction_status, +) + + +def test_remnashop_pricing_helpers_read_final_amount_and_currency(): + pricing = {"final_amount": "199.50", "currency": "rub"} + + assert remnashop_pricing_amount(pricing) == 199.5 + assert remnashop_pricing_currency(pricing) == "RUB" + + +def test_remnashop_traffic_limit_is_converted_from_gib(): + assert remnashop_traffic_gb_to_bytes(10) == 10 * 1024**3 + assert remnashop_traffic_gb_to_bytes(None) is None + + +def test_remnashop_status_and_sale_mode_mapping_matches_current_payment_model(): + assert remnashop_transaction_status("COMPLETED", "YOOKASSA") == "succeeded" + assert remnashop_transaction_status("PENDING", "WATA") == "pending_wata" + assert remnashop_transaction_status("CANCELED", "WATA") == "canceled" + assert remnashop_sale_mode("NEW") == "subscription" + assert remnashop_sale_mode("RENEW") == "subscription" + assert remnashop_sale_mode("CHANGE") == "tariff_upgrade" + + +def test_remnashop_plan_months_prefers_snapshot_then_dates(): + assert remnashop_months_from_plan_snapshot({"duration_days": 90}) == 3 + assert remnashop_months_from_plan_snapshot({"months": 12}) == 12 + assert ( + remnashop_months_from_plan_snapshot( + {}, + created_at=datetime(2026, 1, 1, tzinfo=timezone.utc), + expire_at=datetime(2026, 4, 1, tzinfo=timezone.utc), + ) + == 3 + ) diff --git a/tests/test_support_migration.py b/tests/test_support_migration.py index 5fa5292..2e5fed5 100644 --- a/tests/test_support_migration.py +++ b/tests/test_support_migration.py @@ -1,5 +1,11 @@ from db.migrator import MIGRATIONS -from db.models import SupportTicket, SupportTicketMessage, User +from db.models import ( + LegacyImportMapping, + LegacyReferralCode, + SupportTicket, + SupportTicketMessage, + User, +) def test_support_migration_is_registered_after_existing_revisions(): @@ -39,3 +45,18 @@ def test_trial_eligibility_reset_migration_and_model_are_registered(): "0032_add_telegram_notification_status" ) assert "trial_eligibility_reset_at" in User.__table__.columns + + +def test_legacy_import_compatibility_migration_and_models_are_registered(): + ids = [migration.id for migration in MIGRATIONS] + + assert "0034_add_legacy_import_compatibility" in ids + assert ids.index("0034_add_legacy_import_compatibility") > ids.index( + "0033_add_trial_eligibility_reset_marker" + ) + assert User.__table__.columns["referral_code"].type.length == 64 + assert LegacyReferralCode.__tablename__ == "legacy_referral_codes" + assert LegacyImportMapping.__tablename__ == "legacy_import_mappings" + assert "uq_legacy_referral_source_code" in { + constraint.name for constraint in LegacyReferralCode.__table__.constraints + } diff --git a/tests/test_user_dal.py b/tests/test_user_dal.py index 5b8bd30..8849ba9 100644 --- a/tests/test_user_dal.py +++ b/tests/test_user_dal.py @@ -230,6 +230,8 @@ class UserDalMergeTests(unittest.IsolatedAsyncioTestCase): ) self.assertIn("support_ticket_messages", update_tables) self.assertIn("email_verification_codes", delete_tables) + self.assertIn("legacy_referral_codes", delete_tables) + self.assertIn("legacy_import_mappings", delete_tables) session.delete.assert_awaited_once_with(user) session.flush.assert_awaited_once() @@ -366,6 +368,8 @@ class UserDalMergeTests(unittest.IsolatedAsyncioTestCase): self.assertIn("payments", update_tables) self.assertIn("promo_code_activations", update_tables) self.assertIn("user_payment_methods", update_tables) + self.assertIn("legacy_referral_codes", update_tables) + self.assertIn("legacy_import_mappings", update_tables) self.assertIn("message_logs", update_tables) self.assertIn("users", update_tables) self.assertIn("user_payment_methods", delete_tables) From d22ed238b4f8ad9b89f6defd486df9195b8ddf52 Mon Sep 17 00:00:00 2001 From: 3252a8 <3252a8@proton.me> Date: Tue, 2 Jun 2026 10:08:59 +0300 Subject: [PATCH 05/58] feat: add shell install wizard --- docs/getting-started/deployment.md | 31 + docs/migrations/index.md | 1 + docs/migrations/remnashop.md | 60 ++ scripts/install.sh | 902 +++++++++++++++++++++++++++++ tests/test_install_script.py | 39 ++ 5 files changed, 1033 insertions(+) create mode 100644 docs/migrations/remnashop.md create mode 100644 scripts/install.sh create mode 100644 tests/test_install_script.py diff --git a/docs/getting-started/deployment.md b/docs/getting-started/deployment.md index cee2da1..ae3f09d 100644 --- a/docs/getting-started/deployment.md +++ b/docs/getting-started/deployment.md @@ -13,6 +13,37 @@ docker compose ps docker compose logs -f backend worker frontend ``` +## Интерактивный install wizard + +Для нового сервера можно не клонировать весь репозиторий и не ставить Python. +Скачайте только POSIX `sh`-скрипт через `raw.githubusercontent.com` и +запустите его: + +```bash +curl -fsSL https://raw.githubusercontent.com/3252a8/remnawave-minishop/main/scripts/install.sh -o install.sh +sh install.sh +``` + +Wizard работает через меню с цифрами и подтверждениями `y/n`. Он умеет: + +- скачать выбранный compose-профиль (`Caddy`, `Nginx`, `Pangolin/Newt` или `no-proxy`); +- сгенерировать минимальный `.env`, включая пароли и стабильные secrets; +- сохранить backup существующих файлов перед перезаписью; +- запустить `docker compose pull && docker compose up -d`; +- проверить текущий стек через `docker compose ps` и логи `migrate`; +- запустить миграцию из поддерживаемых legacy-ботов, сейчас - из Remnashop. + +Для тестирования другой ветки или форка задайте источник перед запуском: + +```bash +MINISHOP_INSTALL_REPO=3252a8/remnawave-minishop \ +MINISHOP_INSTALL_REF=main \ +sh install.sh +``` + +Миграция в wizard сначала запускает `dry-run`, показывает JSON-сводку и только +после отдельного подтверждения применяет изменения в целевую БД. + Обычный `docker compose up -d --build` поднимает: - `postgres` и `redis` с проверками здоровья; diff --git a/docs/migrations/index.md b/docs/migrations/index.md index 501a37a..0c20758 100644 --- a/docs/migrations/index.md +++ b/docs/migrations/index.md @@ -5,3 +5,4 @@ | Источник | Поддерживаемый случай | Документы | | --- | --- | --- | | [remnawave-tg-shop](https://github.com/kavore/remnawave-tg-shop/) | Полный перенос всех данных | [Инструкция](remnawave-tg-shop.md) | +| [Remnashop](https://github.com/snoups/remnashop/) | Автоматический импорт пользователей, подписок, платежей, рефералов и промокодов | [Инструкция](remnashop.md) | diff --git a/docs/migrations/remnashop.md b/docs/migrations/remnashop.md new file mode 100644 index 0000000..2387bb9 --- /dev/null +++ b/docs/migrations/remnashop.md @@ -0,0 +1,60 @@ +# Миграция из Remnashop + +Remnashop импортируется через общий legacy-importer `backend/scripts/import_legacy.py`. +Самый удобный путь - интерактивный install wizard: + +```bash +curl -fsSL https://raw.githubusercontent.com/3252a8/remnawave-minishop/main/scripts/install.sh -o install.sh +sh install.sh +``` + +В меню выберите `Install new stack and run legacy migration` для нового сервера +или `Run legacy migration only`, если compose-папка и `.env` уже готовы. + +## Что переносится + +- пользователи Telegram, username, email, Remnawave UUID и метаданные профиля; +- старые referral codes и связи рефералов; +- подписки, сроки, лимиты трафика, HWID/device limit и UUID подписок панели; +- платежи и статусы платежей; +- промокоды на дни подписки и их активации, если таблицы есть в source DB; +- служебные mappings, чтобы повторный запуск мог работать в режиме `merge`; +- настройки совместимости Remnashop в админке: старые ref-ссылки и promo codes. + +Данные, которые не имеют прямого аналога, сохраняются в legacy mappings или +message logs как заметки, чтобы администратор мог проверить их после переноса. + +## Flow wizard + +1. Wizard скачивает compose-профиль и `backend/scripts/import_legacy.py` через + `raw.githubusercontent.com`, без клонирования репозитория. +2. Вы указываете source PostgreSQL DSN Remnashop и schema, обычно `public`. +3. Вы выбираете целевую БД: текущую compose-БД или ручной target DSN. +4. При необходимости указываете JSON map тарифов Remnashop в локальные + `tariff_key`, например `{"basic": "standard_month"}`. +5. Wizard запускает `dry-run` и показывает JSON-сводку. +6. После подтверждения `y` importer применяет изменения и перезапускает + `backend`/`worker`, чтобы настройки совместимости перечитались. + +Если source DB находится на том же Docker host, помните, что DSN выполняется +из backend-контейнера. Для подключения к сервису вне compose-сети может +понадобиться host name вроде `host.docker.internal`, внешний адрес сервера или +ручное подключение контейнеров к общей Docker network. + +## Ручной запуск + +Если нужно запустить importer без wizard: + +```bash +docker compose run --rm backend \ + python backend/scripts/import_legacy.py \ + --source-type remnashop \ + --source-dsn 'postgresql://old_user:old_password@old_host:5432/remnashop' \ + --source-schema public \ + --dry-run +``` + +После успешного `dry-run` повторите команду без `--dry-run`. По умолчанию +режим конфликтов `merge`: существующие пользователи и платежи сопоставляются, +а новые записи добавляются. Для узкого импорта используйте `--only`, например +`--only users,referrals,promocodes`. diff --git a/scripts/install.sh b/scripts/install.sh new file mode 100644 index 0000000..d9d93dc --- /dev/null +++ b/scripts/install.sh @@ -0,0 +1,902 @@ +#!/bin/sh +set -u + +# Dependency-light installer for fresh Linux hosts. +# It intentionally avoids Python and clones only the files selected by the user. + +DEFAULT_REPO="${MINISHOP_INSTALL_REPO:-3252a8/remnawave-minishop}" +DEFAULT_REF="${MINISHOP_INSTALL_REF:-main}" +DEFAULT_IMAGE_TAG="${MINISHOP_IMAGE_TAG:-latest}" +INSTALL_STATE_DIR=".installer" +IMPORTER_CACHE_PATH="$INSTALL_STATE_DIR/import_legacy.py" +APP_UID=10001 +APP_GID=10001 + +if [ -t 1 ] && [ -z "${NO_COLOR:-}" ]; then + RESET="$(printf '\033[0m')" + BOLD="$(printf '\033[1m')" + DIM="$(printf '\033[2m')" + RED="$(printf '\033[31m')" + GREEN="$(printf '\033[32m')" + YELLOW="$(printf '\033[33m')" + BLUE="$(printf '\033[34m')" + CYAN="$(printf '\033[36m')" +else + RESET="" + BOLD="" + DIM="" + RED="" + GREEN="" + YELLOW="" + BLUE="" + CYAN="" +fi + +TARGET_DIR="" +SOURCE_REPO="" +SOURCE_REF="" +PROFILE_KEY="" +ENV_PATH="" +COMPOSE_STYLE="" +PROMPT_VALUE="" +CHOICE_VALUE="" + +COMPOSE_PROJECT_NAME_VALUE="" +IMAGE_TAG_VALUE="" +WEBHOOK_HOST_VALUE="" +MINIAPP_HOST_VALUE="" +WEBHOOK_PUBLIC_URL_VALUE="" +MINIAPP_PUBLIC_URL_VALUE="" +HTTP_BIND_VALUE="" +HTTPS_BIND_VALUE="" +WEB_SERVER_BIND_VALUE="" +FRONTEND_BIND_VALUE="" +PANGOLIN_ENDPOINT_VALUE="" +NEWT_ID_VALUE="" +NEWT_SECRET_VALUE="" +BOT_TOKEN_VALUE="" +ADMIN_IDS_VALUE="" +POSTGRES_USER_VALUE="" +POSTGRES_PASSWORD_VALUE="" +POSTGRES_DB_VALUE="" +WEBAPP_ENABLED_VALUE="" +WEBAPP_SESSION_SECRET_VALUE="" +WEBHOOK_SECRET_TOKEN_VALUE="" +TRUSTED_PROXIES_VALUE="" +PANEL_API_URL_VALUE="" +PANEL_API_KEY_VALUE="" +PANEL_WEBHOOK_SECRET_VALUE="" + +KNOWN_ENV_KEYS="COMPOSE_PROJECT_NAME IMAGE_TAG WEBHOOK_HOST MINIAPP_HOST WEBHOOK_PUBLIC_URL MINIAPP_PUBLIC_URL HTTP_BIND HTTPS_BIND WEB_SERVER_BIND FRONTEND_BIND PANGOLIN_ENDPOINT NEWT_ID NEWT_SECRET BOT_TOKEN ADMIN_IDS POSTGRES_USER POSTGRES_PASSWORD POSTGRES_DB WEBAPP_ENABLED WEBAPP_SESSION_SECRET WEBHOOK_SECRET_TOKEN TRUSTED_PROXIES PANEL_API_URL PANEL_API_KEY PANEL_WEBHOOK_SECRET" + +color() { + printf '%s%s%s' "$2" "$1" "$RESET" +} + +banner() { + printf '\n' + color "Remnawave MiniShop Install Wizard" "$BOLD$CYAN" + printf '\n' + color "Install, configure, start, and migrate legacy bot data." "$DIM" + printf '\n\n' +} + +section() { + printf '\n' + color "== $1 ==" "$BOLD$BLUE" + printf '\n' +} + +info() { + color "* " "$CYAN" + printf '%s\n' "$1" +} + +warn() { + color "! " "$YELLOW" + printf '%s\n' "$1" +} + +ok() { + color "[ok] " "$GREEN" + printf '%s\n' "$1" +} + +fail() { + color "[x] " "$RED" + printf '%s\n' "$1" >&2 +} + +pause() { + printf '%s' "${DIM}Press Enter to continue...${RESET}" + # shellcheck disable=SC2034 + read -r _ +} + +print_help() { + cat </dev/null | tail -n 1 || true) + if [ -n "$line" ]; then + strip_quotes "${line#*=}" + return 0 + fi + fi + printf '%s' "$default_value" +} + +known_env_key() { + case " $KNOWN_ENV_KEYS " in + *" $1 "*) + return 0 + ;; + *) + return 1 + ;; + esac +} + +secret_hex() { + bytes="${1:-32}" + if command -v openssl >/dev/null 2>&1; then + openssl rand -hex "$bytes" + return 0 + fi + if [ -r /dev/urandom ] && command -v od >/dev/null 2>&1; then + dd if=/dev/urandom bs="$bytes" count=1 2>/dev/null | od -An -tx1 | tr -d ' \n' + return 0 + fi + fail "Could not generate a secure secret. Install openssl and retry." + exit 1 +} + +generated_password() { + secret_hex 24 +} + +raw_url() { + repo=$(printf '%s' "$1" | sed 's#^/*##; s#/*$##') + ref=$(printf '%s' "$2" | sed 's#^/*##; s#/*$##') + path=$(printf '%s' "$3" | sed 's#^/*##') + printf 'https://raw.githubusercontent.com/%s/%s/%s' "$repo" "$ref" "$path" +} + +download_to() { + url="$1" + target="$2" + if command -v curl >/dev/null 2>&1; then + curl -fsSL "$url" -o "$target" + return $? + fi + if command -v wget >/dev/null 2>&1; then + wget -qO "$target" "$url" + return $? + fi + fail "curl or wget is required to download files." + exit 1 +} + +backup_path() { + path="$1" + stamp=$(date -u '+%Y%m%d-%H%M%S') + printf '%s.bak-%s' "$path" "$stamp" +} + +write_downloaded_file() { + source_path="$1" + target_path="$2" + mkdir -p "$(dirname "$target_path")" + if [ -e "$target_path" ]; then + if confirm "$target_path exists. Overwrite with backup?" 0; then + backup=$(backup_path "$target_path") + cp "$target_path" "$backup" + info "Backed up $target_path to $(basename "$backup")" + else + warn "Keeping existing $target_path" + rm -f "$source_path" + return 0 + fi + fi + mv "$source_path" "$target_path" + ok "Wrote $target_path" +} + +download_raw_file() { + source="$1" + target="$2" + required="${3:-1}" + url=$(raw_url "$SOURCE_REPO" "$SOURCE_REF" "$source") + tmp="$TARGET_DIR/.download.$$.$(basename "$target")" + if download_to "$url" "$tmp"; then + write_downloaded_file "$tmp" "$TARGET_DIR/$target" + return 0 + fi + rm -f "$tmp" + if [ "$required" = "1" ]; then + fail "Could not download $url" + return 1 + fi + warn "Skipping optional file $source" + return 0 +} + +choose_profile() { + choose "Deployment profile" "1" "1|2|3|4" \ + "1. Caddy HTTPS - recommended public HTTPS with automatic certificates." \ + "2. Nginx HTTPS - TLS certificates are managed manually." \ + "3. Pangolin / Newt - no inbound ports; public routes are configured in Pangolin." \ + "4. No proxy / external TLS - direct HTTP ports or an external TLS terminator." + case "$CHOICE_VALUE" in + 1) PROFILE_KEY="caddy" ;; + 2) PROFILE_KEY="nginx" ;; + 3) PROFILE_KEY="newt" ;; + 4) PROFILE_KEY="no-proxy" ;; + esac +} + +download_profile_files() { + section "Download deployment files" + case "$PROFILE_KEY" in + caddy) + download_raw_file "deploy/examples/caddy/docker-compose.yml" "docker-compose.yml" 1 || return 1 + download_raw_file "deploy/examples/caddy/Caddyfile" "Caddyfile" 1 || return 1 + download_raw_file "deploy/examples/caddy/.env.example" ".env.example" 1 || return 1 + ;; + nginx) + download_raw_file "deploy/examples/nginx/docker-compose.yml" "docker-compose.yml" 1 || return 1 + download_raw_file "deploy/examples/nginx/nginx.conf.template" "nginx.conf.template" 1 || return 1 + download_raw_file "deploy/examples/nginx/.env.example" ".env.example" 1 || return 1 + download_raw_file "deploy/examples/nginx/ssl/README.md" "ssl/README.md" 1 || return 1 + ;; + newt) + download_raw_file "deploy/examples/newt/docker-compose.yml" "docker-compose.yml" 1 || return 1 + download_raw_file "deploy/examples/newt/.env.example" ".env.example" 1 || return 1 + ;; + no-proxy) + download_raw_file "deploy/examples/no-proxy/docker-compose.yml" "docker-compose.yml" 1 || return 1 + download_raw_file "deploy/examples/no-proxy/.env.example" ".env.example" 1 || return 1 + ;; + esac +} + +prompt_common_env() { + section "Minimal .env" + prompt_value "Compose project name" "$(env_get COMPOSE_PROJECT_NAME remnawave-minishop)" 0 0 "" + COMPOSE_PROJECT_NAME_VALUE="$PROMPT_VALUE" + prompt_value "Image tag" "$(env_get IMAGE_TAG "$DEFAULT_IMAGE_TAG")" 0 0 "" + IMAGE_TAG_VALUE="$PROMPT_VALUE" + prompt_value "Telegram bot token" "$(env_get BOT_TOKEN '')" 1 1 "" + BOT_TOKEN_VALUE="$PROMPT_VALUE" + prompt_value "Admin Telegram IDs, comma-separated" "$(env_get ADMIN_IDS '')" 1 0 "" + ADMIN_IDS_VALUE="$PROMPT_VALUE" + prompt_value "Postgres user" "$(env_get POSTGRES_USER remnawave_minishop)" 1 0 "" + POSTGRES_USER_VALUE="$PROMPT_VALUE" + existing_postgres_password=$(env_get POSTGRES_PASSWORD "") + if [ -z "$existing_postgres_password" ]; then + existing_postgres_password=$(generated_password) + fi + prompt_value "Postgres password" "$existing_postgres_password" 1 1 "" + POSTGRES_PASSWORD_VALUE="$PROMPT_VALUE" + prompt_value "Postgres database" "$(env_get POSTGRES_DB remnawave_minishop)" 1 0 "" + POSTGRES_DB_VALUE="$PROMPT_VALUE" + + WEBAPP_ENABLED_VALUE="$(env_get WEBAPP_ENABLED True)" + WEBAPP_SESSION_SECRET_VALUE="$(env_get WEBAPP_SESSION_SECRET "")" + if [ -z "$WEBAPP_SESSION_SECRET_VALUE" ]; then + WEBAPP_SESSION_SECRET_VALUE="$(secret_hex 32)" + fi + WEBHOOK_SECRET_TOKEN_VALUE="$(env_get WEBHOOK_SECRET_TOKEN "")" + if [ -z "$WEBHOOK_SECRET_TOKEN_VALUE" ]; then + WEBHOOK_SECRET_TOKEN_VALUE="$(secret_hex 32)" + fi + + prompt_value "Remnawave Panel API URL" "$(env_get PANEL_API_URL https://panel.example.com/api)" 0 0 "url" + PANEL_API_URL_VALUE="$PROMPT_VALUE" + prompt_value "Remnawave Panel API key" "$(env_get PANEL_API_KEY change_me)" 0 1 "" + PANEL_API_KEY_VALUE="$PROMPT_VALUE" + existing_panel_webhook_secret=$(env_get PANEL_WEBHOOK_SECRET "") + if [ -z "$existing_panel_webhook_secret" ]; then + existing_panel_webhook_secret=$(secret_hex 24) + fi + prompt_value "Remnawave Panel webhook secret" "$existing_panel_webhook_secret" 0 1 "" + PANEL_WEBHOOK_SECRET_VALUE="$PROMPT_VALUE" + + case "$PROFILE_KEY" in + caddy|nginx|newt) + prompt_value "Webhook/API public hostname" "$(env_get WEBHOOK_HOST webhooks.example.com)" 1 0 "hostname" + WEBHOOK_HOST_VALUE="$PROMPT_VALUE" + prompt_value "Mini App public hostname" "$(env_get MINIAPP_HOST app.example.com)" 1 0 "hostname" + MINIAPP_HOST_VALUE="$PROMPT_VALUE" + TRUSTED_PROXIES_VALUE="$(env_get TRUSTED_PROXIES '127.0.0.1,::1,172.16.0.0/12')" + ;; + esac + + case "$PROFILE_KEY" in + caddy|nginx) + prompt_value "HTTP bind" "$(env_get HTTP_BIND '0.0.0.0:80')" 0 0 "" + HTTP_BIND_VALUE="$PROMPT_VALUE" + prompt_value "HTTPS bind" "$(env_get HTTPS_BIND '0.0.0.0:443')" 0 0 "" + HTTPS_BIND_VALUE="$PROMPT_VALUE" + ;; + newt) + prompt_value "Pangolin endpoint" "$(env_get PANGOLIN_ENDPOINT https://pangolin.example.com)" 1 0 "url" + PANGOLIN_ENDPOINT_VALUE="$PROMPT_VALUE" + prompt_value "Newt ID" "$(env_get NEWT_ID '')" 1 0 "" + NEWT_ID_VALUE="$PROMPT_VALUE" + prompt_value "Newt secret" "$(env_get NEWT_SECRET '')" 1 1 "" + NEWT_SECRET_VALUE="$PROMPT_VALUE" + ;; + no-proxy) + prompt_value "Backend bind" "$(env_get WEB_SERVER_BIND '0.0.0.0:8080')" 0 0 "" + WEB_SERVER_BIND_VALUE="$PROMPT_VALUE" + prompt_value "Frontend bind" "$(env_get FRONTEND_BIND '0.0.0.0:8082')" 0 0 "" + FRONTEND_BIND_VALUE="$PROMPT_VALUE" + prompt_value "Webhook public URL" "$(env_get WEBHOOK_PUBLIC_URL 'http://127.0.0.1:8080')" 1 0 "url" + WEBHOOK_PUBLIC_URL_VALUE="$PROMPT_VALUE" + prompt_value "Mini App public URL" "$(env_get MINIAPP_PUBLIC_URL 'http://127.0.0.1:8082/')" 1 0 "url" + MINIAPP_PUBLIC_URL_VALUE="$PROMPT_VALUE" + TRUSTED_PROXIES_VALUE="$(env_get TRUSTED_PROXIES '127.0.0.1,::1')" + ;; + esac +} + +env_line() { + key="$1" + value="$2" + file="$3" + if [ -n "$value" ]; then + printf '%s=%s\n' "$key" "$value" >> "$file" + fi +} + +show_env_value() { + key="$1" + value="$2" + if [ -z "$value" ]; then + return 0 + fi + if is_secret_key "$key"; then + value=$(mask_secret "$value") + fi + printf ' %s=%s\n' "$key" "$value" +} + +display_env_summary() { + show_env_value COMPOSE_PROJECT_NAME "$COMPOSE_PROJECT_NAME_VALUE" + show_env_value IMAGE_TAG "$IMAGE_TAG_VALUE" + show_env_value WEBHOOK_HOST "$WEBHOOK_HOST_VALUE" + show_env_value MINIAPP_HOST "$MINIAPP_HOST_VALUE" + show_env_value WEBHOOK_PUBLIC_URL "$WEBHOOK_PUBLIC_URL_VALUE" + show_env_value MINIAPP_PUBLIC_URL "$MINIAPP_PUBLIC_URL_VALUE" + show_env_value BOT_TOKEN "$BOT_TOKEN_VALUE" + show_env_value ADMIN_IDS "$ADMIN_IDS_VALUE" + show_env_value POSTGRES_USER "$POSTGRES_USER_VALUE" + show_env_value POSTGRES_PASSWORD "$POSTGRES_PASSWORD_VALUE" + show_env_value POSTGRES_DB "$POSTGRES_DB_VALUE" + show_env_value WEBAPP_SESSION_SECRET "$WEBAPP_SESSION_SECRET_VALUE" + show_env_value WEBHOOK_SECRET_TOKEN "$WEBHOOK_SECRET_TOKEN_VALUE" + show_env_value PANEL_API_URL "$PANEL_API_URL_VALUE" + show_env_value PANEL_API_KEY "$PANEL_API_KEY_VALUE" + show_env_value PANEL_WEBHOOK_SECRET "$PANEL_WEBHOOK_SECRET_VALUE" +} + +append_preserved_env() { + output="$1" + [ -f "$ENV_PATH" ] || return 0 + wrote_header=0 + while IFS= read -r line || [ -n "$line" ]; do + case "$line" in + ""|\#*) + continue + ;; + *=*) + key=${line%%=*} + if known_env_key "$key"; then + continue + fi + if [ "$wrote_header" = "0" ]; then + printf '\n# Preserved from previous .env\n' >> "$output" + wrote_header=1 + fi + printf '%s\n' "$line" >> "$output" + ;; + esac + done < "$ENV_PATH" +} + +render_env_file() { + output="$1" + : > "$output" + printf '# Deployment\n' >> "$output" + env_line COMPOSE_PROJECT_NAME "$COMPOSE_PROJECT_NAME_VALUE" "$output" + env_line IMAGE_TAG "$IMAGE_TAG_VALUE" "$output" + env_line WEBHOOK_HOST "$WEBHOOK_HOST_VALUE" "$output" + env_line MINIAPP_HOST "$MINIAPP_HOST_VALUE" "$output" + env_line WEBHOOK_PUBLIC_URL "$WEBHOOK_PUBLIC_URL_VALUE" "$output" + env_line MINIAPP_PUBLIC_URL "$MINIAPP_PUBLIC_URL_VALUE" "$output" + env_line HTTP_BIND "$HTTP_BIND_VALUE" "$output" + env_line HTTPS_BIND "$HTTPS_BIND_VALUE" "$output" + env_line WEB_SERVER_BIND "$WEB_SERVER_BIND_VALUE" "$output" + env_line FRONTEND_BIND "$FRONTEND_BIND_VALUE" "$output" + env_line PANGOLIN_ENDPOINT "$PANGOLIN_ENDPOINT_VALUE" "$output" + env_line NEWT_ID "$NEWT_ID_VALUE" "$output" + env_line NEWT_SECRET "$NEWT_SECRET_VALUE" "$output" + + printf '\n# Telegram\n' >> "$output" + env_line BOT_TOKEN "$BOT_TOKEN_VALUE" "$output" + env_line ADMIN_IDS "$ADMIN_IDS_VALUE" "$output" + + printf '\n# PostgreSQL\n' >> "$output" + env_line POSTGRES_USER "$POSTGRES_USER_VALUE" "$output" + env_line POSTGRES_PASSWORD "$POSTGRES_PASSWORD_VALUE" "$output" + env_line POSTGRES_DB "$POSTGRES_DB_VALUE" "$output" + + printf '\n# Application\n' >> "$output" + env_line WEBAPP_ENABLED "$WEBAPP_ENABLED_VALUE" "$output" + env_line WEBAPP_SESSION_SECRET "$WEBAPP_SESSION_SECRET_VALUE" "$output" + env_line WEBHOOK_SECRET_TOKEN "$WEBHOOK_SECRET_TOKEN_VALUE" "$output" + env_line TRUSTED_PROXIES "$TRUSTED_PROXIES_VALUE" "$output" + + printf '\n# Remnawave Panel\n' >> "$output" + env_line PANEL_API_URL "$PANEL_API_URL_VALUE" "$output" + env_line PANEL_API_KEY "$PANEL_API_KEY_VALUE" "$output" + env_line PANEL_WEBHOOK_SECRET "$PANEL_WEBHOOK_SECRET_VALUE" "$output" + + append_preserved_env "$output" +} + +write_env_file() { + section "Review .env" + display_env_summary + if ! confirm "Write .env now?" 1; then + warn "Skipped .env write." + return 0 + fi + tmp="$TARGET_DIR/.env.tmp.$$" + render_env_file "$tmp" + if [ -e "$ENV_PATH" ]; then + backup=$(backup_path "$ENV_PATH") + cp "$ENV_PATH" "$backup" + info "Backed up $ENV_PATH to $(basename "$backup")" + fi + mv "$tmp" "$ENV_PATH" + ok "Wrote $ENV_PATH" +} + +prepare_data_directory() { + section "Prepare data directory" + data_dir="$TARGET_DIR/data" + mkdir -p "$data_dir/themes" "$data_dir/webapp-logo" "$data_dir/webapp-emoji" "$data_dir/backups" + if [ ! -f "$data_dir/locales-overrides.json" ]; then + printf '{}\n' > "$data_dir/locales-overrides.json" + fi + if command -v chown >/dev/null 2>&1; then + if ! chown -R "$APP_UID:$APP_GID" "$data_dir" 2>/dev/null; then + warn "Could not chown data files. Run: sudo chown -R $APP_UID:$APP_GID data" + fi + fi + ok "Prepared $data_dir" +} + +require_docker() { + if command -v docker >/dev/null 2>&1 && docker compose version >/dev/null 2>&1; then + COMPOSE_STYLE="docker" + elif command -v docker-compose >/dev/null 2>&1; then + COMPOSE_STYLE="docker-compose" + else + fail "Docker Compose was not found." + return 1 + fi + if command -v docker >/dev/null 2>&1 && ! docker info >/dev/null 2>&1; then + fail "Docker is installed but not reachable. Check service/user permissions." + return 1 + fi +} + +compose() { + if [ "$COMPOSE_STYLE" = "docker" ]; then + docker compose "$@" + else + docker-compose "$@" + fi +} + +run_compose() { + if [ "$COMPOSE_STYLE" = "docker" ]; then + color "+ docker compose $*" "$DIM" + else + color "+ docker-compose $*" "$DIM" + fi + printf '\n' + compose "$@" +} + +start_stack() { + section "Start Docker stack" + require_docker || return 1 + (cd "$TARGET_DIR" && run_compose pull) || return 1 + (cd "$TARGET_DIR" && run_compose up -d) || return 1 + (cd "$TARGET_DIR" && run_compose ps) || true + ok "Stack command completed." +} + +validate_stack() { + section "Validate stack" + require_docker || return 1 + (cd "$TARGET_DIR" && run_compose ps) || true + (cd "$TARGET_DIR" && run_compose logs --tail 80 migrate) || true + ok "Validation commands completed." +} + +download_importer() { + importer="$TARGET_DIR/$IMPORTER_CACHE_PATH" + mkdir -p "$(dirname "$importer")" + if [ -f "$importer" ] && confirm "Use cached importer at $importer?" 1 >&2; then + printf '%s' "$importer" + return 0 + fi + url=$(raw_url "$SOURCE_REPO" "$SOURCE_REF" "backend/scripts/import_legacy.py") + tmp="$TARGET_DIR/.import_legacy.$$" + download_to "$url" "$tmp" || { + rm -f "$tmp" + fail "Could not download $url" + return 1 + } + if [ -f "$importer" ]; then + backup=$(backup_path "$importer") + cp "$importer" "$backup" + info "Backed up $importer to $(basename "$backup")" >&2 + fi + mv "$tmp" "$importer" + ok "Cached importer at $importer" >&2 + printf '%s' "$importer" +} + +local_target_dsn() { + printf 'postgresql://%s:%s@postgres:5432/%s' "$POSTGRES_USER_VALUE" "$POSTGRES_PASSWORD_VALUE" "$POSTGRES_DB_VALUE" +} + +run_import_command() { + dry="$1" + set -- run --rm \ + -v "$IMPORTER_PATH:/app/backend/scripts/import_legacy.py:ro" + if [ -n "$TARIFF_MAP_PATH" ]; then + set -- "$@" -v "$TARIFF_MAP_PATH:/tmp/tariff-map.json:ro" + fi + set -- "$@" backend python backend/scripts/import_legacy.py \ + --source-type remnashop \ + --source-dsn "$SOURCE_DSN" \ + --source-schema "$SOURCE_SCHEMA" \ + --target-dsn "$TARGET_DSN" + if [ -n "$TARIFF_MAP_PATH" ]; then + set -- "$@" --tariff-map-json /tmp/tariff-map.json + fi + if [ "$dry" = "1" ]; then + set -- "$@" --dry-run + fi + (cd "$TARGET_DIR" && run_compose "$@") +} + +run_legacy_migration() { + section "Legacy migration" + ENV_PATH="$TARGET_DIR/.env" + if [ ! -f "$ENV_PATH" ]; then + fail ".env not found. Install or generate configuration first." + return 1 + fi + require_docker || return 1 + POSTGRES_USER_VALUE="$(env_get POSTGRES_USER '')" + POSTGRES_PASSWORD_VALUE="$(env_get POSTGRES_PASSWORD '')" + POSTGRES_DB_VALUE="$(env_get POSTGRES_DB '')" + + choose "Source bot" "1" "1|2" \ + "1. Remnashop" \ + "2. Skip migration" + [ "$CHOICE_VALUE" = "2" ] && return 0 + + prompt_value "Source Remnashop PostgreSQL DSN" "${REMNASHOP_SOURCE_DSN:-}" 1 0 "" + SOURCE_DSN="$PROMPT_VALUE" + prompt_value "Source schema" "public" 1 0 "" + SOURCE_SCHEMA="$PROMPT_VALUE" + + choose "Target database" "1" "1|2" \ + "1. This Docker Compose stack database (recommended)" \ + "2. Manual target DSN" + if [ "$CHOICE_VALUE" = "1" ]; then + TARGET_DSN="$(local_target_dsn)" + info "Target DSN points to the Compose postgres service." + else + prompt_value "Target PostgreSQL DSN" "" 1 0 "" + TARGET_DSN="$PROMPT_VALUE" + fi + + prompt_value "Optional tariff map JSON path (empty to skip)" "" 0 0 "" + TARIFF_MAP_PATH="$PROMPT_VALUE" + if [ -n "$TARIFF_MAP_PATH" ]; then + tariff_map_dir=$(dirname "$TARIFF_MAP_PATH") + if [ ! -d "$tariff_map_dir" ]; then + fail "Tariff map directory not found: $tariff_map_dir" + return 1 + fi + TARIFF_MAP_PATH=$(cd "$tariff_map_dir" && pwd)/$(basename "$TARIFF_MAP_PATH") + if [ ! -f "$TARIFF_MAP_PATH" ]; then + fail "Tariff map not found: $TARIFF_MAP_PATH" + return 1 + fi + fi + + IMPORTER_PATH="$(download_importer)" || return 1 + + section "Dry-run import" + if ! run_import_command 1; then + fail "Dry-run failed. Fix the connection/settings before importing." + return 1 + fi + if ! confirm "Apply this migration for real?" 0; then + warn "Migration not applied." + return 0 + fi + + section "Apply import" + run_import_command 0 || return 1 + if confirm "Restart backend and worker so setting overrides are reloaded?" 1; then + (cd "$TARGET_DIR" && run_compose restart backend worker) || true + fi + ok "Legacy migration completed." +} + +installation_directory() { + prompt_value "Install directory" "${MINISHOP_INSTALL_DIR:-$(pwd)}" 1 0 "" + mkdir -p "$(dirname "$PROMPT_VALUE")" + TARGET_DIR=$(cd "$(dirname "$PROMPT_VALUE")" && pwd)/$(basename "$PROMPT_VALUE") + mkdir -p "$TARGET_DIR" +} + +github_source() { + prompt_value "GitHub repository" "$DEFAULT_REPO" 1 0 "" + SOURCE_REPO="$PROMPT_VALUE" + prompt_value "Git ref/branch/tag for raw files" "$DEFAULT_REF" 1 0 "" + SOURCE_REF="$PROMPT_VALUE" +} + +install_flow() { + with_migration="$1" + installation_directory || return 1 + github_source || return 1 + choose_profile + ENV_PATH="$TARGET_DIR/.env" + if [ -f "$ENV_PATH" ]; then + warn "Existing .env found at $ENV_PATH; wizard will preserve unknown values." + fi + prompt_common_env || return 1 + download_profile_files || return 1 + write_env_file || return 1 + mkdir -p "$TARGET_DIR/$INSTALL_STATE_DIR" + prepare_data_directory || return 1 + if confirm "Start Docker Compose stack now?" 1; then + start_stack || return 1 + fi + if [ "$with_migration" = "1" ]; then + run_legacy_migration + elif confirm "Run a legacy bot migration now?" 0; then + run_legacy_migration + fi +} + +migration_only_flow() { + installation_directory || return 1 + github_source || return 1 + run_legacy_migration +} + +download_only_flow() { + installation_directory || return 1 + github_source || return 1 + choose_profile + download_profile_files +} + +health_flow() { + installation_directory || return 1 + validate_stack +} + +main_menu() { + while :; do + banner + choose "Main menu" "1" "1|2|3|4|5|6" \ + "1. Install new stack" \ + "2. Install new stack and run legacy migration" \ + "3. Run legacy migration only" \ + "4. Download/update deployment files only" \ + "5. Validate current stack" \ + "6. Exit" + case "$CHOICE_VALUE" in + 1) install_flow 0 ;; + 2) install_flow 1 ;; + 3) migration_only_flow ;; + 4) download_only_flow ;; + 5) health_flow ;; + 6) printf 'Bye.\n'; return 0 ;; + esac + status=$? + if [ "$status" -ne 0 ]; then + fail "Step failed with status $status." + fi + pause + done +} + +case "${1:-}" in + -h|--help) + print_help + exit 0 + ;; +esac + +main_menu diff --git a/tests/test_install_script.py b/tests/test_install_script.py new file mode 100644 index 0000000..b185524 --- /dev/null +++ b/tests/test_install_script.py @@ -0,0 +1,39 @@ +import shutil +import subprocess +from pathlib import Path + +import pytest + +REPO_ROOT = Path(__file__).resolve().parents[1] +INSTALL_SCRIPT = REPO_ROOT / "scripts" / "install.sh" + + +def test_shell_installer_help_does_not_require_python(): + if not shutil.which("sh"): + pytest.skip("sh is not available on this platform") + + result = subprocess.run( + ["sh", str(INSTALL_SCRIPT), "--help"], + check=True, + text=True, + capture_output=True, + ) + + assert "MINISHOP_INSTALL_REPO" in result.stdout + assert "dry-run" in result.stdout + + +def test_shell_installer_is_the_only_install_entrypoint(): + assert INSTALL_SCRIPT.exists() + assert not (REPO_ROOT / "scripts" / "install.py").exists() + + +def test_shell_installer_downloads_raw_files_and_runs_import_in_container(): + script = INSTALL_SCRIPT.read_text(encoding="utf-8") + + assert script.startswith("#!/bin/sh") + assert "raw.githubusercontent.com" in script + assert "git clone" not in script + assert "backend python backend/scripts/import_legacy.py" in script + assert "--dry-run" in script + assert "Install new stack and run legacy migration" in script From 3b40369606f91735c43e56653a6b596917afbc4c Mon Sep 17 00:00:00 2001 From: 3252a8 <3252a8@proton.me> Date: Tue, 2 Jun 2026 10:17:50 +0300 Subject: [PATCH 06/58] feat: add legacy tg-shop migration to installer --- docs/getting-started/deployment.md | 10 +- docs/migrations/remnawave-tg-shop.md | 27 +++ scripts/install.sh | 285 +++++++++++++++++++++++++-- tests/test_install_script.py | 11 ++ 4 files changed, 314 insertions(+), 19 deletions(-) diff --git a/docs/getting-started/deployment.md b/docs/getting-started/deployment.md index ae3f09d..844cdda 100644 --- a/docs/getting-started/deployment.md +++ b/docs/getting-started/deployment.md @@ -31,7 +31,8 @@ Wizard работает через меню с цифрами и подтвер - сохранить backup существующих файлов перед перезаписью; - запустить `docker compose pull && docker compose up -d`; - проверить текущий стек через `docker compose ps` и логи `migrate`; -- запустить миграцию из поддерживаемых legacy-ботов, сейчас - из Remnashop. +- запустить миграцию из поддерживаемых legacy-ботов: Remnashop и старый + `remnawave-tg-shop`; Для тестирования другой ветки или форка задайте источник перед запуском: @@ -41,8 +42,11 @@ MINISHOP_INSTALL_REF=main \ sh install.sh ``` -Миграция в wizard сначала запускает `dry-run`, показывает JSON-сводку и только -после отдельного подтверждения применяет изменения в целевую БД. +Миграция Remnashop в wizard сначала запускает `dry-run`, показывает JSON-сводку +и только после отдельного подтверждения применяет изменения в целевую БД. +Миграция со старого `remnawave-tg-shop` работает как upgrade совместимой БД: +либо копирует старый Docker volume, либо делает `pg_dump` по source DSN, +восстанавливает дамп в целевую compose-БД и запускает сервис `migrate`. Обычный `docker compose up -d --build` поднимает: diff --git a/docs/migrations/remnawave-tg-shop.md b/docs/migrations/remnawave-tg-shop.md index b7e16b0..9ef4b84 100644 --- a/docs/migrations/remnawave-tg-shop.md +++ b/docs/migrations/remnawave-tg-shop.md @@ -4,6 +4,33 @@ Автоматический скрипт ниже рассчитан именно на родственный стек `remnawave-tg-shop`, где структура БД и Docker volumes известны заранее. Для других ботов нужен отдельный адаптер экспорта/импорта. +## Новый install wizard + +Для нового сервера или переноса без клонирования репозитория используйте общий +`sh` wizard: + +```bash +curl -fsSL https://raw.githubusercontent.com/3252a8/remnawave-minishop/main/scripts/install.sh -o install.sh +sh install.sh +``` + +В меню выберите `Install new stack and run legacy migration` или +`Run legacy migration only`, затем источник `Legacy remnawave-tg-shop`. +Wizard поддерживает два режима: + +- `Copy old Docker volumes` - тот же безопасный сценарий, что и старый + `migrate_to_minishop.sh`: старый volume `remnawave-tg-shop-db-data` + копируется в `remnawave-minishop-db-data`, затем новый stack запускает + сервис `migrate` и накатывает все схемные миграции; +- `Dump from a source PostgreSQL DSN` - новый режим для случаев, когда старая + БД доступна как внешний PostgreSQL DSN. Wizard поднимает целевой `postgres`, + сбрасывает целевую БД, делает `pg_dump` из старой БД, восстанавливает дамп в + compose-БД и затем запускает `migrate`. + +Старый helper `scripts/migrate_to_minishop.sh` ниже всё ещё полезен для +in-place обновления уже клонированного репозитория: он переключает git-ветку, +переносит volumes и стартует новый stack. Прямой source DSN он не поддерживал. + ## Короткий путь без смены ветки и сборки Если вы используете только готовые Docker-образы и не собираете проект diff --git a/scripts/install.sh b/scripts/install.sh index d9d93dc..f365310 100644 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -11,6 +11,13 @@ INSTALL_STATE_DIR=".installer" IMPORTER_CACHE_PATH="$INSTALL_STATE_DIR/import_legacy.py" APP_UID=10001 APP_GID=10001 +OLD_TGSHOP_DB_VOLUME="remnawave-tg-shop-db-data" +NEW_MINISHOP_DB_VOLUME="remnawave-minishop-db-data" +OLD_TGSHOP_CADDY_DATA_VOLUME="remnawave-tg-shop-caddy-data" +OLD_TGSHOP_CADDY_CONFIG_VOLUME="remnawave-tg-shop-caddy-config" +NEW_MINISHOP_CADDY_DATA_VOLUME="remnawave-minishop-caddy-data" +NEW_MINISHOP_CADDY_CONFIG_VOLUME="remnawave-minishop-caddy-config" +KNOWN_LEGACY_CONTAINERS="remnawave-tg-shop remnawave-tg-shop-db remnawave-tg-shop-caddy remnawave-minishop remnawave-minishop-db remnawave-minishop-caddy remnawave-minishop-backend remnawave-minishop-worker remnawave-minishop-frontend remnawave-minishop-migrate remnawave-minishop-postgres remnawave-minishop-redis" if [ -t 1 ] && [ -z "${NO_COLOR:-}" ]; then RESET="$(printf '\033[0m')" @@ -40,6 +47,7 @@ ENV_PATH="" COMPOSE_STYLE="" PROMPT_VALUE="" CHOICE_VALUE="" +LEGACY_SOURCE="" COMPOSE_PROJECT_NAME_VALUE="" IMAGE_TAG_VALUE="" @@ -121,9 +129,11 @@ Environment overrides: MINISHOP_INSTALL_REF default ref ($DEFAULT_REF) MINISHOP_IMAGE_TAG default image tag ($DEFAULT_IMAGE_TAG) REMNASHOP_SOURCE_DSN default source DSN for migration + LEGACY_TGSHOP_SOURCE_DSN default remnawave-tg-shop source DSN for dump/restore The wizard is interactive by design. It never overwrites files without -confirmation and always runs legacy imports as dry-run first. +confirmation. Remnashop imports always run dry-run first; legacy +remnawave-tg-shop can be migrated from Docker volumes or a PostgreSQL DSN. EOF } @@ -677,9 +687,12 @@ run_compose() { } start_stack() { + pull="${1:-1}" section "Start Docker stack" require_docker || return 1 - (cd "$TARGET_DIR" && run_compose pull) || return 1 + if [ "$pull" = "1" ]; then + (cd "$TARGET_DIR" && run_compose pull) || return 1 + fi (cd "$TARGET_DIR" && run_compose up -d) || return 1 (cd "$TARGET_DIR" && run_compose ps) || true ok "Stack command completed." @@ -693,6 +706,95 @@ validate_stack() { ok "Validation commands completed." } +volume_exists() { + docker volume inspect "$1" >/dev/null 2>&1 +} + +volume_is_empty() { + docker run --rm -v "$1:/data" alpine sh -c \ + 'test -z "$(find /data -mindepth 1 -print -quit)"' >/dev/null 2>&1 +} + +copy_volume_if_safe() { + source_volume="$1" + target_volume="$2" + required="${3:-0}" + + if ! volume_exists "$source_volume"; then + if [ "$required" = "1" ]; then + fail "Source Docker volume not found: $source_volume" + return 1 + fi + warn "Skipping $source_volume: source volume was not found." + return 0 + fi + + if ! volume_exists "$target_volume"; then + if [ "$required" = "1" ]; then + fail "Target Docker volume not found: $target_volume" + return 1 + fi + warn "Skipping $target_volume: target volume was not created by this profile." + return 0 + fi + + if ! volume_is_empty "$target_volume"; then + if [ "$required" = "1" ]; then + warn "Target volume $target_volume is already not empty." + warn "It may already be migrated, or the target stack may have been started with an empty database." + if confirm "Continue without copying the legacy database volume?" 0; then + return 0 + fi + return 1 + fi + warn "Skipping $target_volume: target volume is already not empty." + return 0 + fi + + run_label="docker run --rm -v $source_volume:/from:ro -v $target_volume:/to alpine sh -c 'cd /from && cp -a . /to/'" + color "+ $run_label" "$DIM" + printf '\n' + docker run --rm \ + -v "$source_volume:/from:ro" \ + -v "$target_volume:/to" \ + alpine sh -c 'cd /from && cp -a . /to/' || return 1 + ok "Copied $source_volume -> $target_volume" +} + +stop_known_legacy_containers() { + section "Stop legacy containers" + stopped=0 + for container in $KNOWN_LEGACY_CONTAINERS; do + if docker inspect "$container" >/dev/null 2>&1; then + if docker inspect -f '{{.State.Running}}' "$container" 2>/dev/null | grep -q '^true$'; then + docker stop "$container" >/dev/null || true + fi + docker rm "$container" >/dev/null || true + info "Stopped/removed $container" + stopped=1 + fi + done + if [ "$stopped" = "0" ]; then + info "No known legacy containers found." + fi +} + +wait_target_postgres() { + section "Wait for target PostgreSQL" + attempt=1 + while [ "$attempt" -le 30 ]; do + if (cd "$TARGET_DIR" && compose exec -T postgres sh -c \ + 'pg_isready -U "$POSTGRES_USER" -d "$POSTGRES_DB"' >/dev/null 2>&1); then + ok "PostgreSQL is ready." + return 0 + fi + sleep 2 + attempt=$((attempt + 1)) + done + fail "Target PostgreSQL did not become ready." + return 1 +} + download_importer() { importer="$TARGET_DIR/$IMPORTER_CACHE_PATH" mkdir -p "$(dirname "$importer")" @@ -742,23 +844,38 @@ run_import_command() { (cd "$TARGET_DIR" && run_compose "$@") } -run_legacy_migration() { - section "Legacy migration" +choose_legacy_source() { + choose "Source bot" "1" "1|2|3" \ + "1. Remnashop - import users, subscriptions, payments, referrals and promo codes." \ + "2. Legacy remnawave-tg-shop - upgrade an old compatible database/volume." \ + "3. Skip migration" + case "$CHOICE_VALUE" in + 1) LEGACY_SOURCE="remnashop" ;; + 2) LEGACY_SOURCE="remnawave-tg-shop" ;; + 3) LEGACY_SOURCE="skip" ;; + esac +} + +ensure_github_source_for_importer() { + if [ -n "$SOURCE_REPO" ] && [ -n "$SOURCE_REF" ]; then + return 0 + fi + github_source +} + +run_remnashop_migration() { + section "Remnashop migration" ENV_PATH="$TARGET_DIR/.env" if [ ! -f "$ENV_PATH" ]; then fail ".env not found. Install or generate configuration first." return 1 fi + ensure_github_source_for_importer || return 1 require_docker || return 1 POSTGRES_USER_VALUE="$(env_get POSTGRES_USER '')" POSTGRES_PASSWORD_VALUE="$(env_get POSTGRES_PASSWORD '')" POSTGRES_DB_VALUE="$(env_get POSTGRES_DB '')" - choose "Source bot" "1" "1|2" \ - "1. Remnashop" \ - "2. Skip migration" - [ "$CHOICE_VALUE" = "2" ] && return 0 - prompt_value "Source Remnashop PostgreSQL DSN" "${REMNASHOP_SOURCE_DSN:-}" 1 0 "" SOURCE_DSN="$PROMPT_VALUE" prompt_value "Source schema" "public" 1 0 "" @@ -810,6 +927,118 @@ run_legacy_migration() { ok "Legacy migration completed." } +run_target_schema_migrations() { + section "Apply target schema migrations" + require_docker || return 1 + (cd "$TARGET_DIR" && run_compose run --rm migrate) || return 1 + ok "Schema migrations completed." +} + +prepare_compose_without_starting_apps() { + section "Prepare target Compose stack" + require_docker || return 1 + (cd "$TARGET_DIR" && run_compose up --no-start) || return 1 +} + +run_tgshop_volume_migration() { + section "Legacy remnawave-tg-shop volume migration" + warn "This path copies the old PostgreSQL Docker volume into the new Minishop volume." + warn "Old volumes are not deleted; keep them until you verify the new stack." + + if confirm "Stop known old/current containers before copying volumes?" 1; then + stop_known_legacy_containers || return 1 + (cd "$TARGET_DIR" && run_compose down) || true + fi + + prepare_compose_without_starting_apps || return 1 + copy_volume_if_safe "$OLD_TGSHOP_DB_VOLUME" "$NEW_MINISHOP_DB_VOLUME" 1 || return 1 + copy_volume_if_safe "$OLD_TGSHOP_CADDY_DATA_VOLUME" "$NEW_MINISHOP_CADDY_DATA_VOLUME" 0 || return 1 + copy_volume_if_safe "$OLD_TGSHOP_CADDY_CONFIG_VOLUME" "$NEW_MINISHOP_CADDY_CONFIG_VOLUME" 0 || return 1 + + if confirm "Start the new stack and let migrate apply schema changes now?" 1; then + start_stack 0 || return 1 + (cd "$TARGET_DIR" && run_compose logs --tail 120 migrate) || true + else + warn "Stack was prepared but not started. Run docker compose up -d later." + fi +} + +run_tgshop_dsn_migration() { + section "Legacy remnawave-tg-shop DSN migration" + warn "The old standalone helper did not support direct DSN import." + warn "This wizard path dumps the old PostgreSQL database, restores it into target Compose PostgreSQL, then runs Minishop schema migrations." + warn "The target database will be dropped and recreated before restore." + + if ! confirm "Replace target database with the legacy dump?" 0; then + warn "Migration not applied." + return 0 + fi + + prompt_value "Source remnawave-tg-shop PostgreSQL DSN" "${LEGACY_TGSHOP_SOURCE_DSN:-}" 1 0 "" + SOURCE_DSN="$PROMPT_VALUE" + + require_docker || return 1 + POSTGRES_USER_VALUE="$(env_get POSTGRES_USER '')" + POSTGRES_PASSWORD_VALUE="$(env_get POSTGRES_PASSWORD '')" + POSTGRES_DB_VALUE="$(env_get POSTGRES_DB '')" + TARGET_DSN="$(local_target_dsn)" + + section "Start target PostgreSQL" + (cd "$TARGET_DIR" && run_compose stop backend worker frontend migrate) || true + (cd "$TARGET_DIR" && run_compose up -d postgres redis) || return 1 + wait_target_postgres || return 1 + + section "Reset target database" + (cd "$TARGET_DIR" && run_compose exec -T postgres sh -c \ + 'dropdb -U "$POSTGRES_USER" --if-exists "$POSTGRES_DB" && createdb -U "$POSTGRES_USER" "$POSTGRES_DB"') || return 1 + + section "Dump and restore legacy database" + (cd "$TARGET_DIR" && run_compose run --rm --no-deps \ + -e "SOURCE_DSN=$SOURCE_DSN" \ + -e "TARGET_DSN=$TARGET_DSN" \ + backend sh -lc \ + 'pg_dump --clean --if-exists --no-owner --no-privileges "$SOURCE_DSN" | psql "$TARGET_DSN"') || return 1 + + run_target_schema_migrations || return 1 + if confirm "Start the full stack now?" 1; then + start_stack 0 || return 1 + fi +} + +run_remnawave_tg_shop_migration() { + section "Legacy remnawave-tg-shop migration" + ENV_PATH="$TARGET_DIR/.env" + if [ ! -f "$ENV_PATH" ]; then + fail ".env not found. Install or generate configuration first." + return 1 + fi + require_docker || return 1 + + choose "Migration method" "1" "1|2|3" \ + "1. Copy old Docker volumes on this host (recommended for old compose installs)." \ + "2. Dump from a source PostgreSQL DSN and restore into this compose stack." \ + "3. Skip migration" + case "$CHOICE_VALUE" in + 1) run_tgshop_volume_migration ;; + 2) run_tgshop_dsn_migration ;; + 3) return 0 ;; + esac +} + +run_selected_legacy_migration() { + case "$LEGACY_SOURCE" in + remnashop) + run_remnashop_migration + ;; + remnawave-tg-shop) + run_remnawave_tg_shop_migration + ;; + skip|"") + return 0 + ;; + esac +} + installation_directory() { prompt_value "Install directory" "${MINISHOP_INSTALL_DIR:-$(pwd)}" 1 0 "" mkdir -p "$(dirname "$PROMPT_VALUE")" @@ -826,6 +1055,7 @@ github_source() { install_flow() { with_migration="$1" + LEGACY_SOURCE="" installation_directory || return 1 github_source || return 1 choose_profile @@ -838,20 +1068,43 @@ install_flow() { write_env_file || return 1 mkdir -p "$TARGET_DIR/$INSTALL_STATE_DIR" prepare_data_directory || return 1 - if confirm "Start Docker Compose stack now?" 1; then - start_stack || return 1 - fi if [ "$with_migration" = "1" ]; then - run_legacy_migration + choose_legacy_source elif confirm "Run a legacy bot migration now?" 0; then - run_legacy_migration + choose_legacy_source fi + + case "$LEGACY_SOURCE" in + remnawave-tg-shop) + run_selected_legacy_migration + ;; + remnashop) + if confirm "Start Docker Compose stack before Remnashop import?" 1; then + start_stack || return 1 + else + warn "Remnashop import needs the target stack database. Skipping import." + return 0 + fi + run_selected_legacy_migration + ;; + *) + if confirm "Start Docker Compose stack now?" 1; then + start_stack || return 1 + fi + ;; + esac } migration_only_flow() { + LEGACY_SOURCE="" installation_directory || return 1 - github_source || return 1 - run_legacy_migration + choose_legacy_source + case "$LEGACY_SOURCE" in + remnashop) + github_source || return 1 + ;; + esac + run_selected_legacy_migration } download_only_flow() { diff --git a/tests/test_install_script.py b/tests/test_install_script.py index b185524..cec0b1e 100644 --- a/tests/test_install_script.py +++ b/tests/test_install_script.py @@ -21,6 +21,7 @@ def test_shell_installer_help_does_not_require_python(): assert "MINISHOP_INSTALL_REPO" in result.stdout assert "dry-run" in result.stdout + assert "LEGACY_TGSHOP_SOURCE_DSN" in result.stdout def test_shell_installer_is_the_only_install_entrypoint(): @@ -37,3 +38,13 @@ def test_shell_installer_downloads_raw_files_and_runs_import_in_container(): assert "backend python backend/scripts/import_legacy.py" in script assert "--dry-run" in script assert "Install new stack and run legacy migration" in script + + +def test_shell_installer_supports_legacy_tgshop_volume_and_dsn_paths(): + script = INSTALL_SCRIPT.read_text(encoding="utf-8") + + assert "Legacy remnawave-tg-shop" in script + assert "remnawave-tg-shop-db-data" in script + assert "remnawave-minishop-db-data" in script + assert "pg_dump --clean --if-exists" in script + assert "run_compose run --rm migrate" in script From 162e9e20b591d5d7d38340dc3b493db7ca644559 Mon Sep 17 00:00:00 2001 From: 3252a8 <3252a8@proton.me> Date: Tue, 2 Jun 2026 10:21:08 +0300 Subject: [PATCH 07/58] fix: keep installer from preparing data directories --- docs/getting-started/deployment.md | 6 ++-- docs/migrations/remnawave-tg-shop.md | 2 +- scripts/install.sh | 45 ++++++++-------------------- tests/test_install_script.py | 2 +- 4 files changed, 17 insertions(+), 38 deletions(-) diff --git a/docs/getting-started/deployment.md b/docs/getting-started/deployment.md index 844cdda..d3d04cc 100644 --- a/docs/getting-started/deployment.md +++ b/docs/getting-started/deployment.md @@ -15,9 +15,7 @@ docker compose logs -f backend worker frontend ## Интерактивный install wizard -Для нового сервера можно не клонировать весь репозиторий и не ставить Python. -Скачайте только POSIX `sh`-скрипт через `raw.githubusercontent.com` и -запустите его: +Для нового сервера скачайте install-скрипт и запустите его: ```bash curl -fsSL https://raw.githubusercontent.com/3252a8/remnawave-minishop/main/scripts/install.sh -o install.sh @@ -31,7 +29,7 @@ Wizard работает через меню с цифрами и подтвер - сохранить backup существующих файлов перед перезаписью; - запустить `docker compose pull && docker compose up -d`; - проверить текущий стек через `docker compose ps` и логи `migrate`; -- запустить миграцию из поддерживаемых legacy-ботов: Remnashop и старый +- запустить миграцию из поддерживаемых ботов: Remnashop и старый `remnawave-tg-shop`; Для тестирования другой ветки или форка задайте источник перед запуском: diff --git a/docs/migrations/remnawave-tg-shop.md b/docs/migrations/remnawave-tg-shop.md index 9ef4b84..789fa71 100644 --- a/docs/migrations/remnawave-tg-shop.md +++ b/docs/migrations/remnawave-tg-shop.md @@ -15,7 +15,7 @@ sh install.sh ``` В меню выберите `Install new stack and run legacy migration` или -`Run legacy migration only`, затем источник `Legacy remnawave-tg-shop`. +`Run legacy migration only`, затем источник `Old remnawave-tg-shop`. Wizard поддерживает два режима: - `Copy old Docker volumes` - тот же безопасный сценарий, что и старый diff --git a/scripts/install.sh b/scripts/install.sh index f365310..bc7c57c 100644 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -1,16 +1,13 @@ #!/bin/sh set -u -# Dependency-light installer for fresh Linux hosts. -# It intentionally avoids Python and clones only the files selected by the user. +# Interactive installer for fresh Docker Compose hosts. DEFAULT_REPO="${MINISHOP_INSTALL_REPO:-3252a8/remnawave-minishop}" DEFAULT_REF="${MINISHOP_INSTALL_REF:-main}" DEFAULT_IMAGE_TAG="${MINISHOP_IMAGE_TAG:-latest}" INSTALL_STATE_DIR=".installer" IMPORTER_CACHE_PATH="$INSTALL_STATE_DIR/import_legacy.py" -APP_UID=10001 -APP_GID=10001 OLD_TGSHOP_DB_VOLUME="remnawave-tg-shop-db-data" NEW_MINISHOP_DB_VOLUME="remnawave-minishop-db-data" OLD_TGSHOP_CADDY_DATA_VOLUME="remnawave-tg-shop-caddy-data" @@ -85,7 +82,7 @@ banner() { printf '\n' color "Remnawave MiniShop Install Wizard" "$BOLD$CYAN" printf '\n' - color "Install, configure, start, and migrate legacy bot data." "$DIM" + color "Install, configure, start, and migrate existing bot data." "$DIM" printf '\n\n' } @@ -132,8 +129,8 @@ Environment overrides: LEGACY_TGSHOP_SOURCE_DSN default remnawave-tg-shop source DSN for dump/restore The wizard is interactive by design. It never overwrites files without -confirmation. Remnashop imports always run dry-run first; legacy -remnawave-tg-shop can be migrated from Docker volumes or a PostgreSQL DSN. +confirmation. Remnashop imports always run dry-run first; remnawave-tg-shop +can be migrated from Docker volumes or a PostgreSQL DSN. EOF } @@ -638,21 +635,6 @@ write_env_file() { ok "Wrote $ENV_PATH" } -prepare_data_directory() { - section "Prepare data directory" - data_dir="$TARGET_DIR/data" - mkdir -p "$data_dir/themes" "$data_dir/webapp-logo" "$data_dir/webapp-emoji" "$data_dir/backups" - if [ ! -f "$data_dir/locales-overrides.json" ]; then - printf '{}\n' > "$data_dir/locales-overrides.json" - fi - if command -v chown >/dev/null 2>&1; then - if ! chown -R "$APP_UID:$APP_GID" "$data_dir" 2>/dev/null; then - warn "Could not chown data files. Run: sudo chown -R $APP_UID:$APP_GID data" - fi - fi - ok "Prepared $data_dir" -} - require_docker() { if command -v docker >/dev/null 2>&1 && docker compose version >/dev/null 2>&1; then COMPOSE_STYLE="docker" @@ -742,7 +724,7 @@ copy_volume_if_safe() { if [ "$required" = "1" ]; then warn "Target volume $target_volume is already not empty." warn "It may already be migrated, or the target stack may have been started with an empty database." - if confirm "Continue without copying the legacy database volume?" 0; then + if confirm "Continue without copying the old database volume?" 0; then return 0 fi return 1 @@ -762,7 +744,7 @@ copy_volume_if_safe() { } stop_known_legacy_containers() { - section "Stop legacy containers" + section "Stop old containers" stopped=0 for container in $KNOWN_LEGACY_CONTAINERS; do if docker inspect "$container" >/dev/null 2>&1; then @@ -775,7 +757,7 @@ stop_known_legacy_containers() { fi done if [ "$stopped" = "0" ]; then - info "No known legacy containers found." + info "No known old containers found." fi } @@ -847,7 +829,7 @@ run_import_command() { choose_legacy_source() { choose "Source bot" "1" "1|2|3" \ "1. Remnashop - import users, subscriptions, payments, referrals and promo codes." \ - "2. Legacy remnawave-tg-shop - upgrade an old compatible database/volume." \ + "2. Old remnawave-tg-shop - upgrade an old compatible database/volume." \ "3. Skip migration" case "$CHOICE_VALUE" in 1) LEGACY_SOURCE="remnashop" ;; @@ -941,7 +923,7 @@ prepare_compose_without_starting_apps() { } run_tgshop_volume_migration() { - section "Legacy remnawave-tg-shop volume migration" + section "Old remnawave-tg-shop volume migration" warn "This path copies the old PostgreSQL Docker volume into the new Minishop volume." warn "Old volumes are not deleted; keep them until you verify the new stack." @@ -964,7 +946,7 @@ run_tgshop_volume_migration() { } run_tgshop_dsn_migration() { - section "Legacy remnawave-tg-shop DSN migration" + section "Old remnawave-tg-shop DSN migration" warn "The old standalone helper did not support direct DSN import." warn "This wizard path dumps the old PostgreSQL database, restores it into target Compose PostgreSQL, then runs Minishop schema migrations." warn "The target database will be dropped and recreated before restore." @@ -992,7 +974,7 @@ run_tgshop_dsn_migration() { (cd "$TARGET_DIR" && run_compose exec -T postgres sh -c \ 'dropdb -U "$POSTGRES_USER" --if-exists "$POSTGRES_DB" && createdb -U "$POSTGRES_USER" "$POSTGRES_DB"') || return 1 - section "Dump and restore legacy database" + section "Dump and restore old database" (cd "$TARGET_DIR" && run_compose run --rm --no-deps \ -e "SOURCE_DSN=$SOURCE_DSN" \ -e "TARGET_DSN=$TARGET_DSN" \ @@ -1006,7 +988,7 @@ run_tgshop_dsn_migration() { } run_remnawave_tg_shop_migration() { - section "Legacy remnawave-tg-shop migration" + section "Old remnawave-tg-shop migration" ENV_PATH="$TARGET_DIR/.env" if [ ! -f "$ENV_PATH" ]; then fail ".env not found. Install or generate configuration first." @@ -1067,10 +1049,9 @@ install_flow() { download_profile_files || return 1 write_env_file || return 1 mkdir -p "$TARGET_DIR/$INSTALL_STATE_DIR" - prepare_data_directory || return 1 if [ "$with_migration" = "1" ]; then choose_legacy_source - elif confirm "Run a legacy bot migration now?" 0; then + elif confirm "Run a migration from another bot now?" 0; then choose_legacy_source fi diff --git a/tests/test_install_script.py b/tests/test_install_script.py index cec0b1e..f69a83b 100644 --- a/tests/test_install_script.py +++ b/tests/test_install_script.py @@ -43,7 +43,7 @@ def test_shell_installer_downloads_raw_files_and_runs_import_in_container(): def test_shell_installer_supports_legacy_tgshop_volume_and_dsn_paths(): script = INSTALL_SCRIPT.read_text(encoding="utf-8") - assert "Legacy remnawave-tg-shop" in script + assert "Old remnawave-tg-shop" in script assert "remnawave-tg-shop-db-data" in script assert "remnawave-minishop-db-data" in script assert "pg_dump --clean --if-exists" in script From 0473829f7a64152373f6583a0e8e678c8dec2769 Mon Sep 17 00:00:00 2001 From: 3252a8 <3252a8@proton.me> Date: Tue, 2 Jun 2026 10:30:43 +0300 Subject: [PATCH 08/58] fix: prepare writable data mount in installer --- docs/getting-started/deployment.md | 1 + scripts/install.sh | 36 ++++++++++++++++++++++++++++++ tests/test_install_script.py | 12 ++++++++++ 3 files changed, 49 insertions(+) diff --git a/docs/getting-started/deployment.md b/docs/getting-started/deployment.md index d3d04cc..1df27b0 100644 --- a/docs/getting-started/deployment.md +++ b/docs/getting-started/deployment.md @@ -27,6 +27,7 @@ Wizard работает через меню с цифрами и подтвер - скачать выбранный compose-профиль (`Caddy`, `Nginx`, `Pangolin/Newt` или `no-proxy`); - сгенерировать минимальный `.env`, включая пароли и стабильные secrets; - сохранить backup существующих файлов перед перезаписью; +- подготовить writable `data/` для файлов приложения; - запустить `docker compose pull && docker compose up -d`; - проверить текущий стек через `docker compose ps` и логи `migrate`; - запустить миграцию из поддерживаемых ботов: Remnashop и старый diff --git a/scripts/install.sh b/scripts/install.sh index bc7c57c..26274be 100644 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -8,6 +8,8 @@ DEFAULT_REF="${MINISHOP_INSTALL_REF:-main}" DEFAULT_IMAGE_TAG="${MINISHOP_IMAGE_TAG:-latest}" INSTALL_STATE_DIR=".installer" IMPORTER_CACHE_PATH="$INSTALL_STATE_DIR/import_legacy.py" +APP_UID=10001 +APP_GID=10001 OLD_TGSHOP_DB_VOLUME="remnawave-tg-shop-db-data" NEW_MINISHOP_DB_VOLUME="remnawave-minishop-db-data" OLD_TGSHOP_CADDY_DATA_VOLUME="remnawave-tg-shop-caddy-data" @@ -635,6 +637,37 @@ write_env_file() { ok "Wrote $ENV_PATH" } +prepare_data_mount() { + section "Prepare data mount" + data_dir="$TARGET_DIR/data" + created=0 + if [ ! -d "$data_dir" ]; then + mkdir -p "$data_dir" || return 1 + created=1 + fi + + if [ "$created" = "1" ]; then + if command -v chown >/dev/null 2>&1; then + if ! chown "$APP_UID:$APP_GID" "$data_dir" 2>/dev/null; then + warn "Could not chown $data_dir. Run: sudo chown $APP_UID:$APP_GID data" + fi + fi + chmod u+rwx "$data_dir" 2>/dev/null || true + ok "Created writable $data_dir" + return 0 + fi + + info "$data_dir already exists." + if confirm "Adjust $data_dir owner to $APP_UID:$APP_GID for container writes?" 0; then + if command -v chown >/dev/null 2>&1; then + if ! chown "$APP_UID:$APP_GID" "$data_dir" 2>/dev/null; then + warn "Could not chown $data_dir. Run: sudo chown $APP_UID:$APP_GID data" + fi + fi + chmod u+rwx "$data_dir" 2>/dev/null || true + fi +} + require_docker() { if command -v docker >/dev/null 2>&1 && docker compose version >/dev/null 2>&1; then COMPOSE_STYLE="docker" @@ -1049,6 +1082,7 @@ install_flow() { download_profile_files || return 1 write_env_file || return 1 mkdir -p "$TARGET_DIR/$INSTALL_STATE_DIR" + prepare_data_mount || return 1 if [ "$with_migration" = "1" ]; then choose_legacy_source elif confirm "Run a migration from another bot now?" 0; then @@ -1080,6 +1114,8 @@ migration_only_flow() { LEGACY_SOURCE="" installation_directory || return 1 choose_legacy_source + [ "$LEGACY_SOURCE" = "skip" ] && return 0 + prepare_data_mount || return 1 case "$LEGACY_SOURCE" in remnashop) github_source || return 1 diff --git a/tests/test_install_script.py b/tests/test_install_script.py index f69a83b..72a3b56 100644 --- a/tests/test_install_script.py +++ b/tests/test_install_script.py @@ -48,3 +48,15 @@ def test_shell_installer_supports_legacy_tgshop_volume_and_dsn_paths(): assert "remnawave-minishop-db-data" in script assert "pg_dump --clean --if-exists" in script assert "run_compose run --rm migrate" in script + + +def test_shell_installer_only_prepares_data_mount_not_runtime_content(): + script = INSTALL_SCRIPT.read_text(encoding="utf-8") + + assert 'data_dir="$TARGET_DIR/data"' in script + assert "mkdir -p \"$data_dir\"" in script + assert "chown \"$APP_UID:$APP_GID\" \"$data_dir\"" in script + assert "data_dir/themes" not in script + assert "webapp-logo" not in script + assert "webapp-emoji" not in script + assert "locales-overrides.json" not in script From 2ccead9b49b40aa236f19dc7d0088a5844cf9949 Mon Sep 17 00:00:00 2001 From: 3252a8 <3252a8@proton.me> Date: Tue, 2 Jun 2026 10:57:28 +0300 Subject: [PATCH 09/58] refactor: consolidate migration installer --- README.md | 4 +- docs/migrations/remnashop.md | 8 +- docs/migrations/remnawave-tg-shop.md | 447 ++++++--------------------- scripts/install.sh | 9 +- scripts/migrate_to_minishop.sh | 318 ------------------- tests/test_install_script.py | 3 +- tests/test_migration_doc_accuracy.py | 187 ++++------- 7 files changed, 162 insertions(+), 814 deletions(-) delete mode 100644 scripts/migrate_to_minishop.sh diff --git a/README.md b/README.md index d1c581f..96c9ad9 100644 --- a/README.md +++ b/README.md @@ -43,8 +43,8 @@ Remnawave Minishop - Telegram-бот и Web App (Mini App) для продажи - [Telegram-авторизация](docs/features/telegram-auth.md) и [вход по email](docs/features/email-login.md) - настройка BotFather/OAuth и SMTP-логина. - [Поддержка пользователей / тикеты](docs/features/support.md) - тикеты в Mini App, входящий список админки, уведомления, лимиты и внешняя ссылка поддержки. - [Темы Web App](docs/features/webapp-themes.md) - кастомные темы, настройка внешнего вида, логотипы, CSS/ассеты и пайплайн создания новой темы. -- [Миграции](docs/migrations/index.md) - готовые сценарии переноса с других ботов; сейчас описан `remnawave-tg-shop`. -- [Миграция с remnawave-tg-shop](docs/migrations/remnawave-tg-shop.md) - готовый сценарий для legacy-стека. +- [Миграции](docs/migrations/index.md) - готовые сценарии переноса с `remnawave-tg-shop` и Remnashop. +- [Миграция с remnawave-tg-shop](docs/migrations/remnawave-tg-shop.md) и [Remnashop](docs/migrations/remnashop.md) - сценарии через общий install wizard. ## Совместимость diff --git a/docs/migrations/remnashop.md b/docs/migrations/remnashop.md index 2387bb9..11e662c 100644 --- a/docs/migrations/remnashop.md +++ b/docs/migrations/remnashop.md @@ -1,6 +1,6 @@ # Миграция из Remnashop -Remnashop импортируется через общий legacy-importer `backend/scripts/import_legacy.py`. +Remnashop импортируется через общий скрипт импорта `backend/scripts/import_legacy.py`. Самый удобный путь - интерактивный install wizard: ```bash @@ -8,8 +8,8 @@ curl -fsSL https://raw.githubusercontent.com/3252a8/remnawave-minishop/main/scri sh install.sh ``` -В меню выберите `Install new stack and run legacy migration` для нового сервера -или `Run legacy migration only`, если compose-папка и `.env` уже готовы. +В меню выберите `Install new stack and run migration` для нового сервера +или `Run migration only`, если compose-папка и `.env` уже готовы. ## Что переносится @@ -21,7 +21,7 @@ sh install.sh - служебные mappings, чтобы повторный запуск мог работать в режиме `merge`; - настройки совместимости Remnashop в админке: старые ref-ссылки и promo codes. -Данные, которые не имеют прямого аналога, сохраняются в legacy mappings или +Данные, которые не имеют прямого аналога, сохраняются в служебных таблицах миграции или message logs как заметки, чтобы администратор мог проверить их после переноса. ## Flow wizard diff --git a/docs/migrations/remnawave-tg-shop.md b/docs/migrations/remnawave-tg-shop.md index 789fa71..640336f 100644 --- a/docs/migrations/remnawave-tg-shop.md +++ b/docs/migrations/remnawave-tg-shop.md @@ -1,117 +1,60 @@ -# Миграция с `remnawave-tg-shop` (≤ v2.7.0) на `remnawave-minishop` (v3.4+) +# Миграция с `remnawave-tg-shop` на `remnawave-minishop` -Эта страница - готовый сценарий для legacy-стека `remnawave-tg-shop`. Это единственная миграция с другого бота, которая сейчас описана в документации. Для других Telegram-ботов, самописных панелей и ручных таблиц готового сценария пока нет: их нельзя переносить по этой инструкции без отдельного анализа схемы БД, тарифов, платежей и связи с Remnawave Panel. - -Автоматический скрипт ниже рассчитан именно на родственный стек `remnawave-tg-shop`, где структура БД и Docker volumes известны заранее. Для других ботов нужен отдельный адаптер экспорта/импорта. - -## Новый install wizard - -Для нового сервера или переноса без клонирования репозитория используйте общий -`sh` wizard: +Для переноса со старого родственного стека используйте общий install wizard: ```bash curl -fsSL https://raw.githubusercontent.com/3252a8/remnawave-minishop/main/scripts/install.sh -o install.sh sh install.sh ``` -В меню выберите `Install new stack and run legacy migration` или -`Run legacy migration only`, затем источник `Old remnawave-tg-shop`. -Wizard поддерживает два режима: +В меню выберите `Install new stack and run migration` для нового +сервера или `Run migration only`, если compose-папка уже готова. Затем +выберите источник `Old remnawave-tg-shop`. -- `Copy old Docker volumes` - тот же безопасный сценарий, что и старый - `migrate_to_minishop.sh`: старый volume `remnawave-tg-shop-db-data` - копируется в `remnawave-minishop-db-data`, затем новый stack запускает - сервис `migrate` и накатывает все схемные миграции; -- `Dump from a source PostgreSQL DSN` - новый режим для случаев, когда старая - БД доступна как внешний PostgreSQL DSN. Wizard поднимает целевой `postgres`, - сбрасывает целевую БД, делает `pg_dump` из старой БД, восстанавливает дамп в - compose-БД и затем запускает `migrate`. +Wizard поддерживает два способа переноса: -Старый helper `scripts/migrate_to_minishop.sh` ниже всё ещё полезен для -in-place обновления уже клонированного репозитория: он переключает git-ветку, -переносит volumes и стартует новый stack. Прямой source DSN он не поддерживал. +- `Copy old Docker volumes` - для старого compose-стека на том же Docker host. + Скрипт подготавливает новый stack, копирует + `remnawave-tg-shop-db-data` в `remnawave-minishop-db-data`, опционально + переносит Caddy volumes и запускает новый stack. +- `Dump from a source PostgreSQL DSN` - для старой БД, доступной по DSN. + Скрипт поднимает целевой `postgres`, сбрасывает целевую БД, делает + `pg_dump` из старой БД, восстанавливает дамп в compose-БД и запускает + сервис `migrate`. -## Короткий путь без смены ветки и сборки +В обоих режимах старые volumes и старая БД не удаляются автоматически. -Если вы используете только готовые Docker-образы и не собираете проект -локально, git-команды из ручного способа не нужны. Достаточно обновить -compose-файл до одного из готовых примеров в `deploy/examples` и -перенести/обновить БД. Самый прямой вариант без встроенного обратного прокси - -`deploy/examples/no-proxy/docker-compose.yml`; для Caddy, Nginx и Newt есть -такие же самостоятельные папки. +## Как работает перенос -Минимальная последовательность: +`remnawave-tg-shop` и `remnawave-minishop` имеют совместимую историю схемы. +После переноса старой PostgreSQL-БД сервис `migrate` накатывает недостающие +миграции из `backend/db/migrator.py`: сначала применяются `Base.metadata`, +затем последовательные записи `schema_migrations`. Это one-shot сервис: он +должен завершиться с кодом `0`, после чего стартуют `backend` и `worker`. -```bash -docker compose down +При volume-миграции wizard: -# Скопируйте старый .env в выбранную папку примера и обновите значения там. -cp .env deploy/examples/no-proxy/.env -nano deploy/examples/no-proxy/.env +1. Останавливает известные контейнеры старого и переходного стеков, если вы + подтверждаете этот шаг. +2. Запускает `docker compose up --no-start`, чтобы Docker Compose создал новые + volumes. +3. Копирует старый volume БД: -# Подготовьте стек из готовых образов. -IMAGE_TAG=3.4.0 docker compose \ - --env-file deploy/examples/no-proxy/.env \ - -f deploy/examples/no-proxy/docker-compose.yml \ - up --no-start + ```bash + docker run --rm \ + -v remnawave-tg-shop-db-data:/from:ro \ + -v remnawave-minishop-db-data:/to \ + alpine sh -c "cd /from && cp -a . /to" + ``` -# Нужно только при переходе со старого имени volume remnawave-tg-shop-db-data. -# Если у вас уже есть remnawave-minishop-db-data, этот шаг пропустите. -docker run --rm \ - -v remnawave-tg-shop-db-data:/from:ro \ - -v remnawave-minishop-db-data:/to \ - alpine sh -c "cd /from && cp -a . /to" +4. Если старые Caddy volumes существуют, переносит + `remnawave-tg-shop-caddy-data` -> `remnawave-minishop-caddy-data` и + `remnawave-tg-shop-caddy-config` -> `remnawave-minishop-caddy-config`. +5. Запускает новый stack через Docker Compose. -IMAGE_TAG=3.4.0 docker compose \ - --env-file deploy/examples/no-proxy/.env \ - -f deploy/examples/no-proxy/docker-compose.yml \ - up -d -docker compose \ - --env-file deploy/examples/no-proxy/.env \ - -f deploy/examples/no-proxy/docker-compose.yml \ - logs migrate -``` - -Сервис `migrate` сам применит недостающие схемные миграции к перенесённому -тому PostgreSQL. Новые тома `remnawave-minishop-redis-data` и -`remnawave-minishop-shop-data` переносить не нужно: они создаются пустыми. - -Этот документ описывает обновление стека, поднятого по `remnawave-tg-shop` -(включая последний релиз `v2.7.0` форка `kavore/remnawave-tg-shop`), до -текущей версии `remnawave-minishop` (v3.4+). Между этими версиями произошли -две независимые перетряски, и скрипт пытается отработать обе одной командой: - -1. **Переименование стека** (v3.1.0): контейнеры и тома `remnawave-tg-shop-*` - стали `remnawave-minishop-*`. Простой `docker compose up -d` после - `git pull` создаёт пустую БД — без переноса тома данные теряются. -2. **Разделение бота на сервисы** (v3.4.0): из одного контейнера выделены - `backend`, `worker`, `frontend`, `migrate` + новые `postgres`, `redis`. - Появились новые volumes `redis-data` и `shop-data`, новые обязательные - переменные окружения, а схема БД обновляется автоматически one-shot - сервисом `migrate`. - -После миграции `docker compose ps` должен показать как минимум: `backend`, -`worker`, `frontend`, `postgres`, `redis` (running) и `migrate` (exited 0). -Логи: `docker compose logs -f backend worker frontend`. - -Доступные пути: - -- [Автоматический](#автоматический-способ-через-скрипт) — скрипт-обёртка - останавливает старый стек, накатывает свежий код, переносит том БД, - поднимает новые сервисы. Идемпотентный. -- [Ручной](#ручной-способ) — те же шаги командами, для тех, кому нужно - понимать каждое действие или выполнить выборочно. - -В обоих случаях: - -- старые тома **не удаляются** автоматически — это безопасный бэкап на случай - отката; -- сертификаты Caddy (если используется `deploy/examples/caddy/docker-compose.yml`) - тоже переносятся, чтобы Let's Encrypt не выписывал их заново и не упереться - в rate limit; -- схема БД обновляется автоматически: при первом `docker compose up -d` сервис - `migrate` накатывает на перенесённый том все недостающие миграции (от - alembic-схемы v2.7.0 до текущей). +Если целевой DB volume уже непустой, wizard не перетирает его молча: он +останавливается и просит отдельное подтверждение на продолжение без копирования +старой БД. ## Что меняется в архитектуре @@ -120,256 +63,55 @@ docker compose \ | Версия | Сервисы | | --- | --- | | `v2.7.0` | `remnawave-tg-shop`, `remnawave-tg-shop-db` | -| `v3.1.x–v3.3.x` | `remnawave-minishop`, `remnawave-minishop-db` | -| `v3.4+` (текущая) | `remnawave-minishop-backend`, `remnawave-minishop-worker`, `remnawave-minishop-frontend`, `remnawave-minishop-migrate`, `remnawave-minishop-postgres`, `remnawave-minishop-redis` | - -Внутри Docker-сети сервисы доступны по коротким DNS-именам (`backend`, `worker`, -`frontend`, `postgres`, `redis`), а не по полному `container_name`. Это важно -для внешнего reverse-proxy — см. раздел [Внешний reverse-proxy](#внешний-reverse-proxy) ниже. +| `v3.1.x-v3.3.x` | `remnawave-minishop`, `remnawave-minishop-db` | +| `v3.4+` | `remnawave-minishop-backend`, `remnawave-minishop-worker`, `remnawave-minishop-frontend`, `remnawave-minishop-migrate`, `remnawave-minishop-postgres`, `remnawave-minishop-redis` | **Volumes**: -| Volume | v2.7.0 | v3.4+ | Что внутри | -| --- | --- | --- | --- | -| `remnawave-minishop-db-data` | переименовать из `remnawave-tg-shop-db-data` | переносится скриптом | PostgreSQL | -| `remnawave-minishop-redis-data` | — | создаётся пустым | Redis (FSM, rate-limit, cache, очередь вебхуков, distributed locks) | -| `remnawave-minishop-shop-data` | — | создаётся пустым | `/app/data`: `tariffs.json`, темы Web App, кэш логотипа/emoji | -| `remnawave-minishop-caddy-data` / `remnawave-minishop-caddy-config` | переименовать из `remnawave-tg-shop-caddy-*` | переносится скриптом | только при Caddy-варианте | +| Volume | Что происходит | +| --- | --- | +| `remnawave-minishop-db-data` | переносится из `remnawave-tg-shop-db-data` или восстанавливается из source DSN | +| `remnawave-minishop-redis-data` | создается пустым | +| `remnawave-minishop-shop-data` | создается пустым; runtime-файлы в `/app/data` дальше настраиваются через админку или вручную | +| `remnawave-minishop-caddy-data` / `remnawave-minishop-caddy-config` | переносятся из `remnawave-tg-shop-caddy-*`, если старый стек использовал Caddy | -`redis-data` и `shop-data` стартуют пустыми — это нормально. Redis ничего -долгоживущего не хранит (всё либо FSM, либо кеш с TTL), а `data/` инициализируется -из образа при первом старте (`tariffs.json` пуст пока вы не сконфигурируете -тарифы через админ-панель). +Доступные compose-профили: `docker-compose.yml`, +`deploy/examples/caddy/docker-compose.yml`, +`deploy/examples/nginx/docker-compose.yml`, +`deploy/examples/newt/docker-compose.yml`, +`deploy/examples/no-proxy/docker-compose.yml`. -## Переменные окружения, которые могли исчезнуть или переехать +## Переменные окружения -Перед запуском нового стека проверьте `.env`. Ниже — только то, что точно -менялось между v2.7.0 и v3.4+: +Перед запуском нового стека проверьте `.env`. Самые важные изменения: -| Было (v2.7.0) | Стало (v3.4+) | Действие | +| Было | Стало | Действие | | --- | --- | --- | -| `TELEGRAM_WEBHOOK_SECRET` | `WEBHOOK_SECRET_TOKEN` | Переименовать. Если пусто — будет сгенерирован при старте, но тогда Telegram переустановит webhook (на это не реагирует существующий запрос). | -| `TELEGRAM_WEBHOOK_PATH` | удалена | Путь вебхука теперь генерируется из `BOT_TOKEN` автоматически. | -| `REQUIRED_CHANNEL_SUBSCRIBE_TO_USE` | удалена | Гейт включается автоматически, как только задан `REQUIRED_CHANNEL_ID`. | -| `STARS_PROVIDER_TOKEN` | удалена | Telegram Stars (XTR) используются напрямую. | -| `REFERRAL_ENABLED` | удалена | Реферальная программа активна по умолчанию. В legacy-режиме без JSON-каталога отключайте платежные бонусы через нули в `REFERRAL_BONUS_DAYS_*` и `REFEREE_BONUS_DAYS_*`; в JSON-тарифах обнуляйте или удаляйте `referral_bonus_days_inviter` и `referral_bonus_days_referee` у period-тарифов. | -| `POSTGRES_HOST=remnawave-tg-shop-db` | в `.env` — `remnawave-minishop-db` или пусто | Под compose значение всё равно переопределяется на сервисное имя `postgres` (см. `environment:` в compose-файлах), поэтому скрипт правит `.env` только для bare-metal сценариев. | -| `WEBHOOK_BASE_URL` | **обязательна** | Polling-режим удалён, без публичного URL бот не стартует. | -| — | `REDIS_URL=redis://redis:6379/0` | Обязательна для воркера, очередей и rate-limit. По умолчанию в compose-файлах уже задана. | -| — | `WEBAPP_SESSION_SECRET`, `WEBAPP_ENABLED`, `WEBAPP_SERVER_PORT`, `WEBAPP_THEMES_DIR`, `TARIFFS_CONFIG_PATH` | Новые настройки Web App / тарифного каталога. Безопасные дефолты есть в `.env.example`. | +| `TELEGRAM_WEBHOOK_SECRET` | `WEBHOOK_SECRET_TOKEN` | Перенести значение или сгенерировать новый stable secret. | +| `TELEGRAM_WEBHOOK_PATH` | удалена | Путь вебхука теперь рассчитывается автоматически. | +| `REQUIRED_CHANNEL_SUBSCRIBE_TO_USE` | удалена | Гейт включается, когда задан `REQUIRED_CHANNEL_ID`. | +| `STARS_PROVIDER_TOKEN` | удалена | Telegram Stars используются напрямую. | +| `POSTGRES_HOST=remnawave-tg-shop-db` | `postgres` внутри Compose | В compose-файлах `POSTGRES_HOST` переопределяется service name `postgres`. | +| `WEBHOOK_BASE_URL` | обязательна | Без публичного URL backend не стартует корректно. | +| - | `REDIS_URL=redis://redis:6379/0` | В compose-профилях задано автоматически. | +| - | `WEBAPP_SESSION_SECRET`, `WEBAPP_ENABLED`, `TARIFFS_CONFIG_PATH` | Новые настройки Web App и каталога тарифов. | -Полный референс — [docs/getting-started/configuration.md](../getting-started/configuration.md). Скрипт миграции -эти переменные **не правит** автоматически (только `POSTGRES_HOST`), потому -что у каждой инсталляции свой шаблон `.env` с кастомными значениями. Лучше -сравнить свой `.env` с `.env.example` глазами один раз, чем получить -несовместимый шаблон автоматом. +Остальные продуктовые настройки удобнее проверить после первого входа в +админку. -## Автоматический способ (через скрипт) +## Reverse Proxy -Если helper ещё не лежит у вас локально, запускайте его прямо из `raw` из -корня старого репозитория: +В старом стеке часто был один upstream `remnawave-tg-shop:8000`. В текущем +split-arch stack маршруты разделены: -```bash -bash <(curl -fsSL https://raw.githubusercontent.com/3252a8/remnawave-minishop/main/scripts/migrate_to_minishop.sh) -``` - -> Команда выше рассчитана на `bash` / Git Bash / WSL. Если вы запускаете из -> PowerShell, удобнее сначала открыть Git Bash. - -Если вы уже подтянули новую версию и файл есть локально, можно запускать так: - -```bash -bash scripts/migrate_to_minishop.sh -``` - -По умолчанию скрипт работает с `docker-compose.yml` и переключается на ветку -`main`. Можно переопределить через переменные окружения: - -| Переменная | Назначение | По умолчанию | -| ----------------- | ----------------------------------------------------------------------- | ---------------------- | -| `PROJECT_ROOT` | Явный путь к корню старого репозитория, если запуск не из него | текущая директория | -| `COMPOSE_FILE` | Какой compose-файл стартовать в конце | `docker-compose.yml` | -| `TARGET_BRANCH` | На какую ветку переключаться и подтягивать обновления | `main` | -| `GIT_REMOTE` | Какой remote использовать для `fetch`/`pull` | `origin` | -| `NEW_ORIGIN_URL` | Если задано и не совпадает с URL выбранного remote — он будет обновлён | (не меняется) | -| `ASSUME_YES` | `1` — не задавать интерактивных вопросов | `0` | - -Примеры: - -```bash -# Caddy-вариант из raw-файла. -# Перед запуском скопируйте старый .env в deploy/examples/caddy/.env -# и заполните WEBHOOK_HOST / MINIAPP_HOST. -COMPOSE_FILE=deploy/examples/caddy/docker-compose.yml \ - bash <(curl -fsSL https://raw.githubusercontent.com/3252a8/remnawave-minishop/main/scripts/migrate_to_minishop.sh) - -# С переключением origin на форк 3252a8 -NEW_ORIGIN_URL=https://github.com/3252a8/remnawave-minishop.git \ - bash <(curl -fsSL https://raw.githubusercontent.com/3252a8/remnawave-minishop/main/scripts/migrate_to_minishop.sh) - -# Без интерактива -ASSUME_YES=1 \ - bash <(curl -fsSL https://raw.githubusercontent.com/3252a8/remnawave-minishop/main/scripts/migrate_to_minishop.sh) -``` - -Что делает скрипт: - -1. **Останавливает текущий стек**: ищет известные контейнеры старой схемы - (`remnawave-tg-shop`, `…-db`, `…-caddy`), переходного периода - (`remnawave-minishop`, `…-db`, `…-caddy`) и новой схемы - (`…-backend`, `…-worker`, `…-frontend`, `…-migrate`, `…-postgres`, `…-redis`) - и останавливает их, если запущены. Безопасно при повторном запуске. -2. **Переключает `origin`**, если задана переменная `NEW_ORIGIN_URL`, иначе - оставляет как есть. -3. **Подтягивает целевую ветку** (`git fetch` + `git switch` + `git pull --ff-only`). - Прерывается, если в рабочем дереве есть незакоммиченные изменения. -4. **Правит `POSTGRES_HOST` в `.env`** (только для bare-metal сценариев — в - compose это значение перебивает `environment:` блок). -5. **Подготавливает новый стек в режиме `--no-start`**, чтобы Compose сам - создал тома `db-data`, `redis-data`, `shop-data` и не ругался на уже - существующий volume. -6. **Переносит том БД** `remnawave-tg-shop-db-data` → `remnawave-minishop-db-data` - (и Caddy-тома, если применимо) через одноразовый `alpine`-контейнер. Если - новый том уже непустой — копирование пропускается. Новые volumes - `redis-data` и `shop-data` остаются пустыми (их и не должно быть в старом - стеке). -7. **Стартует новый стек** (`docker compose up -d --remove-orphans` плюс - `--build` для локальной сборки). `migrate` отработает первым, накатит - на перенесённый том все недостающие миграции (от alembic-схемы v2.7.0 до - текущей) и завершится. Затем стартуют `backend`, `worker`, `frontend`. - -Скрипт идемпотентен: повторный запуск ничего не сломает, просто пропустит уже -выполненные шаги. - -После того как убедитесь, что бот работает и данные на месте, удалите старые -тома: - -```bash -docker volume rm remnawave-tg-shop-db-data -docker volume rm remnawave-tg-shop-caddy-data remnawave-tg-shop-caddy-config 2>/dev/null || true -``` - -## Ручной способ - -1. **Остановите старый стек и обновите код:** - - ```bash - docker compose down - git fetch origin - git checkout main - git pull --ff-only origin main - ``` - -2. **(Только для bare-metal без compose)** обновите `.env`, если в нём ещё - жёстко прописан старый контейнер БД: - - ```bash - sed -i.bak 's/^POSTGRES_HOST=remnawave-tg-shop-db$/POSTGRES_HOST=remnawave-minishop-db/' .env - ``` - - Под `docker compose up` это не нужно: compose сам выставляет - `POSTGRES_HOST: postgres` (имя сервиса) в `environment:` и `.env`-значение - не используется. - -3. **Проверьте `.env`** на наличие переменных, которые исчезли или - переименовались — см. раздел - [Переменные окружения](#переменные-окружения-которые-могли-исчезнуть-или-переехать) - выше. Главное: `WEBHOOK_SECRET_TOKEN` (бывший `TELEGRAM_WEBHOOK_SECRET`), - обязательный `WEBHOOK_BASE_URL` и наличие `REDIS_URL` (по умолчанию задано - в compose). - -4. **Подготовьте новый стек без запуска**, чтобы Compose создал новые volumes - (`db-data`, `redis-data`, `shop-data`) и контейнеры: - - ```bash - # Локальная сборка - docker compose up --no-start --build - - # Или готовый Caddy-вариант из GHCR-образов - cp .env deploy/examples/caddy/.env - nano deploy/examples/caddy/.env - docker compose \ - --env-file deploy/examples/caddy/.env \ - -f deploy/examples/caddy/docker-compose.yml \ - up --no-start - - # Другие готовые варианты: - # deploy/examples/nginx/docker-compose.yml - # deploy/examples/newt/docker-compose.yml - # deploy/examples/no-proxy/docker-compose.yml - ``` - -5. **Перенесите том БД в новое имя:** - - ```bash - docker run --rm \ - -v remnawave-tg-shop-db-data:/from:ro \ - -v remnawave-minishop-db-data:/to \ - alpine sh -c "cd /from && cp -a . /to" - ``` - - `remnawave-minishop-redis-data` и `remnawave-minishop-shop-data` — новые, - переносить нечего. Они инициализируются на лету: Redis пуст, а `data/` - наполняется при первом обращении к настройкам Web App / каталогу тарифов. - -6. **(Только для Caddy)** перенесите тома Caddy с TLS-сертификатами и - состоянием ACME: - - ```bash - for v in caddy-data caddy-config; do - docker run --rm \ - -v "remnawave-tg-shop-$v":/from:ro \ - -v "remnawave-minishop-$v":/to \ - alpine sh -c "cd /from && cp -a . /to" - done - ``` - -7. **Запустите новый стек:** - - ```bash - docker compose up -d - # или - docker compose \ - --env-file deploy/examples/caddy/.env \ - -f deploy/examples/caddy/docker-compose.yml \ - up -d - ``` - - Сервис `migrate` запустится первым, обнаружит перенесённый том, - применит недостающие схемные миграции (`Base.metadata.create_all` + - последовательные миграции `0001..00NN` из `backend/db/migrator.py`) и - выйдет с кодом 0. Только после этого стартуют `backend` и `worker`. - -8. **Проверьте состояние:** - - ```bash - docker compose ps - docker compose logs -f backend worker frontend - docker compose logs migrate # должен закончиться "Migrator: migration 00NN applied successfully" - ``` - -9. **(Опционально) удалите старые тома**, когда убедитесь, что новый стек - стабилен: - - ```bash - docker volume rm remnawave-tg-shop-db-data - docker volume rm remnawave-tg-shop-caddy-data remnawave-tg-shop-caddy-config 2>/dev/null || true - ``` - -## Внешний reverse-proxy - -В v2.7.0 был один upstream — `remnawave-tg-shop:8000`. В v3.4+ функциональность -разнесена по портам и сервисам: - -| Назначение | DNS-имя сервиса | Порт | +| Назначение | Service | Port | | --- | --- | --- | -| Telegram / платежные / вебхуки панели | `backend` | `8080` | -| Health-чек | `backend` | `8080` (`/healthz`) | -| Web App API (`/api/*`, `/auth/*`, ассеты тем и логотипов) | `backend` | `8081` (доступен только из Docker-сети) | -| Статический фронт Web App | `frontend` | `80` (внутри `frontend` уже проксирует `/api/*` и `/auth/*` на `backend:8081`) | +| Telegram, платежные и panel webhooks | `backend` | `8080` | +| Health-check | `backend` | `8080` (`/healthz`) | +| Web App API и auth | `backend` | `8081` внутри Docker-сети | +| Статический Web App frontend | `frontend` | `80` | -Минимальная замена для внешнего Nginx, который раньше слал всё на один -upstream: +Минимальная схема для внешнего Nginx: ```nginx upstream remnawave_backend_webhooks { server backend:8080; } @@ -378,33 +120,32 @@ upstream remnawave_frontend { server frontend:80; } server { server_name app.domain.com; listen 443 ssl; - http2 on; - # ssl_certificate / ssl_certificate_key — без изменений location /webhook/ { proxy_pass http://remnawave_backend_webhooks; } location /healthz { proxy_pass http://remnawave_backend_webhooks; } - location / { proxy_pass http://remnawave_frontend; } + location / { proxy_pass http://remnawave_frontend; } } ``` -Полные примеры (Caddy, Nginx, Newt/Pangolin и запуск без обратного прокси) — в -[docs/getting-started/deployment.md](../getting-started/deployment.md) и [docs/features/web-app.md](../features/web-app.md). Если раньше прокси указывал на -`remnawave-tg-shop:8000` напрямую, после миграции нужно либо переключиться на -`backend:8080` / `frontend:80`, либо использовать готовый Caddy/Nginx/Newt -пример, который уже знает правильную маршрутизацию. +Готовые Caddy, Nginx, Pangolin/Newt и no-proxy профили уже содержат нужную +маршрутизацию. -## Если что-то пошло не так +## Проверка -`migrate` упал → читайте `docker compose logs migrate`. Том БД остался -не тронут, можно откатиться, переключив compose-файл обратно на старый -коммит и подняв старый стек на старом томе `remnawave-tg-shop-db-data` -(пока вы его не удалили). +После переноса: -`backend` не стартует → чаще всего `WEBHOOK_BASE_URL` пуст, либо -`WEBHOOK_SECRET_TOKEN` отличается от того, что Telegram ждёт. Поставьте -свежий секрет в `.env` и перезапустите — Telegram переустановит webhook -автоматически. +```bash +docker compose ps +docker compose logs migrate +docker compose logs -f backend worker frontend +``` -Web App пуст / 502 → проверьте, что `frontend` живёт (`docker compose ps`), -а внешний прокси шлёт на `frontend:80`, а не на старый -`remnawave-tg-shop:8000`. +`migrate` должен завершиться успешно, а `backend`, `worker`, `frontend`, +`postgres` и `redis` должны быть running/healthy. + +Когда убедитесь, что новый stack работает, старые volumes можно удалить вручную: + +```bash +docker volume rm remnawave-tg-shop-db-data +docker volume rm remnawave-tg-shop-caddy-data remnawave-tg-shop-caddy-config 2>/dev/null || true +``` diff --git a/scripts/install.sh b/scripts/install.sh index 26274be..95b7835 100644 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -939,7 +939,7 @@ run_remnashop_migration() { if confirm "Restart backend and worker so setting overrides are reloaded?" 1; then (cd "$TARGET_DIR" && run_compose restart backend worker) || true fi - ok "Legacy migration completed." + ok "Migration completed." } run_target_schema_migrations() { @@ -980,11 +980,10 @@ run_tgshop_volume_migration() { run_tgshop_dsn_migration() { section "Old remnawave-tg-shop DSN migration" - warn "The old standalone helper did not support direct DSN import." warn "This wizard path dumps the old PostgreSQL database, restores it into target Compose PostgreSQL, then runs Minishop schema migrations." warn "The target database will be dropped and recreated before restore." - if ! confirm "Replace target database with the legacy dump?" 0; then + if ! confirm "Replace target database with the source dump?" 0; then warn "Migration not applied." return 0 fi @@ -1141,8 +1140,8 @@ main_menu() { banner choose "Main menu" "1" "1|2|3|4|5|6" \ "1. Install new stack" \ - "2. Install new stack and run legacy migration" \ - "3. Run legacy migration only" \ + "2. Install new stack and run migration" \ + "3. Run migration only" \ "4. Download/update deployment files only" \ "5. Validate current stack" \ "6. Exit" diff --git a/scripts/migrate_to_minishop.sh b/scripts/migrate_to_minishop.sh deleted file mode 100644 index b8c8546..0000000 --- a/scripts/migrate_to_minishop.sh +++ /dev/null @@ -1,318 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -ROOT="" - -OLD_PREFIX="remnawave-tg-shop" -NEW_PREFIX="remnawave-minishop" -OLD_DB_VOLUME="${OLD_PREFIX}-db-data" -NEW_DB_VOLUME="${NEW_PREFIX}-db-data" -OLD_CADDY_VOLUMES=("${OLD_PREFIX}-caddy-data" "${OLD_PREFIX}-caddy-config") -NEW_CADDY_VOLUMES=("${NEW_PREFIX}-caddy-data" "${NEW_PREFIX}-caddy-config") -# Container names span three eras: original ``remnawave-tg-shop*`` (≤ v2.7.0), -# the renamed but still single-container ``remnawave-minishop*`` (v3.1.0 – -# v3.3.x), and the split-arch stack introduced in v3.4.0 (backend / worker / -# frontend / migrate / postgres / redis, plus optional caddy). The list is -# used only to stop existing containers before migration, so it is safe — -# and idempotent — to include every known name from every era. -KNOWN_CONTAINERS=( - # v2.7.0 (upstream remnawave-tg-shop): - "${OLD_PREFIX}" - "${OLD_PREFIX}-db" - "${OLD_PREFIX}-caddy" - # v3.1.0 – v3.2.x (renamed but still one container): - "${NEW_PREFIX}" - "${NEW_PREFIX}-db" - "${NEW_PREFIX}-caddy" - # v3.4.0+ (split architecture): - "${NEW_PREFIX}-backend" - "${NEW_PREFIX}-worker" - "${NEW_PREFIX}-frontend" - "${NEW_PREFIX}-migrate" - "${NEW_PREFIX}-postgres" - "${NEW_PREFIX}-redis" -) - -log() { - printf '%s\n' "$*" -} - -die() { - printf 'Ошибка: %s\n' "$*" >&2 - exit 1 -} - -require_cmd() { - command -v "$1" >/dev/null 2>&1 || die "Не найдено обязательное средство \`$1\` в PATH." -} - -resolve_root() { - if [[ -n "${PROJECT_ROOT:-}" ]]; then - [[ -d "$PROJECT_ROOT" ]] || die "PROJECT_ROOT не существует: $PROJECT_ROOT" - (cd -- "$PROJECT_ROOT" >/dev/null && pwd -P) - return - fi - - local git_root - if git_root="$(git rev-parse --show-toplevel 2>/dev/null)"; then - printf '%s\n' "$git_root" - return - fi - - pwd -P -} - -run() { - log "+ $*" - "$@" -} - -container_exists() { - docker inspect "$1" >/dev/null 2>&1 -} - -container_running() { - [[ "$(docker inspect -f '{{.State.Running}}' "$1" 2>/dev/null || true)" == "true" ]] -} - -stop_container() { - local name="$1" - - if ! container_exists "$name"; then - return 1 - fi - - if container_running "$name"; then - docker stop "$name" >/dev/null - fi - docker rm "$name" >/dev/null -} - -volume_exists() { - docker volume inspect "$1" >/dev/null 2>&1 -} - -volume_is_empty() { - docker run --rm -v "$1:/data" alpine sh -c 'test -z "$(find /data -mindepth 1 -print -quit)"' >/dev/null 2>&1 -} - -copy_volume() { - local source="$1" - local target="$2" - - if ! volume_exists "$source"; then - log " - Пропускаю том \`$source\`: исходный том не найден." - return 1 - fi - - if volume_exists "$target" && ! volume_is_empty "$target"; then - log " - Пропускаю том \`$target\`: он уже не пустой." - return 1 - fi - - if ! volume_exists "$target"; then - die "Целевой том \`$target\` не создан Compose. Сначала нужно подготовить новый стек в режиме \`--no-start\`." - fi - - docker run --rm -v "$source:/from:ro" -v "$target:/to" alpine sh -c 'cd /from && cp -a . /to/' -} - -is_old_postgres_host() { - grep -Eq "^[[:space:]]*POSTGRES_HOST[[:space:]]*=[[:space:]]*${OLD_PREFIX}-db[[:space:]]*(#.*)?$" "$ROOT/.env" -} - -is_new_postgres_host() { - grep -Eq "^[[:space:]]*POSTGRES_HOST[[:space:]]*=[[:space:]]*${NEW_PREFIX}-db[[:space:]]*(#.*)?$" "$ROOT/.env" -} - -update_postgres_host() { - if is_old_postgres_host; then - sed -i.bak -E "s|^([[:space:]]*POSTGRES_HOST[[:space:]]*=[[:space:]]*)${OLD_PREFIX}-db([[:space:]]*(#.*)?)$|\\1${NEW_PREFIX}-db\\2|" "$ROOT/.env" - log " - \`.env\` обновлён, резервная копия сохранена в \`.env.bak\`." - elif is_new_postgres_host; then - log " - \`POSTGRES_HOST\` уже указывает на новый контейнер, ничего менять не нужно." - else - log " - \`POSTGRES_HOST\` не похож на старую схему, пропускаю изменение." - fi -} - -main() { - require_cmd git - require_cmd docker - docker info >/dev/null - - ROOT="$(resolve_root)" - - local compose_file="${COMPOSE_FILE:-docker-compose.yml}" - local target_branch="${TARGET_BRANCH:-main}" - local git_remote="${GIT_REMOTE:-origin}" - local new_origin_url="${NEW_ORIGIN_URL:-}" - local assume_yes="${ASSUME_YES:-0}" - local current_origin - local current_branch - local remote_ref - local head_commit - local compose_has_build=0 - local compose_has_caddy=0 - local -a compose_cmd - local -a running_containers=() - local -a summary=() - local -a up_args - local name - local source - local target - local answer - - if [[ $compose_file != /* ]]; then - compose_file="$ROOT/$compose_file" - fi - [[ -f "$compose_file" ]] || die "Compose-файл не найден: $compose_file" - [[ -e "$ROOT/.git" ]] || die "Скрипт нужно запускать из корня git-репозитория." - [[ -f "$ROOT/.env" ]] || die "Не найден \`.env\` в корне репозитория." - - if docker compose version >/dev/null 2>&1; then - compose_cmd=(docker compose) - elif command -v docker-compose >/dev/null 2>&1; then - compose_cmd=(docker-compose) - else - die "Не найден ни \`docker compose\`, ни \`docker-compose\`." - fi - - if [[ -n "$(git -C "$ROOT" status --porcelain=v1)" ]]; then - die "В рабочем дереве есть незакоммиченные изменения. Сначала сохраните их, чтобы миграция не затёрла чужие правки." - fi - - if grep -Eq '^[[:space:]]*build:[[:space:]]*' "$compose_file"; then - compose_has_build=1 - fi - if grep -Eq '^[[:space:]]*caddy:[[:space:]]*$' "$compose_file"; then - compose_has_caddy=1 - fi - - for name in "${KNOWN_CONTAINERS[@]}"; do - if container_exists "$name"; then - running_containers+=("$name") - fi - done - - current_origin="$(git -C "$ROOT" remote get-url "$git_remote")" - if [[ -n "$new_origin_url" && "$current_origin" != "$new_origin_url" ]]; then - summary+=( "обновить $git_remote с \`$current_origin\` на \`$new_origin_url\`" ) - fi - summary+=( "скачать ветку \`$target_branch\` из \`$git_remote\`" ) - if volume_exists "$OLD_DB_VOLUME"; then - summary+=( "проверить том БД \`$OLD_DB_VOLUME\` и перенести в \`$NEW_DB_VOLUME\` при необходимости" ) - fi - if ((compose_has_caddy)); then - for i in 0 1; do - source="${OLD_CADDY_VOLUMES[$i]}" - target="${NEW_CADDY_VOLUMES[$i]}" - if volume_exists "$source"; then - summary+=( "проверить том \`$source\` и перенести в \`$target\` при необходимости" ) - fi - done - fi - if is_old_postgres_host; then - summary+=( "обновить \`POSTGRES_HOST\` в \`.env\`" ) - fi - summary+=( "подготовить новый стек через Compose в режиме \`--no-start\`" ) - summary+=( "запустить compose-файл \`$(basename "$compose_file")\`" ) - - if [[ "$assume_yes" != "1" ]]; then - if [[ ! -t 0 ]]; then - die "Скрипт ожидает интерактивное подтверждение. Запустите с \`ASSUME_YES=1\` для неинтерактивного режима." - fi - log "План миграции:" - for name in "${summary[@]}"; do - log " - $name" - done - read -r -p "Продолжить? [y/N]: " answer - case "$answer" in - y|Y|yes|YES|Yes) - ;; - *) - die "Миграция отменена пользователем." - ;; - esac - fi - - log "1. Останавливаю старый стек" - if ((${#running_containers[@]})); then - for name in "${running_containers[@]}"; do - stop_container "$name" - log " - контейнер \`$name\` остановлен/удалён" - done - else - log " - запущенных контейнеров старой схемы не найдено" - fi - - if [[ -n "$new_origin_url" && "$current_origin" != "$new_origin_url" ]]; then - log "2. Обновляю origin" - run git -C "$ROOT" remote set-url "$git_remote" "$new_origin_url" - else - log "2. Origin уже актуален, пропускаю" - fi - - log "3. Обновляю git до ветки \`$target_branch\`" - run git -C "$ROOT" fetch "$git_remote" "$target_branch" - - current_branch="$(git -C "$ROOT" branch --show-current || true)" - if [[ -z "$current_branch" ]]; then - current_branch="$(git -C "$ROOT" rev-parse --abbrev-ref HEAD)" - fi - - if [[ "$current_branch" != "$target_branch" ]]; then - if git -C "$ROOT" show-ref --verify --quiet "refs/heads/$target_branch"; then - run git -C "$ROOT" switch "$target_branch" - else - run git -C "$ROOT" switch -c "$target_branch" --track "$git_remote/$target_branch" - fi - else - log " - уже на ветке \`$target_branch\`" - fi - - remote_ref="$(git -C "$ROOT" rev-parse "$git_remote/$target_branch")" - head_commit="$(git -C "$ROOT" rev-parse HEAD)" - if [[ "$head_commit" != "$remote_ref" ]]; then - run git -C "$ROOT" pull --ff-only "$git_remote" "$target_branch" - else - log " - локальная ветка уже совпадает с удалённой, \`git pull\` не нужен" - fi - - log "4. Обновляю \`.env\`" - update_postgres_host - - log "5. Подготавливаю новый стек через Compose" - if ((compose_has_build)); then - run "${compose_cmd[@]}" -f "$compose_file" up --no-start --build - else - run "${compose_cmd[@]}" -f "$compose_file" up --no-start - fi - - log "6. Переношу тома" - if copy_volume "$OLD_DB_VOLUME" "$NEW_DB_VOLUME"; then - log " - БД перенесена в \`$NEW_DB_VOLUME\`" - fi - if ((compose_has_caddy)); then - for i in 0 1; do - source="${OLD_CADDY_VOLUMES[$i]}" - target="${NEW_CADDY_VOLUMES[$i]}" - if copy_volume "$source" "$target"; then - log " - \`$source\` перенесён в \`$target\`" - fi - done - fi - - log "7. Запускаю новый стек" - if ((compose_has_build)); then - up_args=(up -d --build --remove-orphans) - else - up_args=(up -d --remove-orphans) - fi - run "${compose_cmd[@]}" -f "$compose_file" "${up_args[@]}" - run "${compose_cmd[@]}" -f "$compose_file" ps - - log "Готово." -} - -main "$@" diff --git a/tests/test_install_script.py b/tests/test_install_script.py index 72a3b56..654627e 100644 --- a/tests/test_install_script.py +++ b/tests/test_install_script.py @@ -37,7 +37,8 @@ def test_shell_installer_downloads_raw_files_and_runs_import_in_container(): assert "git clone" not in script assert "backend python backend/scripts/import_legacy.py" in script assert "--dry-run" in script - assert "Install new stack and run legacy migration" in script + assert "Install new stack and run migration" in script + assert "Run migration only" in script def test_shell_installer_supports_legacy_tgshop_volume_and_dsn_paths(): diff --git a/tests/test_migration_doc_accuracy.py b/tests/test_migration_doc_accuracy.py index 30503c7..5dd79b7 100644 --- a/tests/test_migration_doc_accuracy.py +++ b/tests/test_migration_doc_accuracy.py @@ -1,25 +1,15 @@ -"""Pin facts that ``docs/migrations/remnawave-tg-shop.md`` and -``scripts/migrate_to_minishop.sh`` rely on. - -Both documents are written for a user upgrading from ``remnawave-tg-shop`` -(v2.7.0 era) to the current split-arch ``remnawave-minishop`` (v3.4+). They -make concrete claims about: - -* the set of container names produced by today's compose files; -* the set of volume names produced by today's compose files; -* the eras the migration script is allowed to stop containers from. - -If any of these drift apart from reality, the migration document silently -goes stale. These tests fail loudly instead. -""" +"""Pin facts shared by the migration docs and the unified install wizard.""" import re +import shutil +import subprocess import unittest from pathlib import Path REPO_ROOT = Path(__file__).resolve().parents[1] DOC_PATH = REPO_ROOT / "docs" / "migrations" / "remnawave-tg-shop.md" -SCRIPT_PATH = REPO_ROOT / "scripts" / "migrate_to_minishop.sh" +INSTALL_SCRIPT_PATH = REPO_ROOT / "scripts" / "install.sh" +REMOVED_SCRIPT_PATH = REPO_ROOT / "scripts" / "migrate_to_minishop.sh" COMPOSE_FILES = ( REPO_ROOT / "docker-compose.yml", REPO_ROOT / "deploy" / "examples" / "caddy" / "docker-compose.yml", @@ -28,7 +18,6 @@ COMPOSE_FILES = ( REPO_ROOT / "deploy" / "examples" / "no-proxy" / "docker-compose.yml", ) -# Names that the current architecture must produce in at least one compose file. EXPECTED_CONTAINER_NAMES = { "remnawave-minishop-backend", "remnawave-minishop-worker", @@ -52,15 +41,27 @@ def _all_compose_text() -> str: return "\n".join(_read(path) for path in COMPOSE_FILES if path.is_file()) +def _known_containers_from_install_script() -> set[str]: + text = _read(INSTALL_SCRIPT_PATH) + match = re.search(r'^KNOWN_LEGACY_CONTAINERS="([^"]+)"', text, flags=re.MULTILINE) + if not match: + return set() + return set(match.group(1).split()) + + class MigrationDocumentationFactsTests(unittest.TestCase): def setUp(self) -> None: self.doc = _read(DOC_PATH) - self.script = _read(SCRIPT_PATH) + self.script = _read(INSTALL_SCRIPT_PATH) self.compose = _all_compose_text() + def test_unified_install_script_is_the_only_migration_entrypoint(self): + self.assertTrue(INSTALL_SCRIPT_PATH.is_file()) + self.assertFalse(REMOVED_SCRIPT_PATH.exists()) + self.assertIn("scripts/install.sh", self.doc) + self.assertNotIn("migrate_to_minishop.sh", self.doc) + def test_doc_lists_every_running_container_in_current_compose(self): - """The architecture table must reflect what ``docker compose up`` - actually produces today.""" missing = sorted(name for name in EXPECTED_CONTAINER_NAMES if name not in self.doc) self.assertFalse( missing, @@ -77,152 +78,94 @@ class MigrationDocumentationFactsTests(unittest.TestCase): ) def test_doc_warns_about_renamed_telegram_webhook_secret(self): - # This is the single rename most likely to bite a v2.7.0 → HEAD user. self.assertIn("TELEGRAM_WEBHOOK_SECRET", self.doc) self.assertIn("WEBHOOK_SECRET_TOKEN", self.doc) def test_doc_says_webhook_base_url_is_required(self): - # Polling mode was dropped — without WEBHOOK_BASE_URL the bot refuses - # to start. A user migrating from v2.7.0 (where it was optional) must - # be told this explicitly. block = self.doc.lower() self.assertIn("webhook_base_url", block) - # "обязательна" is the marker text in the env-vars table. self.assertIn("обязательн", block) def test_doc_mentions_migrate_one_shot_service(self): - # The migrate sidecar is what makes schema migrations transparent on - # the second-stage upgrade. Don't bury it. - self.assertIn("migrate", self.doc) - # "one-shot" or "разовый" / "однораз" text variants accepted. normalized = self.doc.lower() + self.assertIn("migrate", normalized) self.assertTrue( "one-shot" in normalized or "однораз" in normalized, "migration doc must describe `migrate` as a one-shot service", ) def test_doc_mentions_postgres_host_compose_override_caveat(self): - # Otherwise users follow the sed-fix step blindly and then panic - # because their .env still has the "wrong" hostname under compose. self.assertIn("POSTGRES_HOST", self.doc) - # Russian: "переопределя…" / "перебивает" indicate the override is documented. text = self.doc.lower() self.assertTrue( - "переопредел" in text or "перебивает" in text, + "переопредел" in text or "service name" in text, "migration doc must explain that compose overrides POSTGRES_HOST", ) def test_doc_describes_redis_data_and_shop_data_as_fresh(self): - # We do not migrate redis-data or shop-data — make sure that's said - # so users don't try to copy them from the old stack. text = self.doc.lower() self.assertIn("redis-data", text) self.assertIn("shop-data", text) - # Some phrasing variant must say it's empty / fresh / new on purpose. - self.assertTrue( - any(marker in text for marker in ("пустым", "создаётся пуст", "новые,", "пуст —")) - ) + self.assertTrue(any(marker in text for marker in ("пустым", "создается пуст"))) def test_doc_explains_reverse_proxy_no_longer_single_upstream(self): - # The note "rename remnawave-tg-shop → remnawave-minishop in your - # proxy config" used to be enough; after the split it's wrong. - # Make sure both backend:8080 and frontend:80 are documented. self.assertIn("backend:8080", self.doc) self.assertIn("frontend:80", self.doc) - -def _known_containers_from_script() -> set[str]: - """Parse the literal ``KNOWN_CONTAINERS`` array from the script source. - - The array is defined with ``${OLD_PREFIX}`` / ``${NEW_PREFIX}`` placeholders - that we substitute here. Doing this statically (rather than sourcing the - script in bash) avoids running ``main`` and keeps the test independent of - a bash interpreter being available at runtime. - """ - text = _read(SCRIPT_PATH) - - prefix_match = re.search(r'^OLD_PREFIX="([^"]+)"', text, flags=re.MULTILINE) - new_match = re.search(r'^NEW_PREFIX="([^"]+)"', text, flags=re.MULTILINE) - if not prefix_match or not new_match: - return set() - old_prefix = prefix_match.group(1) - new_prefix = new_match.group(1) - - # ``\n)`` as a boundary: a non-greedy ``.*?\)`` would stop at the first - # close-paren in a comment like ``# (v2.7.0 upstream remnawave-tg-shop)``. - array_match = re.search( - r"KNOWN_CONTAINERS=\((.*?)\n\)", - text, - flags=re.DOTALL, - ) - if not array_match: - return set() - - body = array_match.group(1) - # Strip line comments and quotes, then expand the two placeholders. - names: set[str] = set() - for raw_line in body.splitlines(): - line = raw_line.split("#", 1)[0].strip() - if not line: - continue - for token in re.findall(r'"([^"]+)"', line): - expanded = token.replace("${OLD_PREFIX}", old_prefix).replace( - "${NEW_PREFIX}", new_prefix - ) - names.add(expanded) - return names + def test_doc_mentions_both_supported_tgshop_migration_methods(self): + self.assertIn("Copy old Docker volumes", self.doc) + self.assertIn("Dump from a source PostgreSQL DSN", self.doc) + self.assertIn("pg_dump", self.doc) -class MigrationScriptCoverageTests(unittest.TestCase): +class InstallWizardCoverageTests(unittest.TestCase): def setUp(self) -> None: - self.script = _read(SCRIPT_PATH) - self.known = _known_containers_from_script() + self.script = _read(INSTALL_SCRIPT_PATH) + self.known = _known_containers_from_install_script() def test_known_containers_covers_split_arch(self): - """A re-run on a partially migrated stack must be able to stop the new - containers, otherwise ``docker compose up`` later fails with name - conflicts.""" missing = sorted(EXPECTED_CONTAINER_NAMES - self.known) self.assertFalse( missing, - f"KNOWN_CONTAINERS missing split-arch entries: {missing}\nactual: {sorted(self.known)}", + f"KNOWN_LEGACY_CONTAINERS missing split-arch entries: {missing}", ) - def test_known_containers_still_covers_legacy_eras(self): - # We must also keep stopping the original (v2.7.0) and intermediate - # (v3.1.x – v3.2.x) container names. + def test_known_containers_still_covers_old_eras(self): for legacy in ("remnawave-tg-shop", "remnawave-tg-shop-db", "remnawave-minishop-db"): with self.subTest(container=legacy): self.assertIn(legacy, self.known) - def test_script_is_syntactically_valid_bash(self): - # The script is curl|bash'ed from raw.githubusercontent in the docs, - # so a syntax break is a hard regression. - import shutil - import subprocess + def test_installer_contains_tgshop_volume_and_dsn_paths(self): + self.assertIn("run_tgshop_volume_migration", self.script) + self.assertIn("run_tgshop_dsn_migration", self.script) + self.assertIn("remnawave-tg-shop-db-data", self.script) + self.assertIn("pg_dump --clean --if-exists", self.script) + self.assertIn("run_compose run --rm migrate", self.script) - bash = shutil.which("bash") - if not bash: # pragma: no cover - self.skipTest("bash not available in PATH") + def test_script_is_syntactically_valid_sh_and_bash(self): + sh = shutil.which("sh") + if not sh: # pragma: no cover + self.skipTest("sh not available in PATH") result = subprocess.run( - [bash, "-n", str(SCRIPT_PATH)], + [sh, "-n", str(INSTALL_SCRIPT_PATH)], check=False, capture_output=True, text=True, ) - self.assertEqual( - result.returncode, - 0, - ( - "bash -n flagged migrate_to_minishop.sh:\n" - f"stdout={result.stdout}\nstderr={result.stderr}" - ), - ) + self.assertEqual(result.returncode, 0, result.stderr) + + bash = shutil.which("bash") + if bash: + result = subprocess.run( + [bash, "-n", str(INSTALL_SCRIPT_PATH)], + check=False, + capture_output=True, + text=True, + ) + self.assertEqual(result.returncode, 0, result.stderr) class DocComposeFileReferencesTests(unittest.TestCase): - """The doc links the user to specific compose files — they must exist.""" - def test_referenced_compose_files_exist(self): doc = _read(DOC_PATH) for relpath in ( @@ -234,40 +177,22 @@ class DocComposeFileReferencesTests(unittest.TestCase): ): with self.subTest(path=relpath): self.assertIn(relpath, doc) - self.assertTrue( - (REPO_ROOT / relpath).is_file(), - f"{relpath} is referenced in migrations/remnawave-tg-shop.md " - "but missing on disk", - ) + self.assertTrue((REPO_ROOT / relpath).is_file()) def test_doc_references_migrator_module_path(self): - # The doc tells users to expect ``backend/db/migrator.py`` migrations - # to apply via the migrate service. If the file moves, the doc lies. doc = _read(DOC_PATH) self.assertIn("backend/db/migrator.py", doc) self.assertTrue((REPO_ROOT / "backend" / "db" / "migrator.py").is_file()) class MigrationFootprintRegexTests(unittest.TestCase): - """Spot-check that what compose actually defines matches what we documented.""" - def test_every_compose_volume_documented(self): - """If a future compose file introduces a new ``remnawave-minishop-*`` - named volume, the migration doc must call out whether it carries data - from the old stack or starts fresh.""" doc = _read(DOC_PATH) compose_text = _all_compose_text() - # Find every named volume of the form ``remnawave-minishop--data``. defined = set(re.findall(r"remnawave-minishop-[\w-]+-data", compose_text)) - # Caddy-only volumes only ship in the caddy compose file but are still - # documented; allow them either way. for volume in defined: with self.subTest(volume=volume): - self.assertIn( - volume, - doc, - f"new volume {volume} is defined in compose but missing from migration doc", - ) + self.assertIn(volume, doc) if __name__ == "__main__": # pragma: no cover From 9508eece5f5e3cacf8b1c8cb1372139fd0f59357 Mon Sep 17 00:00:00 2001 From: 3252a8 <3252a8@proton.me> Date: Tue, 2 Jun 2026 11:21:26 +0300 Subject: [PATCH 10/58] feat: import Remnashop payment settings --- backend/scripts/import_legacy.py | 596 ++++++++++++++++++++++++++- docs/getting-started/deployment.md | 6 +- docs/migrations/index.md | 2 +- docs/migrations/remnashop.md | 54 ++- scripts/install.sh | 64 ++- tests/test_install_script.py | 18 + tests/test_migration_doc_accuracy.py | 26 ++ tests/test_remnashop_import.py | 168 ++++++++ 8 files changed, 926 insertions(+), 8 deletions(-) diff --git a/backend/scripts/import_legacy.py b/backend/scripts/import_legacy.py index b2fede7..df3b0ed 100644 --- a/backend/scripts/import_legacy.py +++ b/backend/scripts/import_legacy.py @@ -17,6 +17,7 @@ import asyncio import json import logging import re +import shlex import sys from collections import defaultdict from datetime import datetime, timedelta, timezone @@ -53,13 +54,47 @@ from db.models import ( # noqa: E402 User, ) +try: # cryptography is already used by the app for payment webhook validation. + from cryptography.fernet import Fernet +except Exception: # pragma: no cover - defensive fallback for minimal tooling. + Fernet = None # type: ignore[assignment] + SOURCE = "remnashop" +REMNASHOP_ENCRYPTED_PREFIX = "enc_" GIB = 1024**3 UUID_RE = re.compile( r"\b[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-" r"[0-9a-fA-F]{4}-[0-9a-fA-F]{12}\b" ) SAFE_SCHEMA_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$") +REMNASHOP_PAYMENT_WEBHOOK_PATH = "/api/v1/payments/{gateway}" +REMNASHOP_PANEL_WEBHOOK_PATH = "/api/v1/remnawave" + +SUPPORTED_REMNASHOP_PROVIDER_TYPES = { + "TELEGRAM_STARS", + "YOOKASSA", + "HELEKET", + "CRYPTOPAY", + "FREEKASSA", + "PLATEGA", + "WATA", +} +UNSUPPORTED_REMNASHOP_PROVIDER_TYPES = { + "YOOMONEY", + "CRYPTOMUS", + "MULENPAY", + "PAYMASTER", + "ROBOKASSA", + "URLPAY", +} +PAYMENT_WEBHOOK_PATHS = { + "yookassa": "/webhook/yookassa", + "wata": "/webhook/wata", + "cryptopay": "/webhook/cryptopay", + "heleket": "/webhook/heleket", + "freekassa": "/webhook/freekassa", + "platega": "/webhook/platega", +} logger = logging.getLogger(__name__) @@ -160,6 +195,365 @@ def _jsonish(value: Any) -> dict[str, Any]: return {} +def _strip_env_value(value: str) -> str: + lexer = shlex.shlex(value, posix=True) + lexer.whitespace_split = True + lexer.commenters = "#" + try: + tokens = list(lexer) + except ValueError: + return value.strip().strip("\"'") + return " ".join(tokens).strip() + + +def parse_remnashop_env_text(text_value: str) -> dict[str, str]: + env: dict[str, str] = {} + for raw_line in str(text_value or "").splitlines(): + line = raw_line.strip() + if not line or line.startswith("#"): + continue + if line.startswith("export "): + line = line[len("export ") :].strip() + if "=" not in line: + continue + key, value = line.split("=", 1) + key = key.strip() + if not key or not re.match(r"^[A-Za-z_][A-Za-z0-9_]*$", key): + continue + env[key] = _strip_env_value(value) + return env + + +def read_remnashop_env_file(path: Optional[str]) -> dict[str, str]: + if not path: + return {} + return parse_remnashop_env_text(Path(path).read_text(encoding="utf-8")) + + +def _clean_url(value: Any) -> Optional[str]: + text_value = str(value or "").strip().rstrip("/") + return text_value or None + + +def _remnashop_panel_api_url(value: Any) -> Optional[str]: + host = _clean_url(value) + if not host: + return None + if "://" not in host: + if "." in host: + host = f"https://{host}" + else: + host = f"http://{host}:3000" + if not host.rstrip("/").endswith("/api"): + host = f"{host.rstrip('/')}/api" + return host + + +def _source_public_base_from_env(env: dict[str, str]) -> Optional[str]: + domain = _clean_url(env.get("APP_DOMAIN")) + if not domain: + return None + if "://" not in domain: + domain = f"https://{domain}" + return domain + + +def _support_link_from_username(value: Any) -> Optional[str]: + username = str(value or "").strip().lstrip("@") + if not username: + return None + return f"https://t.me/{username}" + + +def _mini_app_url_from_env(value: Any) -> Optional[str]: + raw = str(value or "").strip() + if not raw: + return None + if raw.lower() in {"true", "false", "0", "1"}: + return None + return raw if raw.startswith("https://") else None + + +def _add_override(overrides: dict[str, Any], key: str, value: Any) -> None: + if value is None: + return + if isinstance(value, str) and not value.strip(): + return + overrides[key] = value + + +def remnashop_env_overrides(env: dict[str, str]) -> dict[str, Any]: + overrides: dict[str, Any] = {} + _add_override(overrides, "PANEL_API_URL", _remnashop_panel_api_url(env.get("REMNAWAVE_HOST"))) + _add_override(overrides, "PANEL_API_KEY", env.get("REMNAWAVE_TOKEN")) + _add_override(overrides, "PANEL_WEBHOOK_SECRET", env.get("REMNAWAVE_WEBHOOK_SECRET")) + _add_override( + overrides, + "SUPPORT_LINK", + _support_link_from_username(env.get("BOT_SUPPORT_USERNAME")), + ) + _add_override(overrides, "DEFAULT_LANGUAGE", env.get("APP_DEFAULT_LOCALE")) + _add_override( + overrides, + "SUBSCRIPTION_MINI_APP_URL", + _mini_app_url_from_env(env.get("BOT_MINI_APP")), + ) + return overrides + + +def remnashop_source_urls_from_env(env: dict[str, str]) -> dict[str, str]: + base = _source_public_base_from_env(env) + if not base: + return {} + return { + "telegram": f"{base}/api/v1/telegram", + "remnawave_panel": f"{base}{REMNASHOP_PANEL_WEBHOOK_PATH}", + "payments": f"{base}/api/v1/payments/", + } + + +def _normalize_gateway_type(value: Any) -> str: + if hasattr(value, "value"): + value = value.value + text_value = str(value or "").strip().upper() + if "." in text_value: + text_value = text_value.rsplit(".", 1)[-1] + return re.sub(r"[^A-Z0-9_]+", "_", text_value).strip("_") + + +def _normalize_currency(value: Any) -> Optional[str]: + text_value = str(value or "").strip().upper() + if "." in text_value: + text_value = text_value.rsplit(".", 1)[-1] + aliases = {"RUR": "RUB", "STARS": "XTR", "STAR": "XTR"} + normalized = aliases.get(text_value, text_value) + return normalized or None + + +def _truthy(value: Any) -> bool: + if isinstance(value, bool): + return value + if isinstance(value, (int, float)): + return bool(value) + return str(value or "").strip().lower() in {"1", "true", "yes", "on", "active"} + + +def _is_encrypted_remnashop_value(value: Any) -> bool: + return isinstance(value, str) and value.startswith(REMNASHOP_ENCRYPTED_PREFIX) + + +def remnashop_decrypt_value(value: Any, crypt_key: Optional[str]) -> tuple[Any, bool]: + if not _is_encrypted_remnashop_value(value): + return value, False + if not crypt_key or Fernet is None: + return None, True + try: + token = str(value).removeprefix(REMNASHOP_ENCRYPTED_PREFIX).encode() + return Fernet(crypt_key.encode()).decrypt(token).decode(), False + except Exception: + return None, True + + +def remnashop_decrypt_recursive( + value: Any, + crypt_key: Optional[str], + *, + skipped_paths: Optional[list[str]] = None, + path: str = "", +) -> Any: + if isinstance(value, dict): + return { + key: remnashop_decrypt_recursive( + item, + crypt_key, + skipped_paths=skipped_paths, + path=f"{path}.{key}" if path else str(key), + ) + for key, item in value.items() + } + if isinstance(value, list): + return [ + remnashop_decrypt_recursive( + item, + crypt_key, + skipped_paths=skipped_paths, + path=f"{path}[{index}]", + ) + for index, item in enumerate(value) + ] + decrypted, skipped = remnashop_decrypt_value(value, crypt_key) + if skipped and skipped_paths is not None: + skipped_paths.append(path or "") + return decrypted + + +def _provider_mapping_result( + gateway_type: str, + provider_ids: Iterable[str], + overrides: dict[str, Any], + warnings: Optional[list[str]] = None, +) -> dict[str, Any]: + return { + "source_type": gateway_type, + "provider_ids": [provider for provider in provider_ids if provider], + "overrides": overrides, + "warnings": warnings or [], + "supported": True, + } + + +def remnashop_payment_gateway_overrides( + row: dict[str, Any], + *, + crypt_key: Optional[str] = None, +) -> dict[str, Any]: + gateway_type = _normalize_gateway_type(row.get("type")) + if gateway_type not in SUPPORTED_REMNASHOP_PROVIDER_TYPES: + return { + "source_type": gateway_type, + "provider_ids": [], + "overrides": {}, + "warnings": [], + "supported": False, + } + + skipped_secret_paths: list[str] = [] + settings = remnashop_decrypt_recursive( + _jsonish(row.get("settings")), + crypt_key, + skipped_paths=skipped_secret_paths, + ) + active = _truthy(row.get("is_active")) + currency = _normalize_currency(row.get("currency")) + overrides: dict[str, Any] = {} + warnings = [ + ( + f"Skipped encrypted Remnashop {gateway_type} setting '{path}': " + "APP_CRYPT_KEY is missing or invalid" + ) + for path in skipped_secret_paths + ] + + if gateway_type == "TELEGRAM_STARS": + _add_override(overrides, "STARS_ENABLED", active) + return _provider_mapping_result(gateway_type, ["stars"], overrides, warnings) + + if gateway_type == "YOOKASSA": + _add_override(overrides, "YOOKASSA_ENABLED", active) + _add_override(overrides, "YOOKASSA_SHOP_ID", settings.get("shop_id")) + _add_override(overrides, "YOOKASSA_SECRET_KEY", settings.get("api_key")) + _add_override(overrides, "YOOKASSA_DEFAULT_RECEIPT_EMAIL", settings.get("customer")) + _add_override(overrides, "YOOKASSA_VAT_CODE", settings.get("vat_code")) + if currency and currency != "RUB": + warnings.append( + f"YooKassa supports RUB only in this shop; source currency was {currency}" + ) + return _provider_mapping_result(gateway_type, ["yookassa"], overrides, warnings) + + if gateway_type == "WATA": + _add_override(overrides, "WATA_ENABLED", active) + _add_override(overrides, "WATA_API_TOKEN", settings.get("api_key")) + return _provider_mapping_result(gateway_type, ["wata"], overrides, warnings) + + if gateway_type == "CRYPTOPAY": + _add_override(overrides, "CRYPTOPAY_ENABLED", active) + _add_override(overrides, "CRYPTOPAY_TOKEN", settings.get("api_key")) + if currency: + _add_override(overrides, "CRYPTOPAY_ASSET", currency) + return _provider_mapping_result(gateway_type, ["cryptopay"], overrides, warnings) + + if gateway_type == "HELEKET": + _add_override(overrides, "HELEKET_ENABLED", active) + _add_override(overrides, "HELEKET_MERCHANT_ID", settings.get("merchant_id")) + _add_override(overrides, "HELEKET_API_KEY", settings.get("api_key")) + if currency: + _add_override(overrides, "HELEKET_CURRENCY", currency) + _add_override(overrides, "HELEKET_SUPPORTED_CURRENCIES", currency) + return _provider_mapping_result(gateway_type, ["heleket"], overrides, warnings) + + if gateway_type == "FREEKASSA": + _add_override(overrides, "FREEKASSA_ENABLED", active) + _add_override(overrides, "FREEKASSA_MERCHANT_ID", settings.get("shop_id")) + _add_override(overrides, "FREEKASSA_API_KEY", settings.get("api_key")) + _add_override(overrides, "FREEKASSA_SECOND_SECRET", settings.get("secret_word_2")) + _add_override(overrides, "FREEKASSA_PAYMENT_METHOD_ID", settings.get("payment_system_id")) + _add_override(overrides, "FREEKASSA_PAYMENT_IP", settings.get("customer_ip")) + if settings.get("customer_email"): + warnings.append( + "FreeKassa customer_email was captured by Remnashop but is not a " + "Minishop provider setting" + ) + return _provider_mapping_result(gateway_type, ["freekassa"], overrides, warnings) + + if gateway_type == "PLATEGA": + _add_override(overrides, "PLATEGA_ENABLED", active) + _add_override(overrides, "PLATEGA_SBP_ENABLED", active) + _add_override(overrides, "PLATEGA_MERCHANT_ID", settings.get("merchant_id")) + _add_override(overrides, "PLATEGA_SECRET", settings.get("api_key")) + _add_override(overrides, "PLATEGA_PAYMENT_METHOD", settings.get("payment_method")) + _add_override(overrides, "PLATEGA_SBP_METHOD", settings.get("payment_method")) + if currency: + _add_override(overrides, "PLATEGA_SUPPORTED_CURRENCIES", currency) + return _provider_mapping_result(gateway_type, ["platega_sbp"], overrides, warnings) + + return _provider_mapping_result(gateway_type, [], overrides, warnings) + + +def _target_webhook_url(base_url: Optional[str], path: str) -> Optional[str]: + base = _clean_url(base_url) + if not base: + return None + return f"{base}{path if path.startswith('/') else '/' + path}" + + +def remnashop_post_migration_actions( + *, + target_webhook_base_url: Optional[str], + imported_provider_ids: Iterable[str], + source_env: Optional[dict[str, str]] = None, +) -> dict[str, Any]: + provider_ids = list(dict.fromkeys(imported_provider_ids)) + payment_actions = [] + seen_paths: set[str] = set() + for provider_id in provider_ids: + path = PAYMENT_WEBHOOK_PATHS.get(provider_id) + if not path or path in seen_paths: + continue + seen_paths.add(path) + payment_actions.append( + { + "provider": provider_id, + "new_url": _target_webhook_url(target_webhook_base_url, path), + "where": { + "yookassa": "YooKassa merchant cabinet -> HTTP notifications URL", + "wata": "WATA merchant dashboard -> webhook/callback URL", + "cryptopay": "CryptoBot/Crypto Pay app -> webhook URL", + "heleket": "Heleket merchant dashboard -> payment webhook/callback URL", + "freekassa": "FreeKassa shop settings -> notification/result URL", + "platega": "Platega merchant/project settings -> webhook URL", + }.get(provider_id, "Payment provider dashboard -> webhook/callback URL"), + } + ) + + return { + "webhook_base_url_configured": bool(_clean_url(target_webhook_base_url)), + "source_urls": remnashop_source_urls_from_env(source_env or {}), + "remnawave_panel": { + "new_url": _target_webhook_url(target_webhook_base_url, "/webhook/panel"), + "where": "Remnawave Panel -> WEBHOOK_URL", + "secret": ( + "Set the Remnawave webhook secret to the value stored in " + "PANEL_WEBHOOK_SECRET." + ), + }, + "payment_providers": payment_actions, + "telegram": { + "new_url": _target_webhook_url(target_webhook_base_url, "/tg/webhook"), + "where": "Telegram webhook is set automatically by Minishop on startup.", + }, + } + + def _listish(value: Any) -> list[Any]: if value is None: return [] @@ -307,6 +701,9 @@ class RemnashopImporter: created_by_admin_id: int, tariff_map: dict[str, str], write_admin_compat_overrides: bool, + source_env: Optional[dict[str, str]] = None, + source_crypt_key: Optional[str] = None, + target_webhook_base_url: Optional[str] = None, ) -> None: self.source = source self.target = target @@ -317,8 +714,12 @@ class RemnashopImporter: self.created_by_admin_id = created_by_admin_id self.tariff_map = tariff_map self.write_admin_compat_overrides = write_admin_compat_overrides + self.source_env = source_env or {} + self.source_crypt_key = source_crypt_key or self.source_env.get("APP_CRYPT_KEY") + self.target_webhook_base_url = target_webhook_base_url self.tables: set[str] = set() self.user_map: dict[int, int] = {} + self.imported_payment_provider_ids: list[str] = [] self.summary: dict[str, Any] = { "source": SOURCE, "dry_run": dry_run, @@ -328,6 +729,7 @@ class RemnashopImporter: "subscriptions": _counter(), "payments": _counter(), "promocodes": _counter(), + "payment_provider_settings": _counter(), "settings": _counter(), "warnings": [], } @@ -349,6 +751,12 @@ class RemnashopImporter: if self._should_run("settings"): await self.import_settings() + self.summary["post_migration_actions"] = remnashop_post_migration_actions( + target_webhook_base_url=self.target_webhook_base_url, + imported_provider_ids=self.imported_payment_provider_ids, + source_env=self.source_env, + ) + if self.write_admin_compat_overrides: await self._write_admin_overrides() @@ -486,7 +894,19 @@ class RemnashopImporter: result = await self.target.execute(stmt) return result.scalar_one_or_none() - async def _upsert_setting_override(self, key: str, value: Any) -> None: + async def _upsert_setting_override(self, key: str, value: Any) -> bool: + from bot.app.web.admin_settings_manifest import coerce_value, get_field_by_key + + field = get_field_by_key(key) + if field is None: + self.summary["warnings"].append(f"Skipped unknown admin setting override: {key}") + return False + try: + value = coerce_value(field, value) + except ValueError as exc: + self.summary["warnings"].append(f"Skipped invalid admin setting override {key}: {exc}") + return False + now = datetime.now(timezone.utc) encoded = json.dumps(value, ensure_ascii=False, separators=(",", ":")) stmt = ( @@ -507,6 +927,142 @@ class RemnashopImporter: ) ) await self.target.execute(stmt) + return True + + async def _write_setting_overrides( + self, + overrides: dict[str, Any], + *, + summary_key: str, + ) -> list[str]: + written: list[str] = [] + for key, value in overrides.items(): + if await self._upsert_setting_override(key, value): + written.append(key) + self.summary[summary_key]["overrides_written"] += 1 + else: + self.summary[summary_key]["overrides_skipped"] += 1 + return written + + async def import_env_settings(self) -> list[str]: + if not self.source_env: + self.summary["settings"]["source_env_missing"] += 1 + return [] + + overrides = remnashop_env_overrides(self.source_env) + if not overrides: + self.summary["settings"]["source_env_no_supported_values"] += 1 + return [] + + written = await self._write_setting_overrides(overrides, summary_key="settings") + if written: + self.summary["settings"]["source_env_overrides_written"] += 1 + await self._upsert_mapping( + entity_type="settings_env", + source_id="remnashop.env", + target_table="app_setting_overrides", + target_id=",".join(written) if written else "none", + metadata={ + "override_keys": written, + "source_keys_used": sorted( + key + for key in ( + "REMNAWAVE_HOST", + "REMNAWAVE_TOKEN", + "REMNAWAVE_WEBHOOK_SECRET", + "BOT_SUPPORT_USERNAME", + "APP_DEFAULT_LOCALE", + "BOT_MINI_APP", + ) + if self.source_env.get(key) + ), + "has_app_crypt_key": bool(self.source_env.get("APP_CRYPT_KEY")), + "source_urls": remnashop_source_urls_from_env(self.source_env), + }, + ) + return written + + async def import_payment_provider_settings(self) -> None: + if "payment_gateways" not in self.tables: + self.summary["payment_provider_settings"]["missing_source_table"] += 1 + return + + rows = await self._fetch_rows("payment_gateways", order_by="order_index, id") + if not rows: + self.summary["payment_provider_settings"]["empty_source_table"] += 1 + return + + active_provider_ids: list[str] = [] + for index, row in enumerate(rows): + source_id = row.get("id") or row.get("type") or f"row:{index}" + mapping = remnashop_payment_gateway_overrides( + row, + crypt_key=self.source_crypt_key, + ) + gateway_type = mapping["source_type"] + self.summary["payment_provider_settings"]["seen"] += 1 + + for warning in mapping["warnings"]: + self.summary["warnings"].append(warning) + + if not mapping["supported"]: + self.summary["payment_provider_settings"]["unsupported"] += 1 + display_type = gateway_type or str(row.get("type") or "unknown") + self.summary["warnings"].append( + f"Remnashop payment provider {display_type} is not supported by " + "Minishop; configure it manually if it is still needed." + ) + await self._upsert_mapping( + entity_type="payment_provider_settings", + source_id=source_id, + target_table="manual_configuration_required", + target_id=display_type, + metadata={ + "source_type": display_type, + "active": _truthy(row.get("is_active")), + "currency": _normalize_currency(row.get("currency")), + "supported": False, + }, + ) + continue + + written = await self._write_setting_overrides( + mapping["overrides"], + summary_key="payment_provider_settings", + ) + if written: + self.summary["payment_provider_settings"]["providers_mapped"] += 1 + else: + self.summary["payment_provider_settings"]["providers_without_overrides"] += 1 + + if _truthy(row.get("is_active")): + for provider_id in mapping["provider_ids"]: + if provider_id and provider_id not in active_provider_ids: + active_provider_ids.append(provider_id) + if provider_id and provider_id not in self.imported_payment_provider_ids: + self.imported_payment_provider_ids.append(provider_id) + + await self._upsert_mapping( + entity_type="payment_provider_settings", + source_id=source_id, + target_table="app_setting_overrides", + target_id=",".join(written) if written else "none", + metadata={ + "source_type": gateway_type, + "provider_ids": mapping["provider_ids"], + "active": _truthy(row.get("is_active")), + "currency": _normalize_currency(row.get("currency")), + "override_keys": written, + "source_settings_keys": sorted(_jsonish(row.get("settings")).keys()), + "warnings_count": len(mapping["warnings"]), + "supported": True, + }, + ) + + if active_provider_ids: + order_value = ",".join(active_provider_ids) + if await self._upsert_setting_override("PAYMENT_METHODS_ORDER", order_value): + self.summary["payment_provider_settings"]["payment_order_written"] += 1 async def _upsert_legacy_referral_code(self, *, code: str, user_id: int) -> None: if len(code) > 128: @@ -1029,7 +1585,29 @@ class RemnashopImporter: } for plan in plans[:100] ], + "source_env": { + "provided": bool(self.source_env), + "supported_keys_present": sorted( + key + for key in ( + "REMNAWAVE_HOST", + "REMNAWAVE_TOKEN", + "REMNAWAVE_WEBHOOK_SECRET", + "BOT_SUPPORT_USERNAME", + "APP_DEFAULT_LOCALE", + "BOT_MINI_APP", + "APP_DOMAIN", + "APP_CRYPT_KEY", + ) + if self.source_env.get(key) + ), + "source_urls": remnashop_source_urls_from_env(self.source_env), + }, } + env_override_keys = await self.import_env_settings() + await self.import_payment_provider_settings() + notes["env_override_keys"] = env_override_keys + notes["payment_provider_ids"] = list(dict.fromkeys(self.imported_payment_provider_ids)) await self._upsert_mapping( entity_type="settings", source_id="singleton", @@ -1080,6 +1658,17 @@ def build_arg_parser() -> argparse.ArgumentParser: parser.add_argument("--source-type", choices=[SOURCE], default=SOURCE) parser.add_argument("--source-dsn", required=True) parser.add_argument("--source-schema", default="public") + parser.add_argument( + "--source-env-file", + help=( + "Path to the source Remnashop .env. Used for APP_CRYPT_KEY, Remnawave " + "API settings and selected safe compatibility values." + ), + ) + parser.add_argument( + "--source-crypt-key", + help="Explicit Remnashop APP_CRYPT_KEY. Overrides the value from --source-env-file.", + ) parser.add_argument("--target-dsn") parser.add_argument( "--only", @@ -1116,6 +1705,8 @@ async def _prepare_target_schema(engine: Any) -> None: async def run_import(args: argparse.Namespace) -> dict[str, Any]: settings = Settings() + source_env = read_remnashop_env_file(args.source_env_file) + source_crypt_key = args.source_crypt_key or source_env.get("APP_CRYPT_KEY") source_engine = create_async_engine(normalize_async_postgres_dsn(args.source_dsn)) target_engine = create_async_engine( normalize_async_postgres_dsn(args.target_dsn or settings.DATABASE_URL) @@ -1141,6 +1732,9 @@ async def run_import(args: argparse.Namespace) -> dict[str, Any]: created_by_admin_id=args.created_by_admin_id, tariff_map=parse_tariff_map(args.tariff_map_json), write_admin_compat_overrides=not args.no_admin_compat_overrides, + source_env=source_env, + source_crypt_key=source_crypt_key, + target_webhook_base_url=settings.WEBHOOK_BASE_URL, ) summary = await importer.run() if args.dry_run: diff --git a/docs/getting-started/deployment.md b/docs/getting-started/deployment.md index 1df27b0..e5ff0bb 100644 --- a/docs/getting-started/deployment.md +++ b/docs/getting-started/deployment.md @@ -42,7 +42,11 @@ sh install.sh ``` Миграция Remnashop в wizard сначала запускает `dry-run`, показывает JSON-сводку -и только после отдельного подтверждения применяет изменения в целевую БД. +и только после отдельного подтверждения применяет изменения в целевую БД. Если +указать старый Remnashop `.env`, wizard передаст importer-у `APP_CRYPT_KEY`, +Remnawave API settings и поддерживаемые payment provider settings из таблицы +`payment_gateways`. После применения wizard печатает новые webhook URL для +Remnawave Panel и платежных провайдеров. Миграция со старого `remnawave-tg-shop` работает как upgrade совместимой БД: либо копирует старый Docker volume, либо делает `pg_dump` по source DSN, восстанавливает дамп в целевую compose-БД и запускает сервис `migrate`. diff --git a/docs/migrations/index.md b/docs/migrations/index.md index 0c20758..42d2a74 100644 --- a/docs/migrations/index.md +++ b/docs/migrations/index.md @@ -5,4 +5,4 @@ | Источник | Поддерживаемый случай | Документы | | --- | --- | --- | | [remnawave-tg-shop](https://github.com/kavore/remnawave-tg-shop/) | Полный перенос всех данных | [Инструкция](remnawave-tg-shop.md) | -| [Remnashop](https://github.com/snoups/remnashop/) | Автоматический импорт пользователей, подписок, платежей, рефералов и промокодов | [Инструкция](remnashop.md) | +| [Remnashop](https://github.com/snoups/remnashop/) | Автоматический импорт пользователей, подписок, платежей, рефералов, промокодов и поддерживаемых платежных настроек | [Инструкция](remnashop.md) | diff --git a/docs/migrations/remnashop.md b/docs/migrations/remnashop.md index 11e662c..a719c4a 100644 --- a/docs/migrations/remnashop.md +++ b/docs/migrations/remnashop.md @@ -24,16 +24,61 @@ sh install.sh Данные, которые не имеют прямого аналога, сохраняются в служебных таблицах миграции или message logs как заметки, чтобы администратор мог проверить их после переноса. +## Настройки и платежные провайдеры + +Если указать старый Remnashop `.env`, importer дополнительно переносит часть +настроек в админские overrides: + +- `REMNAWAVE_HOST` -> `PANEL_API_URL`; +- `REMNAWAVE_TOKEN` -> `PANEL_API_KEY`; +- `REMNAWAVE_WEBHOOK_SECRET` -> `PANEL_WEBHOOK_SECRET`; +- `BOT_SUPPORT_USERNAME` -> `SUPPORT_LINK`; +- `APP_DEFAULT_LOCALE` -> `DEFAULT_LANGUAGE`; +- `BOT_MINI_APP` -> `SUBSCRIPTION_MINI_APP_URL`, если там уже HTTPS URL. + +Платежные провайдеры берутся из таблицы Remnashop `payment_gateways`. +Поддерживаются и автоматически маппятся: Telegram Stars, YooKassa, WATA, +CryptoPay, Heleket, FreeKassa и Platega. Для них importer переносит флаги +включения, API-ключи/merchant IDs и доступные provider-specific параметры в +раздел настроек админки. + +Провайдеры YooMoney, Cryptomus, MulenPay, PayMaster, RoboKassa и UrlPay сейчас +не имеют прямого аналога в Minishop. Если они были в Remnashop, importer +оставит предупреждение в JSON-сводке и notes миграции, а настроить их нужно +вручную или через будущий отдельный provider. + +Remnashop может хранить секреты в формате `enc_...`. Для расшифровки нужен +старый `APP_CRYPT_KEY`; проще всего указать путь к старому `.env` в wizard или +передать `--source-env-file`. Если ключ не передан или неверный, зашифрованные +значения будут пропущены с предупреждением, остальные данные продолжат +импортироваться. + +После успешного применения wizard печатает список новых адресов webhook. Их +нужно указать во внешних сервисах вместо старых Remnashop URL: + +- Remnawave Panel -> `WEBHOOK_URL`: `WEBHOOK_BASE_URL` + `/webhook/panel`; +- YooKassa HTTP notifications URL: `WEBHOOK_BASE_URL` + `/webhook/yookassa`; +- WATA webhook/callback URL: `WEBHOOK_BASE_URL` + `/webhook/wata`; +- CryptoBot/Crypto Pay webhook URL: `WEBHOOK_BASE_URL` + `/webhook/cryptopay`; +- Heleket payment webhook/callback URL: `WEBHOOK_BASE_URL` + `/webhook/heleket`; +- FreeKassa notification/result URL: `WEBHOOK_BASE_URL` + `/webhook/freekassa`; +- Platega webhook URL: `WEBHOOK_BASE_URL` + `/webhook/platega`; +- Telegram webhook `WEBHOOK_BASE_URL` + `/tg/webhook` выставляется ботом + автоматически при старте. + ## Flow wizard 1. Wizard скачивает compose-профиль и `backend/scripts/import_legacy.py` через `raw.githubusercontent.com`, без клонирования репозитория. 2. Вы указываете source PostgreSQL DSN Remnashop и schema, обычно `public`. -3. Вы выбираете целевую БД: текущую compose-БД или ручной target DSN. -4. При необходимости указываете JSON map тарифов Remnashop в локальные +3. Опционально указываете путь к старому Remnashop `.env` для `APP_CRYPT_KEY`, + Remnawave API settings и переносимых payment/provider settings. +4. Вы выбираете целевую БД: текущую compose-БД или ручной target DSN. +5. При необходимости указываете JSON map тарифов Remnashop в локальные `tariff_key`, например `{"basic": "standard_month"}`. -5. Wizard запускает `dry-run` и показывает JSON-сводку. -6. После подтверждения `y` importer применяет изменения и перезапускает +6. Wizard запускает `dry-run` и показывает JSON-сводку. +7. После подтверждения `y` importer применяет изменения, печатает список новых + webhook URL для Remnawave Panel и платежных провайдеров, затем перезапускает `backend`/`worker`, чтобы настройки совместимости перечитались. Если source DB находится на том же Docker host, помните, что DSN выполняется @@ -51,6 +96,7 @@ docker compose run --rm backend \ --source-type remnashop \ --source-dsn 'postgresql://old_user:old_password@old_host:5432/remnashop' \ --source-schema public \ + --source-env-file /path/to/remnashop/.env \ --dry-run ``` diff --git a/scripts/install.sh b/scripts/install.sh index 95b7835..b4b5caf 100644 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -47,6 +47,7 @@ COMPOSE_STYLE="" PROMPT_VALUE="" CHOICE_VALUE="" LEGACY_SOURCE="" +SOURCE_ENV_PATH="" COMPOSE_PROJECT_NAME_VALUE="" IMAGE_TAG_VALUE="" @@ -128,6 +129,7 @@ Environment overrides: MINISHOP_INSTALL_REF default ref ($DEFAULT_REF) MINISHOP_IMAGE_TAG default image tag ($DEFAULT_IMAGE_TAG) REMNASHOP_SOURCE_DSN default source DSN for migration + REMNASHOP_SOURCE_ENV_FILE default source Remnashop .env path for migration LEGACY_TGSHOP_SOURCE_DSN default remnawave-tg-shop source DSN for dump/restore The wizard is interactive by design. It never overwrites files without @@ -838,10 +840,52 @@ local_target_dsn() { printf 'postgresql://%s:%s@postgres:5432/%s' "$POSTGRES_USER_VALUE" "$POSTGRES_PASSWORD_VALUE" "$POSTGRES_DB_VALUE" } +target_webhook_base_url() { + public_url=$(env_get WEBHOOK_PUBLIC_URL "") + if [ -n "$public_url" ]; then + printf '%s' "$public_url" | sed 's:/*$::' + return 0 + fi + host=$(env_get WEBHOOK_HOST "") + if [ -n "$host" ]; then + printf 'https://%s' "$host" | sed 's:/*$::' + return 0 + fi + printf '' +} + +remnashop_webhook_checklist() { + section "Update external webhooks" + base_url=$(target_webhook_base_url) + if [ -z "$base_url" ]; then + warn "Could not determine webhook base URL from .env. Set WEBHOOK_HOST or WEBHOOK_PUBLIC_URL, then use WEBHOOK_BASE_URL + paths below." + base_url="WEBHOOK_BASE_URL" + fi + + info "Set these URLs in external dashboards after the migration:" + printf ' Remnawave Panel -> WEBHOOK_URL: %s/webhook/panel\n' "$base_url" + panel_secret=$(env_get PANEL_WEBHOOK_SECRET "") + if [ -n "$panel_secret" ]; then + printf ' Remnawave Panel -> webhook secret: %s\n' "$(mask_secret "$panel_secret")" + else + warn "PANEL_WEBHOOK_SECRET is empty; set it in Minishop and in Remnawave Panel." + fi + printf ' YooKassa merchant cabinet -> HTTP notifications URL: %s/webhook/yookassa\n' "$base_url" + printf ' WATA merchant dashboard -> webhook/callback URL: %s/webhook/wata\n' "$base_url" + printf ' CryptoBot/Crypto Pay app -> webhook URL: %s/webhook/cryptopay\n' "$base_url" + printf ' Heleket merchant dashboard -> payment webhook/callback URL: %s/webhook/heleket\n' "$base_url" + printf ' FreeKassa shop settings -> notification/result URL: %s/webhook/freekassa\n' "$base_url" + printf ' Platega merchant/project settings -> webhook URL: %s/webhook/platega\n' "$base_url" + printf ' Telegram webhook: %s/tg/webhook (configured automatically on bot startup)\n' "$base_url" +} + run_import_command() { dry="$1" set -- run --rm \ -v "$IMPORTER_PATH:/app/backend/scripts/import_legacy.py:ro" + if [ -n "$SOURCE_ENV_PATH" ]; then + set -- "$@" -v "$SOURCE_ENV_PATH:/tmp/remnashop.env:ro" + fi if [ -n "$TARIFF_MAP_PATH" ]; then set -- "$@" -v "$TARIFF_MAP_PATH:/tmp/tariff-map.json:ro" fi @@ -850,6 +894,9 @@ run_import_command() { --source-dsn "$SOURCE_DSN" \ --source-schema "$SOURCE_SCHEMA" \ --target-dsn "$TARGET_DSN" + if [ -n "$SOURCE_ENV_PATH" ]; then + set -- "$@" --source-env-file /tmp/remnashop.env + fi if [ -n "$TARIFF_MAP_PATH" ]; then set -- "$@" --tariff-map-json /tmp/tariff-map.json fi @@ -861,7 +908,7 @@ run_import_command() { choose_legacy_source() { choose "Source bot" "1" "1|2|3" \ - "1. Remnashop - import users, subscriptions, payments, referrals and promo codes." \ + "1. Remnashop - import users, subscriptions, payments, provider settings and promo codes." \ "2. Old remnawave-tg-shop - upgrade an old compatible database/volume." \ "3. Skip migration" case "$CHOICE_VALUE" in @@ -895,6 +942,20 @@ run_remnashop_migration() { SOURCE_DSN="$PROMPT_VALUE" prompt_value "Source schema" "public" 1 0 "" SOURCE_SCHEMA="$PROMPT_VALUE" + prompt_value "Optional source Remnashop .env path (empty to skip)" "${REMNASHOP_SOURCE_ENV_FILE:-}" 0 0 "" + SOURCE_ENV_PATH="$PROMPT_VALUE" + if [ -n "$SOURCE_ENV_PATH" ]; then + source_env_dir=$(dirname "$SOURCE_ENV_PATH") + if [ ! -d "$source_env_dir" ]; then + fail "Source .env directory not found: $source_env_dir" + return 1 + fi + SOURCE_ENV_PATH=$(cd "$source_env_dir" && pwd)/$(basename "$SOURCE_ENV_PATH") + if [ ! -f "$SOURCE_ENV_PATH" ]; then + fail "Source Remnashop .env not found: $SOURCE_ENV_PATH" + return 1 + fi + fi choose "Target database" "1" "1|2" \ "1. This Docker Compose stack database (recommended)" \ @@ -936,6 +997,7 @@ run_remnashop_migration() { section "Apply import" run_import_command 0 || return 1 + remnashop_webhook_checklist if confirm "Restart backend and worker so setting overrides are reloaded?" 1; then (cd "$TARGET_DIR" && run_compose restart backend worker) || true fi diff --git a/tests/test_install_script.py b/tests/test_install_script.py index 654627e..bc2477c 100644 --- a/tests/test_install_script.py +++ b/tests/test_install_script.py @@ -36,6 +36,8 @@ def test_shell_installer_downloads_raw_files_and_runs_import_in_container(): assert "raw.githubusercontent.com" in script assert "git clone" not in script assert "backend python backend/scripts/import_legacy.py" in script + assert "Optional source Remnashop .env path" in script + assert "--source-env-file /tmp/remnashop.env" in script assert "--dry-run" in script assert "Install new stack and run migration" in script assert "Run migration only" in script @@ -61,3 +63,19 @@ def test_shell_installer_only_prepares_data_mount_not_runtime_content(): assert "webapp-logo" not in script assert "webapp-emoji" not in script assert "locales-overrides.json" not in script + + +def test_shell_installer_prints_remnashop_webhook_checklist(): + script = INSTALL_SCRIPT.read_text(encoding="utf-8") + + assert "remnashop_webhook_checklist" in script + assert "Remnawave Panel -> WEBHOOK_URL" in script + assert "PANEL_WEBHOOK_SECRET" in script + assert "/webhook/panel" in script + assert "/webhook/yookassa" in script + assert "/webhook/wata" in script + assert "/webhook/cryptopay" in script + assert "/webhook/heleket" in script + assert "/webhook/freekassa" in script + assert "/webhook/platega" in script + assert "/tg/webhook" in script diff --git a/tests/test_migration_doc_accuracy.py b/tests/test_migration_doc_accuracy.py index 5dd79b7..6c0b850 100644 --- a/tests/test_migration_doc_accuracy.py +++ b/tests/test_migration_doc_accuracy.py @@ -8,6 +8,7 @@ from pathlib import Path REPO_ROOT = Path(__file__).resolve().parents[1] DOC_PATH = REPO_ROOT / "docs" / "migrations" / "remnawave-tg-shop.md" +REMNASHOP_DOC_PATH = REPO_ROOT / "docs" / "migrations" / "remnashop.md" INSTALL_SCRIPT_PATH = REPO_ROOT / "scripts" / "install.sh" REMOVED_SCRIPT_PATH = REPO_ROOT / "scripts" / "migrate_to_minishop.sh" COMPOSE_FILES = ( @@ -185,6 +186,31 @@ class DocComposeFileReferencesTests(unittest.TestCase): self.assertTrue((REPO_ROOT / "backend" / "db" / "migrator.py").is_file()) +class RemnashopMigrationDocumentationFactsTests(unittest.TestCase): + def setUp(self) -> None: + self.doc = _read(REMNASHOP_DOC_PATH) + + def test_doc_mentions_env_payment_gateways_and_encrypted_secrets(self): + self.assertIn("--source-env-file", self.doc) + self.assertIn("APP_CRYPT_KEY", self.doc) + self.assertIn("payment_gateways", self.doc) + self.assertIn("enc_", self.doc) + + def test_doc_lists_new_webhook_paths_after_migration(self): + for path in ( + "/webhook/panel", + "/webhook/yookassa", + "/webhook/wata", + "/webhook/cryptopay", + "/webhook/heleket", + "/webhook/freekassa", + "/webhook/platega", + "/tg/webhook", + ): + with self.subTest(path=path): + self.assertIn(path, self.doc) + + class MigrationFootprintRegexTests(unittest.TestCase): def test_every_compose_volume_documented(self): doc = _read(DOC_PATH) diff --git a/tests/test_remnashop_import.py b/tests/test_remnashop_import.py index fb4a16b..0010ef1 100644 --- a/tests/test_remnashop_import.py +++ b/tests/test_remnashop_import.py @@ -1,7 +1,12 @@ from datetime import datetime, timezone +import pytest from scripts.import_legacy import ( + parse_remnashop_env_text, + remnashop_env_overrides, remnashop_months_from_plan_snapshot, + remnashop_payment_gateway_overrides, + remnashop_post_migration_actions, remnashop_pricing_amount, remnashop_pricing_currency, remnashop_sale_mode, @@ -42,3 +47,166 @@ def test_remnashop_plan_months_prefers_snapshot_then_dates(): ) == 3 ) + + +def test_remnashop_env_parser_and_overrides_map_safe_values(): + env = parse_remnashop_env_text( + """ + # old Remnashop + export REMNAWAVE_HOST=panel.example.com + REMNAWAVE_TOKEN='panel-token#kept' + REMNAWAVE_WEBHOOK_SECRET="panel secret" + BOT_SUPPORT_USERNAME=@support_bot # comment + APP_DEFAULT_LOCALE=en + BOT_MINI_APP=https://app.example.com/ + APP_DOMAIN=old.example.com + """ + ) + + assert env["REMNAWAVE_TOKEN"] == "panel-token#kept" + assert env["BOT_SUPPORT_USERNAME"] == "@support_bot" + + overrides = remnashop_env_overrides(env) + assert overrides == { + "PANEL_API_URL": "https://panel.example.com/api", + "PANEL_API_KEY": "panel-token#kept", + "PANEL_WEBHOOK_SECRET": "panel secret", + "SUPPORT_LINK": "https://t.me/support_bot", + "DEFAULT_LANGUAGE": "en", + "SUBSCRIPTION_MINI_APP_URL": "https://app.example.com/", + } + + +def test_remnashop_yookassa_gateway_maps_to_current_provider_settings(): + result = remnashop_payment_gateway_overrides( + { + "type": "YOOKASSA", + "currency": "RUB", + "is_active": True, + "settings": { + "shop_id": "shop-1", + "api_key": "secret", + "customer": "receipt@example.com", + "vat_code": 1, + }, + } + ) + + assert result["supported"] is True + assert result["provider_ids"] == ["yookassa"] + assert result["overrides"] == { + "YOOKASSA_ENABLED": True, + "YOOKASSA_SHOP_ID": "shop-1", + "YOOKASSA_SECRET_KEY": "secret", + "YOOKASSA_DEFAULT_RECEIPT_EMAIL": "receipt@example.com", + "YOOKASSA_VAT_CODE": 1, + } + + +def test_remnashop_free_kassa_and_platega_gateways_map_available_settings(): + freekassa = remnashop_payment_gateway_overrides( + { + "type": "FREEKASSA", + "is_active": True, + "settings": { + "shop_id": "merchant", + "api_key": "api", + "secret_word_2": "notify-secret", + "payment_system_id": 42, + "customer_ip": "203.0.113.10", + "customer_email": "payer@example.com", + }, + } + ) + assert freekassa["provider_ids"] == ["freekassa"] + assert freekassa["overrides"]["FREEKASSA_SECOND_SECRET"] == "notify-secret" + assert freekassa["overrides"]["FREEKASSA_PAYMENT_METHOD_ID"] == 42 + assert any("customer_email" in warning for warning in freekassa["warnings"]) + + platega = remnashop_payment_gateway_overrides( + { + "type": "PLATEGA", + "currency": "RUB", + "is_active": True, + "settings": { + "merchant_id": "merchant", + "api_key": "secret", + "payment_method": 2, + }, + } + ) + assert platega["provider_ids"] == ["platega_sbp"] + assert platega["overrides"]["PLATEGA_SBP_ENABLED"] is True + assert platega["overrides"]["PLATEGA_SBP_METHOD"] == 2 + + +def test_remnashop_unsupported_gateway_is_reported_without_overrides(): + result = remnashop_payment_gateway_overrides( + { + "type": "ROBOKASSA", + "is_active": True, + "settings": {"merchant_login": "shop"}, + } + ) + + assert result["supported"] is False + assert result["provider_ids"] == [] + assert result["overrides"] == {} + + +def test_remnashop_encrypted_gateway_settings_need_app_crypt_key(): + result = remnashop_payment_gateway_overrides( + { + "type": "WATA", + "is_active": True, + "settings": {"api_key": "enc_not-a-fernet-token"}, + } + ) + + assert result["overrides"] == {"WATA_ENABLED": True} + assert any("APP_CRYPT_KEY" in warning for warning in result["warnings"]) + + +def test_remnashop_encrypted_gateway_settings_decrypt_with_app_crypt_key(): + cryptography = pytest.importorskip("cryptography.fernet") + key = cryptography.Fernet.generate_key().decode() + token = cryptography.Fernet(key.encode()).encrypt(b"wata-token").decode() + + result = remnashop_payment_gateway_overrides( + { + "type": "WATA", + "is_active": True, + "settings": {"api_key": f"enc_{token}"}, + }, + crypt_key=key, + ) + + assert result["overrides"] == { + "WATA_ENABLED": True, + "WATA_API_TOKEN": "wata-token", + } + assert result["warnings"] == [] + + +def test_remnashop_post_migration_actions_include_new_webhook_urls(): + actions = remnashop_post_migration_actions( + target_webhook_base_url="https://webhooks.example.com/", + imported_provider_ids=["yookassa", "wata", "yookassa"], + source_env={"APP_DOMAIN": "old.example.com"}, + ) + + assert actions["remnawave_panel"]["new_url"] == "https://webhooks.example.com/webhook/panel" + assert actions["telegram"]["new_url"] == "https://webhooks.example.com/tg/webhook" + assert actions["source_urls"]["payments"] == "https://old.example.com/api/v1/payments/" + assert actions["payment_providers"] == [ + { + "provider": "yookassa", + "new_url": "https://webhooks.example.com/webhook/yookassa", + "where": "YooKassa merchant cabinet -> HTTP notifications URL", + }, + { + "provider": "wata", + "new_url": "https://webhooks.example.com/webhook/wata", + "where": "WATA merchant dashboard -> webhook/callback URL", + }, + ] From e9e028fc00873953d5c602cc521a572c9f53f785 Mon Sep 17 00:00:00 2001 From: 3252a8 <3252a8@proton.me> Date: Tue, 2 Jun 2026 11:31:03 +0300 Subject: [PATCH 11/58] fix: narrow Remnashop settings import --- backend/scripts/import_legacy.py | 66 ++++++++++++++++++++------------ docs/migrations/remnashop.md | 26 ++++++++++--- tests/test_remnashop_import.py | 48 ++++++++++++++++++++++- 3 files changed, 109 insertions(+), 31 deletions(-) diff --git a/backend/scripts/import_legacy.py b/backend/scripts/import_legacy.py index df3b0ed..f3dbd0b 100644 --- a/backend/scripts/import_legacy.py +++ b/backend/scripts/import_legacy.py @@ -61,6 +61,7 @@ except Exception: # pragma: no cover - defensive fallback for minimal tooling. SOURCE = "remnashop" REMNASHOP_ENCRYPTED_PREFIX = "enc_" +PLACEHOLDER_SETTING_VALUES = {"change_me", "changeme"} GIB = 1024**3 UUID_RE = re.compile( r"\b[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-" @@ -230,7 +231,13 @@ def read_remnashop_env_file(path: Optional[str]) -> dict[str, str]: return parse_remnashop_env_text(Path(path).read_text(encoding="utf-8")) +def _is_placeholder_setting_value(value: Any) -> bool: + return isinstance(value, str) and value.strip().lower() in PLACEHOLDER_SETTING_VALUES + + def _clean_url(value: Any) -> Optional[str]: + if _is_placeholder_setting_value(value): + return None text_value = str(value or "").strip().rstrip("/") return text_value or None @@ -259,26 +266,23 @@ def _source_public_base_from_env(env: dict[str, str]) -> Optional[str]: def _support_link_from_username(value: Any) -> Optional[str]: + if _is_placeholder_setting_value(value): + return None username = str(value or "").strip().lstrip("@") if not username: return None return f"https://t.me/{username}" -def _mini_app_url_from_env(value: Any) -> Optional[str]: - raw = str(value or "").strip() - if not raw: - return None - if raw.lower() in {"true", "false", "0", "1"}: - return None - return raw if raw.startswith("https://") else None - - def _add_override(overrides: dict[str, Any], key: str, value: Any) -> None: if value is None: return - if isinstance(value, str) and not value.strip(): - return + if isinstance(value, str): + value = value.strip() + if not value: + return + if value.lower() in PLACEHOLDER_SETTING_VALUES: + return overrides[key] = value @@ -293,11 +297,6 @@ def remnashop_env_overrides(env: dict[str, str]) -> dict[str, Any]: _support_link_from_username(env.get("BOT_SUPPORT_USERNAME")), ) _add_override(overrides, "DEFAULT_LANGUAGE", env.get("APP_DEFAULT_LOCALE")) - _add_override( - overrides, - "SUBSCRIPTION_MINI_APP_URL", - _mini_app_url_from_env(env.get("BOT_MINI_APP")), - ) return overrides @@ -458,17 +457,24 @@ def remnashop_payment_gateway_overrides( if gateway_type == "CRYPTOPAY": _add_override(overrides, "CRYPTOPAY_ENABLED", active) _add_override(overrides, "CRYPTOPAY_TOKEN", settings.get("api_key")) - if currency: - _add_override(overrides, "CRYPTOPAY_ASSET", currency) + if currency and currency != "RUB": + warnings.append( + f"CryptoPay source currency was {currency}; Minishop keeps payment currency " + "controlled by tariffs/default currency. Configure CRYPTOPAY_ASSET manually " + "if this instance needs a different default." + ) return _provider_mapping_result(gateway_type, ["cryptopay"], overrides, warnings) if gateway_type == "HELEKET": _add_override(overrides, "HELEKET_ENABLED", active) _add_override(overrides, "HELEKET_MERCHANT_ID", settings.get("merchant_id")) _add_override(overrides, "HELEKET_API_KEY", settings.get("api_key")) - if currency: - _add_override(overrides, "HELEKET_CURRENCY", currency) - _add_override(overrides, "HELEKET_SUPPORTED_CURRENCIES", currency) + if currency and currency != "RUB": + warnings.append( + f"Heleket source currency was {currency}; Minishop keeps payment currency " + "controlled by tariffs/default currency. Configure HELEKET_CURRENCY manually " + "if this instance needs a different default." + ) return _provider_mapping_result(gateway_type, ["heleket"], overrides, warnings) if gateway_type == "FREEKASSA": @@ -492,8 +498,12 @@ def remnashop_payment_gateway_overrides( _add_override(overrides, "PLATEGA_SECRET", settings.get("api_key")) _add_override(overrides, "PLATEGA_PAYMENT_METHOD", settings.get("payment_method")) _add_override(overrides, "PLATEGA_SBP_METHOD", settings.get("payment_method")) - if currency: - _add_override(overrides, "PLATEGA_SUPPORTED_CURRENCIES", currency) + if currency and currency != "RUB": + warnings.append( + f"Platega source currency was {currency}; Minishop keeps payment currency " + "controlled by tariffs/default currency. Configure PLATEGA_SUPPORTED_CURRENCIES " + "manually if this instance needs a different currency." + ) return _provider_mapping_result(gateway_type, ["platega_sbp"], overrides, warnings) return _provider_mapping_result(gateway_type, [], overrides, warnings) @@ -972,12 +982,15 @@ class RemnashopImporter: "REMNAWAVE_WEBHOOK_SECRET", "BOT_SUPPORT_USERNAME", "APP_DEFAULT_LOCALE", - "BOT_MINI_APP", ) if self.source_env.get(key) + and not _is_placeholder_setting_value(self.source_env.get(key)) ), "has_app_crypt_key": bool(self.source_env.get("APP_CRYPT_KEY")), "source_urls": remnashop_source_urls_from_env(self.source_env), + "ignored_keys_present": sorted( + key for key in ("BOT_MINI_APP",) if self.source_env.get(key) + ), }, ) return written @@ -1595,13 +1608,16 @@ class RemnashopImporter: "REMNAWAVE_WEBHOOK_SECRET", "BOT_SUPPORT_USERNAME", "APP_DEFAULT_LOCALE", - "BOT_MINI_APP", "APP_DOMAIN", "APP_CRYPT_KEY", ) if self.source_env.get(key) + and not _is_placeholder_setting_value(self.source_env.get(key)) ), "source_urls": remnashop_source_urls_from_env(self.source_env), + "ignored_keys_present": sorted( + key for key in ("BOT_MINI_APP",) if self.source_env.get(key) + ), }, } env_override_keys = await self.import_env_settings() diff --git a/docs/migrations/remnashop.md b/docs/migrations/remnashop.md index a719c4a..00dd2af 100644 --- a/docs/migrations/remnashop.md +++ b/docs/migrations/remnashop.md @@ -33,14 +33,30 @@ message logs как заметки, чтобы администратор мог - `REMNAWAVE_TOKEN` -> `PANEL_API_KEY`; - `REMNAWAVE_WEBHOOK_SECRET` -> `PANEL_WEBHOOK_SECRET`; - `BOT_SUPPORT_USERNAME` -> `SUPPORT_LINK`; -- `APP_DEFAULT_LOCALE` -> `DEFAULT_LANGUAGE`; -- `BOT_MINI_APP` -> `SUBSCRIPTION_MINI_APP_URL`, если там уже HTTPS URL. +- `APP_DEFAULT_LOCALE` -> `DEFAULT_LANGUAGE`. + +`BOT_MINI_APP` из Remnashop не переносится автоматически. В Remnashop эта +переменная управляет кнопкой подключения к subscription page или внешнему Mini +App, а не веб-кабинетом Remnashop. В Minishop `SUBSCRIPTION_MINI_APP_URL` +должен указывать на текущий frontend/Mini App этого стека; wizard настраивает +его из `WEBHOOK_HOST`/`MINIAPP_HOST` или `MINIAPP_PUBLIC_URL`. + +Значения-заглушки вроде `change_me` importer пропускает, чтобы случайно не +записать шаблонные секреты в рабочую конфигурацию. Платежные провайдеры берутся из таблицы Remnashop `payment_gateways`. Поддерживаются и автоматически маппятся: Telegram Stars, YooKassa, WATA, CryptoPay, Heleket, FreeKassa и Platega. Для них importer переносит флаги -включения, API-ключи/merchant IDs и доступные provider-specific параметры в -раздел настроек админки. +включения, API-ключи/merchant IDs и прямые технические параметры, без которых +провайдер не сможет работать: YooKassa receipt email/VAT, FreeKassa second +secret/payment method/server IP и Platega payment method. + +Provider currency и supported-currency ограничения не переносятся автоматически: +в Minishop валюта платежа управляется тарифами и `DEFAULT_CURRENCY_SYMBOL`. +Если старый gateway Remnashop был настроен на нестандартную валюту, importer +оставит предупреждение в JSON-сводке; проверьте `CRYPTOPAY_ASSET`, +`HELEKET_CURRENCY`, `HELEKET_SUPPORTED_CURRENCIES` или +`PLATEGA_SUPPORTED_CURRENCIES` вручную. Провайдеры YooMoney, Cryptomus, MulenPay, PayMaster, RoboKassa и UrlPay сейчас не имеют прямого аналога в Minishop. Если они были в Remnashop, importer @@ -72,7 +88,7 @@ Remnashop может хранить секреты в формате `enc_...`. `raw.githubusercontent.com`, без клонирования репозитория. 2. Вы указываете source PostgreSQL DSN Remnashop и schema, обычно `public`. 3. Опционально указываете путь к старому Remnashop `.env` для `APP_CRYPT_KEY`, - Remnawave API settings и переносимых payment/provider settings. + Remnawave API settings и переносимых settings. 4. Вы выбираете целевую БД: текущую compose-БД или ручной target DSN. 5. При необходимости указываете JSON map тарифов Remnashop в локальные `tariff_key`, например `{"basic": "standard_month"}`. diff --git a/tests/test_remnashop_import.py b/tests/test_remnashop_import.py index 0010ef1..4316f96 100644 --- a/tests/test_remnashop_import.py +++ b/tests/test_remnashop_import.py @@ -73,10 +73,24 @@ def test_remnashop_env_parser_and_overrides_map_safe_values(): "PANEL_WEBHOOK_SECRET": "panel secret", "SUPPORT_LINK": "https://t.me/support_bot", "DEFAULT_LANGUAGE": "en", - "SUBSCRIPTION_MINI_APP_URL": "https://app.example.com/", } +def test_remnashop_env_overrides_skip_placeholders_and_mini_app(): + overrides = remnashop_env_overrides( + { + "REMNAWAVE_HOST": "change_me", + "REMNAWAVE_TOKEN": "change_me", + "REMNAWAVE_WEBHOOK_SECRET": "change_me", + "BOT_SUPPORT_USERNAME": "change_me", + "APP_DEFAULT_LOCALE": "change_me", + "BOT_MINI_APP": "https://old-mini-app.example.com/", + } + ) + + assert overrides == {} + + def test_remnashop_yookassa_gateway_maps_to_current_provider_settings(): result = remnashop_payment_gateway_overrides( { @@ -138,6 +152,38 @@ def test_remnashop_free_kassa_and_platega_gateways_map_available_settings(): assert platega["provider_ids"] == ["platega_sbp"] assert platega["overrides"]["PLATEGA_SBP_ENABLED"] is True assert platega["overrides"]["PLATEGA_SBP_METHOD"] == 2 + assert "PLATEGA_SUPPORTED_CURRENCIES" not in platega["overrides"] + + +def test_remnashop_crypto_provider_currency_is_not_imported_as_setting_override(): + cryptopay = remnashop_payment_gateway_overrides( + { + "type": "CRYPTOPAY", + "currency": "USD", + "is_active": True, + "settings": {"api_key": "crypto-token"}, + } + ) + assert cryptopay["overrides"] == { + "CRYPTOPAY_ENABLED": True, + "CRYPTOPAY_TOKEN": "crypto-token", + } + assert any("source currency was USD" in warning for warning in cryptopay["warnings"]) + + heleket = remnashop_payment_gateway_overrides( + { + "type": "HELEKET", + "currency": "USD", + "is_active": True, + "settings": {"merchant_id": "merchant", "api_key": "secret"}, + } + ) + assert heleket["overrides"] == { + "HELEKET_ENABLED": True, + "HELEKET_MERCHANT_ID": "merchant", + "HELEKET_API_KEY": "secret", + } + assert "HELEKET_CURRENCY" not in heleket["overrides"] def test_remnashop_unsupported_gateway_is_reported_without_overrides(): From 4d7577f4ec0398638b457ff294865e3f9df64843 Mon Sep 17 00:00:00 2001 From: 3252a8 <3252a8@proton.me> Date: Tue, 2 Jun 2026 12:29:43 +0300 Subject: [PATCH 12/58] fix: harden Remnawave panel timeouts --- .../bot/app/web/admin_settings_manifest.py | 36 ++++++ backend/bot/services/panel_api_service.py | 105 ++++++++++++++++-- backend/bot/utils/ttl_cache.py | 9 ++ backend/config/settings.py | 4 + docs/configuration/env-vars.md | 4 + locales/en.json | 8 ++ locales/ru.json | 8 ++ tests/test_admin_settings_manifest_i18n.py | 13 +++ tests/test_panel_api_service_logging.py | 65 +++++++++++ tests/test_performance_caches.py | 8 ++ 10 files changed, 249 insertions(+), 11 deletions(-) diff --git a/backend/bot/app/web/admin_settings_manifest.py b/backend/bot/app/web/admin_settings_manifest.py index 9c86789..1ebffd0 100644 --- a/backend/bot/app/web/admin_settings_manifest.py +++ b/backend/bot/app/web/admin_settings_manifest.py @@ -101,6 +101,42 @@ SETTINGS_MANIFEST: List[SettingField] = [ "Секретный ключ API панели.", secret=True, ), + SettingField( + "PANEL_API_TOTAL_TIMEOUT_SECONDS", + "float", + "remnawave", + "Panel API total timeout", + "Maximum total time for one Remnawave API request, in seconds.", + optional=False, + min=1, + ), + SettingField( + "PANEL_API_CONNECT_TIMEOUT_SECONDS", + "float", + "remnawave", + "Panel API connect timeout", + "Maximum time to get or open a Remnawave API connection, in seconds.", + optional=False, + min=1, + ), + SettingField( + "PANEL_API_SOCK_CONNECT_TIMEOUT_SECONDS", + "float", + "remnawave", + "Panel API socket connect timeout", + "Maximum TCP/TLS connection time for Remnawave API, in seconds.", + optional=False, + min=1, + ), + SettingField( + "PANEL_API_SOCK_READ_TIMEOUT_SECONDS", + "float", + "remnawave", + "Panel API socket read timeout", + "Maximum time to wait for response data from Remnawave API, in seconds.", + optional=False, + min=1, + ), SettingField( "PANEL_WEBHOOK_SECRET", "string", diff --git a/backend/bot/services/panel_api_service.py b/backend/bot/services/panel_api_service.py index 4ff5c43..bac4e60 100644 --- a/backend/bot/services/panel_api_service.py +++ b/backend/bot/services/panel_api_service.py @@ -22,6 +22,11 @@ class PanelApiService: _TRANSIENT_STATUS_CODES = (-1, -3) _SAFE_METHODS = frozenset({"GET", "HEAD"}) _RETRY_BACKOFF_SECONDS = 0.5 + _MIN_TIMEOUT_SECONDS = 0.1 + _DEFAULT_TOTAL_TIMEOUT_SECONDS = 25.0 + _DEFAULT_CONNECT_TIMEOUT_SECONDS = 8.0 + _DEFAULT_SOCK_CONNECT_TIMEOUT_SECONDS = 8.0 + _DEFAULT_SOCK_READ_TIMEOUT_SECONDS = 15.0 def __init__(self, settings: Settings): self.settings = settings @@ -70,17 +75,46 @@ class PanelApiService: async def _get_session(self) -> aiohttp.ClientSession: if self._session is None or self._session.closed: - # Separate connect/read timeouts so a stuck panel does not hold a - # bot worker for the full window; total caps worst-case latency. - timeout = aiohttp.ClientTimeout( - total=15, - connect=3, - sock_connect=3, - sock_read=10, - ) - self._session = aiohttp.ClientSession(timeout=timeout) + self._session = aiohttp.ClientSession(timeout=self._client_timeout()) return self._session + @classmethod + def _timeout_setting(cls, settings: Settings, name: str, default: float) -> float: + raw_value = getattr(settings, name, default) + try: + value = float(raw_value) + except (TypeError, ValueError): + return default + if value <= 0: + return default + return max(cls._MIN_TIMEOUT_SECONDS, value) + + def _client_timeout(self) -> aiohttp.ClientTimeout: + # Separate connect/read timeouts so a slow panel route has more room, + # while genuinely stuck requests still cannot pin a worker forever. + return aiohttp.ClientTimeout( + total=self._timeout_setting( + self.settings, + "PANEL_API_TOTAL_TIMEOUT_SECONDS", + self._DEFAULT_TOTAL_TIMEOUT_SECONDS, + ), + connect=self._timeout_setting( + self.settings, + "PANEL_API_CONNECT_TIMEOUT_SECONDS", + self._DEFAULT_CONNECT_TIMEOUT_SECONDS, + ), + sock_connect=self._timeout_setting( + self.settings, + "PANEL_API_SOCK_CONNECT_TIMEOUT_SECONDS", + self._DEFAULT_SOCK_CONNECT_TIMEOUT_SECONDS, + ), + sock_read=self._timeout_setting( + self.settings, + "PANEL_API_SOCK_READ_TIMEOUT_SECONDS", + self._DEFAULT_SOCK_READ_TIMEOUT_SECONDS, + ), + ) + async def close_session(self): if self._session and not self._session.closed: await self._session.close() @@ -121,6 +155,15 @@ class PanelApiService: for attempt in range(max_attempts): result = await self._request_once(method, endpoint, log_full_response, **kwargs) if attempt + 1 < max_attempts and self._is_transient_error(result): + logging.warning( + "Retrying transient Panel API request method=%s endpoint=%s " + "attempt=%s/%s status_code=%s", + method.upper(), + endpoint, + attempt + 1, + max_attempts, + result.get("status_code") if isinstance(result, dict) else None, + ) await asyncio.sleep(self._RETRY_BACKOFF_SECONDS) continue return result @@ -158,8 +201,8 @@ class PanelApiService: ) except Exception: log_prefix += f" | Payload: {str(json_payload_for_log)[:300]}..." + started = time.monotonic() try: - started = time.monotonic() async with aiohttp_session.request( method.upper(), url_for_request, headers=headers, **kwargs ) as response: @@ -228,15 +271,48 @@ class PanelApiService: return {"error": True, "status_code": response_status, "details": error_details} except aiohttp.ClientConnectorError as e: + logging.info( + "metric panel_latency_seconds=%.3f method=%s endpoint=%s status=connect_error", + time.monotonic() - started, + method.upper(), + endpoint, + ) logging.error(f"Panel API ClientConnectorError to {url_for_request}: {e}") return {"error": True, "status_code": -1, "message": f"Connection error: {str(e)}"} + except aiohttp.ServerTimeoutError as e: + logging.info( + "metric panel_latency_seconds=%.3f method=%s endpoint=%s status=timeout", + time.monotonic() - started, + method.upper(), + endpoint, + ) + logging.warning("Panel API timeout to %s: %s", url_for_request, e) + return {"error": True, "status_code": -3, "message": f"Request timed out: {str(e)}"} except aiohttp.ClientError as e: + logging.info( + "metric panel_latency_seconds=%.3f method=%s endpoint=%s status=client_error", + time.monotonic() - started, + method.upper(), + endpoint, + ) logging.exception("Panel API ClientError to %s.", url_for_request) return {"error": True, "status_code": -2, "message": f"Client error: {str(e)}"} except asyncio.TimeoutError: + logging.info( + "metric panel_latency_seconds=%.3f method=%s endpoint=%s status=timeout", + time.monotonic() - started, + method.upper(), + endpoint, + ) logging.error(f"Panel API request to {url_for_request} timed out.") return {"error": True, "status_code": -3, "message": "Request timed out"} except Exception as e: + logging.info( + "metric panel_latency_seconds=%.3f method=%s endpoint=%s status=unexpected_error", + time.monotonic() - started, + method.upper(), + endpoint, + ) logging.error( f"Unexpected Panel API request error to {url_for_request}: {e}", exc_info=True ) @@ -885,7 +961,14 @@ class PanelApiService: await self._devices_cache.invalidate_remote(f"user:{user_uuid}") async def get_internal_squads(self) -> Optional[List[Dict[str, Any]]]: - return await self._squads_cache.get_or_load("list", self._get_internal_squads_uncached) + squads = await self._squads_cache.get_or_load("list", self._get_internal_squads_uncached) + if squads is not None: + return squads + stale_squads = self._squads_cache.get_stale("list") + if stale_squads is not None: + logging.warning("Using stale internal squads cache after panel fetch failed.") + return stale_squads + return None async def _get_internal_squads_uncached(self) -> Optional[List[Dict[str, Any]]]: response_data = await self._request("GET", "/internal-squads", log_full_response=False) diff --git a/backend/bot/utils/ttl_cache.py b/backend/bot/utils/ttl_cache.py index 18408fd..d182ae7 100644 --- a/backend/bot/utils/ttl_cache.py +++ b/backend/bot/utils/ttl_cache.py @@ -29,6 +29,15 @@ class AsyncTTLCache: return None return value + def get_stale(self, key: str) -> Optional[Any]: + entry = self._data.get(key) + if entry is None: + return None + _, value = entry + if not self._is_cacheable(value): + return None + return value + @staticmethod def _is_cacheable(value: Any) -> bool: if value is None: diff --git a/backend/config/settings.py b/backend/config/settings.py index bd09406..72827f1 100644 --- a/backend/config/settings.py +++ b/backend/config/settings.py @@ -100,6 +100,10 @@ class Settings(BaseSettings): PANEL_DEVICES_CACHE_TTL_SECONDS: int = Field(default=5) PANEL_ALL_USERS_CACHE_TTL_SECONDS: int = Field(default=5) PANEL_ALL_USERS_PAGE_SIZE: int = Field(default=1000) + PANEL_API_TOTAL_TIMEOUT_SECONDS: float = Field(default=25) + PANEL_API_CONNECT_TIMEOUT_SECONDS: float = Field(default=8) + PANEL_API_SOCK_CONNECT_TIMEOUT_SECONDS: float = Field(default=8) + PANEL_API_SOCK_READ_TIMEOUT_SECONDS: float = Field(default=15) ADMIN_PANEL_STATS_CACHE_TTL_SECONDS: int = Field(default=15) ADMIN_DB_STATS_CACHE_TTL_SECONDS: int = Field(default=5) ADMIN_USERS_LIST_CACHE_TTL_SECONDS: int = Field(default=3) diff --git a/docs/configuration/env-vars.md b/docs/configuration/env-vars.md index b7dbdf7..c3f988c 100644 --- a/docs/configuration/env-vars.md +++ b/docs/configuration/env-vars.md @@ -59,6 +59,10 @@ | `PANEL_DEVICES_CACHE_TTL_SECONDS` | TTL кеша устройств пользователя Remnawave. | | `PANEL_ALL_USERS_CACHE_TTL_SECONDS` | TTL кеша полных сканов пользователей Remnawave. | | `PANEL_ALL_USERS_PAGE_SIZE` | Размер страницы Remnawave `/users`. | +| `PANEL_API_TOTAL_TIMEOUT_SECONDS` | Общий timeout запроса к Remnawave API. | +| `PANEL_API_CONNECT_TIMEOUT_SECONDS` | Timeout получения соединения с Remnawave API. | +| `PANEL_API_SOCK_CONNECT_TIMEOUT_SECONDS` | Timeout TCP/TLS-подключения к Remnawave API. | +| `PANEL_API_SOCK_READ_TIMEOUT_SECONDS` | Timeout ожидания данных ответа Remnawave API. | | `ADMIN_PANEL_STATS_CACHE_TTL_SECONDS` | TTL статистики Remnawave в админке. | | `ADMIN_DB_STATS_CACHE_TTL_SECONDS` | TTL дорогих DB-агрегатов админки. | | `ADMIN_USERS_LIST_CACHE_TTL_SECONDS` | TTL списка пользователей админки. | diff --git a/locales/en.json b/locales/en.json index cf3ffff..360a129 100644 --- a/locales/en.json +++ b/locales/en.json @@ -1544,6 +1544,14 @@ "admin_settings_field_panel_api_url_description": "For example, https://panel.example.com/api.", "admin_settings_field_panel_api_key_label": "Remnawave API key", "admin_settings_field_panel_api_key_description": "Secret API key for the panel.", + "admin_settings_field_panel_api_total_timeout_seconds_label": "Remnawave API total timeout", + "admin_settings_field_panel_api_total_timeout_seconds_description": "Maximum total time for one Remnawave API request, in seconds.", + "admin_settings_field_panel_api_connect_timeout_seconds_label": "Remnawave API connect timeout", + "admin_settings_field_panel_api_connect_timeout_seconds_description": "Maximum time to get or open a Remnawave API connection, in seconds.", + "admin_settings_field_panel_api_sock_connect_timeout_seconds_label": "Remnawave API TCP/TLS timeout", + "admin_settings_field_panel_api_sock_connect_timeout_seconds_description": "Maximum TCP/TLS connection time for Remnawave API, in seconds.", + "admin_settings_field_panel_api_sock_read_timeout_seconds_label": "Remnawave API read timeout", + "admin_settings_field_panel_api_sock_read_timeout_seconds_description": "Maximum time to wait for response data from Remnawave API, in seconds.", "admin_settings_field_panel_webhook_secret_label": "Remnawave webhook secret", "admin_settings_field_panel_webhook_secret_description": "Set the secret in Remnawave Panel and paste the same value here to verify incoming panel webhooks.", "admin_settings_field_user_squad_uuids_label": "Default Internal Squads", diff --git a/locales/ru.json b/locales/ru.json index 4ce0660..ab47d7f 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -1544,6 +1544,14 @@ "admin_settings_field_panel_api_url_description": "Например, https://panel.example.com/api.", "admin_settings_field_panel_api_key_label": "API-ключ Remnawave", "admin_settings_field_panel_api_key_description": "Секретный ключ API панели.", + "admin_settings_field_panel_api_total_timeout_seconds_label": "Общий таймаут API Remnawave", + "admin_settings_field_panel_api_total_timeout_seconds_description": "Максимальное время одного запроса к Remnawave API, в секундах.", + "admin_settings_field_panel_api_connect_timeout_seconds_label": "Таймаут подключения API Remnawave", + "admin_settings_field_panel_api_connect_timeout_seconds_description": "Максимальное время получения или открытия соединения с Remnawave API, в секундах.", + "admin_settings_field_panel_api_sock_connect_timeout_seconds_label": "TCP/TLS-таймаут API Remnawave", + "admin_settings_field_panel_api_sock_connect_timeout_seconds_description": "Максимальное время TCP/TLS-подключения к Remnawave API, в секундах.", + "admin_settings_field_panel_api_sock_read_timeout_seconds_label": "Таймаут чтения API Remnawave", + "admin_settings_field_panel_api_sock_read_timeout_seconds_description": "Максимальное ожидание данных ответа от Remnawave API, в секундах.", "admin_settings_field_panel_webhook_secret_label": "Секрет вебхуков Remnawave", "admin_settings_field_panel_webhook_secret_description": "Задайте секрет в Remnawave Panel и вставьте то же значение здесь для проверки входящих вебхуков панели.", "admin_settings_field_user_squad_uuids_label": "Internal Squads по умолчанию", diff --git a/tests/test_admin_settings_manifest_i18n.py b/tests/test_admin_settings_manifest_i18n.py index 2144100..fb11359 100644 --- a/tests/test_admin_settings_manifest_i18n.py +++ b/tests/test_admin_settings_manifest_i18n.py @@ -236,10 +236,15 @@ def test_remnawave_settings_include_panel_webhook_metadata(): remnawave_keys = ( "PANEL_API_URL", "PANEL_API_KEY", + "PANEL_API_TOTAL_TIMEOUT_SECONDS", + "PANEL_API_CONNECT_TIMEOUT_SECONDS", + "PANEL_API_SOCK_CONNECT_TIMEOUT_SECONDS", + "PANEL_API_SOCK_READ_TIMEOUT_SECONDS", "PANEL_WEBHOOK_SECRET", "USER_SQUAD_UUIDS", "USER_EXTERNAL_SQUAD_UUID", ) + timeout_keys = remnawave_keys[2:6] assert field["webhook_path"] == "/webhook/panel" assert field["webhook_requires_base_url"] is True @@ -250,10 +255,18 @@ def test_remnawave_settings_include_panel_webhook_metadata(): assert manifest[setting_key]["section_order"] == 3 assert manifest[setting_key]["subsection"] is None + for setting_key in timeout_keys: + assert manifest[setting_key]["type"] == "float" + assert manifest[setting_key]["optional"] is False + assert manifest[setting_key]["min"] == 1 + for language in ("ru", "en"): messages = _locale(language) assert "admin_settings_section_remnawave" in messages assert field["webhook_hint_i18n_key"] in messages + for setting_key in timeout_keys: + assert manifest[setting_key]["i18n_label_key"] in messages + assert manifest[setting_key]["i18n_description_key"] in messages def test_payment_provider_admin_only_toggles_are_mutually_exclusive(): diff --git a/tests/test_panel_api_service_logging.py b/tests/test_panel_api_service_logging.py index 4691bb5..8589496 100644 --- a/tests/test_panel_api_service_logging.py +++ b/tests/test_panel_api_service_logging.py @@ -1,8 +1,11 @@ import asyncio +import time import unittest from types import SimpleNamespace from unittest.mock import AsyncMock, patch +import aiohttp + from bot.services.panel_api_service import PanelApiService @@ -16,6 +19,68 @@ class PanelApiServiceLoggingTests(unittest.IsolatedAsyncioTestCase): ) ) + async def test_client_timeout_uses_panel_settings(self): + service = PanelApiService( + SimpleNamespace( + PANEL_API_URL="https://panel.example.test/api", + PANEL_API_KEY="panel-key", + PANEL_API_TOTAL_TIMEOUT_SECONDS="30", + PANEL_API_CONNECT_TIMEOUT_SECONDS="10", + PANEL_API_SOCK_CONNECT_TIMEOUT_SECONDS="9", + PANEL_API_SOCK_READ_TIMEOUT_SECONDS="20", + ) + ) + + timeout = service._client_timeout() + + self.assertEqual(timeout.total, 30) + self.assertEqual(timeout.connect, 10) + self.assertEqual(timeout.sock_connect, 9) + self.assertEqual(timeout.sock_read, 20) + + async def test_get_request_retries_connection_timeout(self): + service = self._make_service() + request_calls = 0 + + class OkResponse: + status = 200 + headers = {"Content-Type": "application/json"} + + async def __aenter__(self): + return self + + async def __aexit__(self, exc_type, exc_val, exc_tb): + return None + + async def text(self): + return '{"response": {"ok": true}}' + + def fake_request(*_args, **_kwargs): + nonlocal request_calls + request_calls += 1 + if request_calls == 1: + raise aiohttp.ConnectionTimeoutError("connect took too long") + return OkResponse() + + service._get_session = AsyncMock(return_value=SimpleNamespace(request=fake_request)) + + with patch("bot.services.panel_api_service.asyncio.sleep", new=AsyncMock()): + result = await service._request("GET", "/internal-squads") + + self.assertEqual(result, {"response": {"ok": True}}) + self.assertEqual(request_calls, 2) + + async def test_get_internal_squads_uses_stale_cache_after_refresh_failure(self): + service = self._make_service() + stale_squads = [{"uuid": "squad-1", "name": "Squad 1"}] + service._squads_cache._data["list"] = (time.monotonic() - 1, stale_squads) + service._get_internal_squads_uncached = AsyncMock(return_value=None) + + squads = await service.get_internal_squads() + + self.assertEqual(squads, stale_squads) + service._get_internal_squads_uncached.assert_awaited_once() + async def test_update_user_details_does_not_log_full_response_by_default(self): service = self._make_service() service._request = AsyncMock(return_value={"response": {"uuid": "user-uuid"}}) diff --git a/tests/test_performance_caches.py b/tests/test_performance_caches.py index 6093678..1d23a03 100644 --- a/tests/test_performance_caches.py +++ b/tests/test_performance_caches.py @@ -42,6 +42,14 @@ class AsyncTTLCacheSingleflightTests(unittest.IsolatedAsyncioTestCase): class AsyncTTLCacheInvalidationTests(unittest.IsolatedAsyncioTestCase): + def test_get_stale_returns_expired_cacheable_value(self): + cache = AsyncTTLCache(ttl_seconds=60) + value = {"ok": True} + cache._data["same"] = (time.monotonic() - 1, value) + + self.assertIsNone(cache.get_fresh("same")) + self.assertEqual(cache.get_stale("same"), value) + async def test_invalidate_remote_deletes_single_redis_key(self): settings = SimpleNamespace(REDIS_URL="redis://example", REDIS_KEY_PREFIX="test") cache = AsyncTTLCache(ttl_seconds=60, settings=settings, namespace="bench") From 8f009c8cafa0eb853d387858f20914c468d66d3b Mon Sep 17 00:00:00 2001 From: 3252a8 <3252a8@proton.me> Date: Tue, 2 Jun 2026 12:33:59 +0300 Subject: [PATCH 13/58] fix: show email avatars without Telegram --- frontend/src/App.svelte | 4 ++-- frontend/src/lib/webapp/gravatar.js | 17 +++++++++++++---- 2 files changed, 15 insertions(+), 6 deletions(-) diff --git a/frontend/src/App.svelte b/frontend/src/App.svelte index 6c0eb53..c50d228 100644 --- a/frontend/src/App.svelte +++ b/frontend/src/App.svelte @@ -78,7 +78,7 @@ activationPaymentFailed, createActivationHandoff, } from "./lib/webapp/activationHandoff.js"; - import { buildGravatarUrl } from "./lib/webapp/gravatar.js"; + import { buildGravatarUrl, resolveProfileAvatarUrl } from "./lib/webapp/gravatar.js"; import { createBillingActions } from "./lib/webapp/billingActions.js"; import { invalidateWebappTariffOptionCaches } from "./lib/webapp/billingOptionCache.js"; import { runWebappBoot } from "./lib/webapp/webappBoot.js"; @@ -494,7 +494,7 @@ $: telegramProfileName = telegramName(user); $: profileEmail = user?.email || t("wa_settings_email_not_linked"); $: profileTelegramId = user?.telegram_id ? `TG ID ${user.telegram_id}` : t("wa_tg_id_not_linked"); - $: profileAvatarUrl = user?.telegram_photo_url || emailAvatarUrl || ""; + $: profileAvatarUrl = resolveProfileAvatarUrl(user, emailAvatarUrl); $: privacyPolicyUrl = String(CFG.privacyPolicyUrl || "").trim(); $: userAgreementUrl = String(CFG.userAgreementUrl || "").trim(); $: supportUrl = String(appSettings?.support_url || CFG.supportUrl || "").trim(); diff --git a/frontend/src/lib/webapp/gravatar.js b/frontend/src/lib/webapp/gravatar.js index bf7bfef..e89b18a 100644 --- a/frontend/src/lib/webapp/gravatar.js +++ b/frontend/src/lib/webapp/gravatar.js @@ -4,16 +4,25 @@ function bytesToHex(buffer) { async function sha256Hex(value) { const data = new TextEncoder().encode(value); - const hashBuffer = await window.crypto.subtle.digest("SHA-256", data); + const hashBuffer = await globalThis.crypto?.subtle?.digest("SHA-256", data); return bytesToHex(hashBuffer); } export async function buildGravatarUrl(emailValue) { - if (!emailValue || !window.crypto?.subtle) return ""; + const email = String(emailValue || "") + .trim() + .toLowerCase(); + if (!email || !globalThis.crypto?.subtle) return ""; try { - const hash = await sha256Hex(emailValue); - return `https://www.gravatar.com/avatar/${hash}?d=mp&s=160`; + const hash = await sha256Hex(email); + return `https://www.gravatar.com/avatar/${hash}?d=identicon&s=160`; } catch { return ""; } } + +export function resolveProfileAvatarUrl(user, emailAvatarUrl = "") { + const telegramAvatar = String(user?.telegram_photo_url || "").trim(); + if (user?.telegram_linked && telegramAvatar) return telegramAvatar; + return String(emailAvatarUrl || "").trim(); +} From ee840a7e6d76e0ce5d953797b3fd60cf12558d75 Mon Sep 17 00:00:00 2001 From: 3252a8 <3252a8@proton.me> Date: Tue, 2 Jun 2026 12:43:57 +0300 Subject: [PATCH 14/58] fix(admin): keep mobile extend button full height --- frontend/src/styles/admin-controls.css | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/frontend/src/styles/admin-controls.css b/frontend/src/styles/admin-controls.css index ae0934d..1b2f739 100644 --- a/frontend/src/styles/admin-controls.css +++ b/frontend/src/styles/admin-controls.css @@ -330,6 +330,23 @@ background: color-mix(in srgb, var(--accent) 90%, #fff); } +@media (max-width: 640px) { + .admin-extend-control .input, + .admin-extend-control .admin-btn { + height: 46px; + min-height: 46px; + } + + .admin-extend-control .input { + line-height: 46px; + } + + .admin-extend-control .admin-btn { + width: 100%; + border-width: 1px 0 0; + } +} + .admin-input-row .input, .admin-input-row .admin-btn { height: 36px; From 1ee8af338c0f7654c4df5ae57d6e0cd5f5f31f70 Mon Sep 17 00:00:00 2001 From: 3252a8 <3252a8@proton.me> Date: Tue, 2 Jun 2026 12:57:15 +0300 Subject: [PATCH 15/58] fix(admin): show trial history and log activations --- backend/bot/app/web/admin_api_impl/common.py | 7 +- backend/bot/app/web/admin_api_impl/users.py | 29 +++++ backend/bot/app/web/webapp/billing.py | 23 ++++ .../src/admin/sections/UserDetailModal.svelte | 56 ++++++++- tests/test_admin_user_reset_trial.py | 65 ++++++++++ tests/test_webapp_trial_activation.py | 117 ++++++++++++++++++ 6 files changed, 293 insertions(+), 4 deletions(-) create mode 100644 tests/test_webapp_trial_activation.py diff --git a/backend/bot/app/web/admin_api_impl/common.py b/backend/bot/app/web/admin_api_impl/common.py index b43ac82..f469da7 100644 --- a/backend/bot/app/web/admin_api_impl/common.py +++ b/backend/bot/app/web/admin_api_impl/common.py @@ -94,6 +94,9 @@ def _serialize_subscription(sub: Subscription) -> Dict[str, Any]: regular_unlimited_override = bool(getattr(sub, "regular_unlimited_override", False)) premium_unlimited_override = bool(getattr(sub, "premium_unlimited_override", False)) premium_limit_bytes = _premium_limit_bytes_from_subscription(sub) + provider = sub.provider + is_trial = str(provider or "").strip().lower() == "trial" + display_label = "Trial" if is_trial else sub.tariff_key return { "subscription_id": int(sub.subscription_id), "panel_user_uuid": sub.panel_user_uuid, @@ -118,8 +121,10 @@ def _serialize_subscription(sub: Subscription) -> Dict[str, Any]: "premium_unlimited_override": premium_unlimited_override, "premium_is_limited": bool(sub.premium_is_limited), "tariff_key": sub.tariff_key, + "display_label": display_label, + "is_trial": is_trial, "auto_renew_enabled": bool(sub.auto_renew_enabled), - "provider": sub.provider, + "provider": provider, "is_throttled": bool(sub.is_throttled), } diff --git a/backend/bot/app/web/admin_api_impl/users.py b/backend/bot/app/web/admin_api_impl/users.py index 31587c4..ecb8e4d 100644 --- a/backend/bot/app/web/admin_api_impl/users.py +++ b/backend/bot/app/web/admin_api_impl/users.py @@ -745,6 +745,24 @@ def _user_search_condition(query: str): return or_(*conditions) +def _serialize_trial_summary(user: User, trial_subs: List[Subscription]) -> Dict[str, Any]: + first_trial_sub = trial_subs[0] if trial_subs else None + latest_trial_sub = trial_subs[-1] if trial_subs else None + first_start = getattr(first_trial_sub, "start_date", None) + latest_start = getattr(latest_trial_sub, "start_date", None) + latest_end = getattr(latest_trial_sub, "end_date", None) + reset_at = getattr(user, "trial_eligibility_reset_at", None) + return { + "used": bool(trial_subs), + "count": len(trial_subs), + "first_activated_at": first_start.isoformat() if first_start else None, + "latest_activated_at": latest_start.isoformat() if latest_start else None, + "latest_end_date": latest_end.isoformat() if latest_end else None, + "active": bool(latest_trial_sub and getattr(latest_trial_sub, "is_active", False)), + "last_reset_at": reset_at.isoformat() if reset_at else None, + } + + async def admin_user_detail_route(request: web.Request) -> web.Response: _require_admin_user_id(request) target_id = int(request.match_info["user_id"]) @@ -764,6 +782,15 @@ async def admin_user_detail_route(request: web.Request) -> web.Response: .limit(20) ) latest_subs = (await session.execute(latest_subs_stmt)).scalars().all() + trial_subs_stmt = ( + select(Subscription) + .where( + Subscription.user_id == target_id, + sa_func.lower(sa_func.coalesce(Subscription.provider, "")) == "trial", + ) + .order_by(Subscription.start_date.asc().nullslast(), Subscription.end_date.asc()) + ) + trial_subs = (await session.execute(trial_subs_stmt)).scalars().all() total_paid = await payment_dal.get_user_total_paid(session, target_id) recent_payments_stmt = ( select(Payment) @@ -830,12 +857,14 @@ async def admin_user_detail_route(request: web.Request) -> web.Response: serialized_inviter = ( _serialize_admin_user_with_avatar(inviter, avatar_keys) if inviter is not None else None ) + trial_payload = _serialize_trial_summary(user, trial_subs) return _ok( { "user": serialized_user, "active_subscription": _serialize_subscription(active_sub) if active_sub else None, "subscriptions": [_serialize_subscription(s) for s in (latest_subs or [])], + "trial": trial_payload, "total_paid": float(total_paid), "recent_payments": [_serialize_payment(p) for p in recent_payments], "log_count": int(log_count or 0), diff --git a/backend/bot/app/web/webapp/billing.py b/backend/bot/app/web/webapp/billing.py index 56189bc..6a3cf16 100644 --- a/backend/bot/app/web/webapp/billing.py +++ b/backend/bot/app/web/webapp/billing.py @@ -2,6 +2,7 @@ from ._runtime import * # noqa: F403,F405 from bot.app.web.webapp.cache_helpers import invalidate_webapp_user_caches +from db.dal import message_log_dal def _billing_iso_datetime(value: Optional[Any]) -> Optional[str]: @@ -395,6 +396,28 @@ async def activate_trial_route(request: web.Request) -> web.Response: except Exception: logger.exception("Failed to send WebApp trial activation notification") + try: + await message_log_dal.create_message_log_no_commit( + session, + { + "user_id": user_id, + "telegram_username": getattr(db_user, "username", None), + "telegram_first_name": getattr(db_user, "first_name", None), + "event_type": "webapp_trial_activate", + "content": ( + f"Trial activated via WebApp for user_id={user_id}; " + f"email={getattr(db_user, 'email', None) or 'N/A'}" + ), + "is_admin_event": False, + "target_user_id": user_id, + "timestamp": datetime.now(timezone.utc), + }, + ) + except Exception: + logger.exception("Failed to add WebApp trial activation audit log") + + await session.commit() + try: from db.dal import ad_dal as _ad_dal diff --git a/frontend/src/admin/sections/UserDetailModal.svelte b/frontend/src/admin/sections/UserDetailModal.svelte index f0ecc1d..35a36f2 100644 --- a/frontend/src/admin/sections/UserDetailModal.svelte +++ b/frontend/src/admin/sections/UserDetailModal.svelte @@ -53,6 +53,26 @@ return String(val ?? "—"); } + function isTrialSubscription(sub) { + return Boolean(sub?.is_trial || String(sub?.provider || "").toLowerCase() === "trial"); + } + + function subscriptionDisplayLabel(sub) { + if (!sub) return "—"; + if (isTrialSubscription(sub)) return at("user_subscription_trial", {}, "Триал"); + if (sub.display_label) return sub.display_label; + return sub.tariff_name || sub.tariff_key || at("user_history_no_tariff", {}, "Без тарифа"); + } + + function trialSummaryText(trial) { + if (!trial?.used) return at("user_trial_not_used", {}, "Не брал"); + const date = trial.latest_activated_at || trial.first_activated_at; + const base = date + ? at("user_trial_used_at", { date: fmtDate(date) }, `Брал ${fmtDate(date)}`) + : at("user_trial_used", {}, "Брал"); + return trial.active ? `${base} · ${at("user_trial_active", {}, "активен")}` : base; + } + const usersStore = getContext("usersStore"); $: ({ @@ -404,7 +424,7 @@
  • {at("user_label_tariff", {}, "Тариф")}{openedUserDetail.active_subscription.tariff_key || "—"}{subscriptionDisplayLabel(openedUserDetail.active_subscription)}
  • @@ -507,6 +527,37 @@

    {/if} + {#if openedUserDetail?.trial} +
      +
    • + {at("user_label_trial", {}, "Пробник / триал")}{trialSummaryText(openedUserDetail.trial)} +
    • + {#if openedUserDetail.trial.used && openedUserDetail.trial.latest_end_date} +
    • + {at("user_label_trial_until", {}, "Триал до")}{fmtDate(openedUserDetail.trial.latest_end_date)} +
    • + {/if} + {#if Number(openedUserDetail.trial.count || 0) > 1} +
    • + {at("user_label_trial_count", {}, "Триалов")}{openedUserDetail.trial.count} +
    • + {/if} + {#if openedUserDetail.trial.last_reset_at} +
    • + {at("user_label_trial_reset_at", {}, "Сброс триала")}{fmtDate(openedUserDetail.trial.last_reset_at)} +
    • + {/if} +
    + {/if} + {#if (openedUserDetail.subscriptions || []).length}
    @@ -521,8 +572,7 @@
    {sub.tariff_key || - at("user_history_no_tariff", {}, "Без тарифа")}{subscriptionDisplayLabel(sub)} {at( diff --git a/tests/test_admin_user_reset_trial.py b/tests/test_admin_user_reset_trial.py index 8954bc2..c24a7e2 100644 --- a/tests/test_admin_user_reset_trial.py +++ b/tests/test_admin_user_reset_trial.py @@ -1,8 +1,10 @@ import json import unittest +from datetime import datetime, timezone from types import SimpleNamespace from unittest.mock import AsyncMock, patch +from bot.app.web.admin_api_impl import common as admin_common from bot.app.web.admin_api_impl import users as admin_users @@ -73,5 +75,68 @@ class AdminUserResetTrialRouteTests(unittest.IsolatedAsyncioTestCase): self.assertFalse(session.rolled_back) +class AdminUserTrialPresentationTests(unittest.TestCase): + def test_trial_subscription_serializes_display_label(self): + start_at = datetime(2026, 1, 2, 3, 4, tzinfo=timezone.utc) + end_at = datetime(2026, 1, 9, 3, 4, tzinfo=timezone.utc) + sub = SimpleNamespace( + subscription_id=7, + panel_user_uuid="panel-user", + panel_subscription_uuid=None, + start_date=start_at, + end_date=end_at, + duration_months=None, + is_active=False, + status_from_panel="EXPIRED", + traffic_limit_bytes=10, + traffic_used_bytes=2, + tier_baseline_bytes=0, + topup_balance_bytes=0, + premium_used_bytes=0, + premium_baseline_bytes=0, + premium_topup_balance_bytes=0, + premium_topup_used_bytes=0, + premium_bonus_bytes=0, + regular_bonus_bytes=0, + regular_unlimited_override=False, + premium_unlimited_override=False, + premium_is_limited=False, + tariff_key=None, + auto_renew_enabled=False, + provider="trial", + is_throttled=False, + ) + + payload = admin_common._serialize_subscription(sub) + + self.assertTrue(payload["is_trial"]) + self.assertEqual(payload["display_label"], "Trial") + self.assertIsNone(payload["tariff_key"]) + + def test_trial_summary_includes_usage_dates_and_reset_marker(self): + first_at = datetime(2026, 1, 2, 3, 4, tzinfo=timezone.utc) + latest_at = datetime(2026, 2, 3, 4, 5, tzinfo=timezone.utc) + latest_end = datetime(2026, 2, 10, 4, 5, tzinfo=timezone.utc) + reset_at = datetime(2026, 3, 1, tzinfo=timezone.utc) + user = SimpleNamespace(trial_eligibility_reset_at=reset_at) + trial_subs = [ + SimpleNamespace( + start_date=first_at, + end_date=datetime(2026, 1, 9, tzinfo=timezone.utc), + ), + SimpleNamespace(start_date=latest_at, end_date=latest_end, is_active=True), + ] + + payload = admin_users._serialize_trial_summary(user, trial_subs) + + self.assertTrue(payload["used"]) + self.assertTrue(payload["active"]) + self.assertEqual(payload["count"], 2) + self.assertEqual(payload["first_activated_at"], first_at.isoformat()) + self.assertEqual(payload["latest_activated_at"], latest_at.isoformat()) + self.assertEqual(payload["latest_end_date"], latest_end.isoformat()) + self.assertEqual(payload["last_reset_at"], reset_at.isoformat()) + + if __name__ == "__main__": unittest.main() diff --git a/tests/test_webapp_trial_activation.py b/tests/test_webapp_trial_activation.py new file mode 100644 index 0000000..f073a5c --- /dev/null +++ b/tests/test_webapp_trial_activation.py @@ -0,0 +1,117 @@ +import json +from datetime import datetime, timezone +from types import SimpleNamespace +from unittest import IsolatedAsyncioTestCase +from unittest.mock import AsyncMock, patch + +import bot.app.web.subscription_webapp # noqa: F401 +from bot.app.web.webapp import billing as billing_module + + +class _Session: + def __init__(self): + self.commit_count = 0 + self.rollback_count = 0 + + async def __aenter__(self): + return self + + async def __aexit__(self, exc_type, exc, tb): + return False + + async def commit(self): + self.commit_count += 1 + + async def rollback(self): + self.rollback_count += 1 + + +class _SessionFactory: + def __init__(self, session): + self.session = session + + def __call__(self): + return self.session + + +class WebAppTrialActivationTests(IsolatedAsyncioTestCase): + async def test_email_only_trial_activation_is_written_to_admin_logs(self): + session = _Session() + end_date = datetime(2026, 1, 9, 3, 4, tzinfo=timezone.utc) + settings = SimpleNamespace( + TRIAL_ENABLED=True, + TRIAL_DURATION_DAYS=7, + TRIAL_TRAFFIC_LIMIT_GB=10, + LOG_TRIAL_ACTIVATIONS=False, + ) + db_user = SimpleNamespace( + user_id=42, + is_banned=False, + username=None, + first_name=None, + email="email-only@example.com", + ) + subscription_service = SimpleNamespace( + activate_trial_subscription=AsyncMock( + return_value={ + "activated": True, + "days": 7, + "end_date": end_date, + "traffic_gb": 10, + "subscription_url": "https://panel.example/sub", + } + ) + ) + request = SimpleNamespace( + app={ + "settings": settings, + "async_session_factory": _SessionFactory(session), + "subscription_service": subscription_service, + } + ) + + 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.user_dal, + "get_user_by_id", + AsyncMock(return_value=db_user), + ), + patch.object( + billing_module, + "prepare_config_links", + AsyncMock(return_value=("https://panel.example/sub", "https://connect.example")), + ), + patch.object( + billing_module.message_log_dal, + "create_message_log_no_commit", + AsyncMock(), + ) as create_log, + patch.object( + billing_module, + "invalidate_webapp_user_caches", + AsyncMock(), + ), + patch("db.dal.ad_dal.mark_trial_activated", AsyncMock()) as mark_trial_activated, + ): + response = await billing_module.activate_trial_route(request) + + payload = json.loads(response.text) + self.assertEqual(response.status, 200) + self.assertTrue(payload["activated"]) + subscription_service.activate_trial_subscription.assert_awaited_once_with(session, 42) + create_log.assert_awaited_once() + log_payload = create_log.await_args.args[1] + self.assertEqual(log_payload["user_id"], 42) + self.assertEqual(log_payload["target_user_id"], 42) + self.assertEqual(log_payload["event_type"], "webapp_trial_activate") + self.assertFalse(log_payload["is_admin_event"]) + self.assertIn("email-only@example.com", log_payload["content"]) + mark_trial_activated.assert_awaited_once_with(session, 42) + self.assertEqual(session.commit_count, 2) + self.assertEqual(session.rollback_count, 0) From 4263cb7c99b88e10432809ba0c5ecd20a258f6ed Mon Sep 17 00:00:00 2001 From: 3252a8 <3252a8@proton.me> Date: Tue, 2 Jun 2026 14:03:14 +0300 Subject: [PATCH 16/58] fix(webapp): show expiring subscription countdown --- frontend/src/styles/webapp.css | 25 +++++++ frontend/src/webapp/screens/HomeScreen.svelte | 65 ++++++++++++++++++- 2 files changed, 87 insertions(+), 3 deletions(-) diff --git a/frontend/src/styles/webapp.css b/frontend/src/styles/webapp.css index ec2bae6..24d631b 100644 --- a/frontend/src/styles/webapp.css +++ b/frontend/src/styles/webapp.css @@ -233,6 +233,31 @@ a { color: var(--danger); } +.status-card-warning { + border-color: var(--warning-border); + background: + linear-gradient( + 135deg, + color-mix(in srgb, var(--warning) 14%, var(--surface-sheen-soft)), + color-mix(in srgb, var(--warning) 8%, var(--surface-sheen-soft)) + ), + var(--panel); + box-shadow: + var(--shadow-soft), + 0 0 0 1px color-mix(in srgb, var(--warning) 20%, transparent), + inset 0 1px 0 var(--inset-highlight); +} + +.status-card-warning .sub-status { + color: var(--warning-text); +} + +.status-card-warning .subscription-end-line { + color: var(--warning-text); + font-variant-numeric: tabular-nums; + opacity: 1; +} + .sub-status-inactive { min-height: 0; justify-content: flex-start; diff --git a/frontend/src/webapp/screens/HomeScreen.svelte b/frontend/src/webapp/screens/HomeScreen.svelte index 429ee1f..bce6606 100644 --- a/frontend/src/webapp/screens/HomeScreen.svelte +++ b/frontend/src/webapp/screens/HomeScreen.svelte @@ -1,4 +1,5 @@ + +
    + {#each items as item, index (getKey(item, index))} +
    handleDragOver(event, index)} + on:drop={(event) => handleDrop(event, index)} + on:dragend={reset} + > + + +
    + {/each} +
    + + diff --git a/frontend/src/styles/admin.css b/frontend/src/styles/admin.css index b33de09..346f67a 100644 --- a/frontend/src/styles/admin.css +++ b/frontend/src/styles/admin.css @@ -3572,6 +3572,18 @@ minmax(120px, 1fr) 32px; } +/* Rows with a leading drag handle (Sortable) so the purchase order can be + reordered. The 24px column lines up with the handle the Sortable renders. */ +.admin-row-editor-line.admin-row-editor-period { + grid-template-columns: + 24px minmax(72px, 0.8fr) minmax(90px, 1fr) minmax(90px, 1fr) minmax(120px, 1fr) + minmax(120px, 1fr) 32px; +} + +.admin-row-editor-line.admin-row-editor-drag { + grid-template-columns: 24px minmax(90px, 1fr) minmax(110px, 1fr) 32px; +} + .admin-package-columns { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); @@ -3790,7 +3802,9 @@ .admin-package-columns, .admin-row-editor-line, .admin-row-editor-line.admin-row-editor-4, - .admin-row-editor-line.admin-row-editor-6 { + .admin-row-editor-line.admin-row-editor-6, + .admin-row-editor-line.admin-row-editor-period, + .admin-row-editor-line.admin-row-editor-drag { grid-template-columns: 1fr; } diff --git a/locales/en.json b/locales/en.json index d952774..26a886d 100644 --- a/locales/en.json +++ b/locales/en.json @@ -2074,9 +2074,11 @@ "admin_tariff_provider_any_currency": "Any", "admin_tariff_provider_not_declared": "Not declared", "admin_tariff_pricing_empty": "Add at least one period so the tariff appears in the storefront.", - "admin_tariff_pricing_period_subtitle": "Each row is a separate storefront option: how many months the user pays for and how much it costs", + "admin_tariff_pricing_period_subtitle": "Each row is a separate storefront option: how many months the user pays for and how much it costs. Drag rows by the handle to set the period order in the bot and the web app", "admin_tariff_pricing_period_title": "Subscription periods and prices", - "admin_tariff_pricing_traffic_subtitle": "Base storefront for the traffic model. Each row is an \"N gigabytes for N currency units\" package", + "admin_tariff_period_reorder": "Drag to reorder", + "admin_tariff_package_reorder": "Drag to reorder", + "admin_tariff_pricing_traffic_subtitle": "Base storefront for the traffic model. Each row is an \"N gigabytes for N currency units\" package. Drag rows by the handle to set the package order in the bot and the web app", "admin_tariff_pricing_traffic_title": "Traffic packages", "admin_tariff_saved": "Tariff saved", "admin_tariff_status_updated": "Tariff status updated", diff --git a/locales/ru.json b/locales/ru.json index 2620ce6..0835d68 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -2074,9 +2074,11 @@ "admin_tariff_provider_any_currency": "Любая", "admin_tariff_provider_not_declared": "Не задано", "admin_tariff_pricing_empty": "Добавьте хотя бы один период — без него тариф не появится на витрине.", - "admin_tariff_pricing_period_subtitle": "Каждая строка — отдельный вариант на витрине: за сколько месяцев пользователь платит и сколько это стоит", + "admin_tariff_pricing_period_subtitle": "Каждая строка — отдельный вариант на витрине: за сколько месяцев пользователь платит и сколько это стоит. Перетаскивайте строки за рукоятку, чтобы задать порядок периодов в боте и веб-приложении", "admin_tariff_pricing_period_title": "Периоды подписки и цены", - "admin_tariff_pricing_traffic_subtitle": "Базовая витрина для трафиковой модели. Каждая строка — пакет «N гигабайт за N единиц валюты»", + "admin_tariff_period_reorder": "Перетащите, чтобы изменить порядок", + "admin_tariff_package_reorder": "Перетащите, чтобы изменить порядок", + "admin_tariff_pricing_traffic_subtitle": "Базовая витрина для трафиковой модели. Каждая строка — пакет «N гигабайт за N единиц валюты». Перетаскивайте строки за рукоятку, чтобы задать порядок пакетов в боте и веб-приложении", "admin_tariff_pricing_traffic_title": "Пакеты трафика", "admin_tariff_saved": "Тариф сохранён", "admin_tariff_status_updated": "Статус тарифа обновлён", diff --git a/tests/test_webapp_assets.py b/tests/test_webapp_assets.py index 7365058..8c92a7d 100644 --- a/tests/test_webapp_assets.py +++ b/tests/test_webapp_assets.py @@ -85,6 +85,87 @@ class WebAppAssetTests(unittest.IsolatedAsyncioTestCase): self.assertEqual(plans[1]["traffic_gb"], 50.0) self.assertEqual(plans[1]["stars_price"], 2500) + def test_serialize_plans_preserves_enabled_period_order(self): + with tempfile.TemporaryDirectory() as tmpdir: + path = Path(tmpdir) / "tariffs.json" + path.write_text( + json.dumps( + { + "default_tariff": "standard", + "tariffs": [ + { + "key": "standard", + "names": {"en": "Standard"}, + "descriptions": {"en": "Custom order"}, + "squad_uuids": ["uuid"], + "billing_model": "period", + "monthly_gb": 100, + "prices_rub": {"1": 150, "3": 400, "6": 700, "12": 1200}, + "prices_stars": {}, + # Deliberately unsorted: the storefront must follow this order. + "enabled_periods": [12, 1, 6, 3], + "enabled": True, + } + ], + } + ), + encoding="utf-8", + ) + settings = Settings( + _env_file=None, + BOT_TOKEN="token", + POSTGRES_USER="app_user", + POSTGRES_PASSWORD="app_password", + TARIFFS_CONFIG_PATH=str(path), + ) + + plans = subscription_webapp._serialize_plans(settings, "en") + + self.assertEqual([plan["months"] for plan in plans], [12, 1, 6, 3]) + + def test_serialize_plans_preserves_traffic_package_order(self): + with tempfile.TemporaryDirectory() as tmpdir: + path = Path(tmpdir) / "tariffs.json" + path.write_text( + json.dumps( + { + "default_tariff": "traffic", + "tariffs": [ + { + "key": "traffic", + "names": {"en": "Traffic"}, + "descriptions": {"en": "Pay as you go"}, + "squad_uuids": ["uuid"], + "billing_model": "traffic", + "traffic_packages": { + # Deliberately unsorted by volume. + "rub": [ + {"gb": 100, "price": 999}, + {"gb": 10, "price": 199}, + {"gb": 50, "price": 599}, + ], + "stars": [{"gb": 250, "price": 2500}], + }, + "enabled": True, + } + ], + } + ), + encoding="utf-8", + ) + settings = Settings( + _env_file=None, + BOT_TOKEN="token", + POSTGRES_USER="app_user", + POSTGRES_PASSWORD="app_password", + TARIFFS_CONFIG_PATH=str(path), + ) + + plans = subscription_webapp._serialize_plans(settings, "en") + + # default-currency order first, then Stars-only volumes appended. + self.assertEqual([plan["traffic_gb"] for plan in plans], [100.0, 10.0, 50.0, 250.0]) + def test_referral_bonus_details_use_custom_tariff_periods(self): with tempfile.TemporaryDirectory() as tmpdir: path = Path(tmpdir) / "tariffs.json" From 101119911a6619fe4cb1f9a1b4645d75ab195619 Mon Sep 17 00:00:00 2001 From: 3252a8 <3252a8@proton.me> Date: Wed, 3 Jun 2026 10:55:52 +0300 Subject: [PATCH 31/58] refactor(webapp): remove emoji web app logo option MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drop the emoji-logo feature (and its font picker) from the Web App. Only an uploaded/linked image logo and favicon remain; when no logo is set, the default project logo is shown. Existing emoji-logo overrides are ignored — the keys are gone from the manifest, so the override service skips them and the app falls back to the default logo. - Remove WEBAPP_LOGO_USE_EMOJI / WEBAPP_LOGO_EMOJI / WEBAPP_LOGO_EMOJI_FONT settings, validators, manifest entries and override/runtime plumbing. - Strip the animated-emoji fetch/cache subsystem, the /webapp-emoji route and emoji branches from logo/favicon resolution; leftover emoji cache files are now purged on appearance save. - Simplify BrandMark to an image-only component and drop the emoji UI from the admin Appearance section. - Regenerate the demo settings manifest and clean docs, locales, nginx and demo data of emoji-logo references. --- README.md | 4 +- backend/bot/app/web/admin_api_impl/themes.py | 19 +- .../app/web/admin_api_impl/webapp_runtime.py | 3 - .../bot/app/web/admin_settings_manifest.py | 20 - backend/bot/app/web/webapp/_runtime.py | 2 - backend/bot/app/web/webapp/application.py | 1 - backend/bot/app/web/webapp/assets.py | 167 ------ backend/bot/app/web/webapp/routes.py | 4 - backend/bot/services/email_templates.py | 2 - .../bot/services/settings_override_service.py | 9 - backend/config/settings.py | 30 -- deploy/docker/frontend/nginx.conf | 2 +- docs-site/scripts/generate-email-previews.py | 1 - docs/configuration/env-vars.md | 3 - docs/features/admin-panel.md | 6 +- docs/features/web-app.md | 2 +- docs/features/webapp-themes.md | 3 +- docs/getting-started/configuration.md | 2 +- docs/getting-started/deployment.md | 2 +- frontend/src/App.svelte | 9 +- frontend/src/PreviewBoard.svelte | 12 +- .../admin/sections/AppearanceSection.svelte | 124 +---- .../webapp/TicketMessageBubble.svelte | 2 +- frontend/src/lib/webapp/BrandMark.svelte | 195 +------ frontend/src/lib/webapp/browser.js | 37 +- frontend/src/lib/webapp/demoDataset.js | 480 +----------------- frontend/src/lib/webapp/mockApi.js | 36 -- frontend/src/lib/webapp/previewMock.js | 3 - .../webapp/settingsManifest.generated.json | 104 ---- locales/en.json | 17 +- locales/ru.json | 17 +- tests/test_email_localization.py | 1 - tests/test_locale_overrides.py | 1 - tests/test_security.py | 1 - tests/test_settings.py | 7 - ...st_subscription_lifecycle_notifications.py | 1 - tests/test_user_email_notifications.py | 1 - tests/test_webapp_assets.py | 75 +-- tests/test_webapp_route_contract.py | 1 - 39 files changed, 50 insertions(+), 1356 deletions(-) diff --git a/README.md b/README.md index 96c9ad9..9444857 100644 --- a/README.md +++ b/README.md @@ -98,10 +98,10 @@ docker compose logs -f backend worker frontend Для каталога тарифов используется `TARIFFS_CONFIG_PATH` со значением по умолчанию `data/tariffs.json`. Пример формата лежит в [data/tariffs.example.json](data/tariffs.example.json), подробности - в [docs/features/tariffs.md](docs/features/tariffs.md). -В compose-примерах `/app/data` монтируется из папки `./data` рядом с `docker-compose.yml`. Заранее создайте каталог и отдайте его пользователю контейнера. Это нужно для сохранения `data/tariffs.json`, каталога тем `data/themes`, кеша логотипа Web App и animated emoji: +В compose-примерах `/app/data` монтируется из папки `./data` рядом с `docker-compose.yml`. Заранее создайте каталог и отдайте его пользователю контейнера. Это нужно для сохранения `data/tariffs.json`, каталога тем `data/themes` и кеша логотипа Web App: ```bash -mkdir -p data/themes data/webapp-logo data/webapp-emoji +mkdir -p data/themes data/webapp-logo touch data/locales-overrides.json chown -R 10001:10001 data chmod -R u+rwX data diff --git a/backend/bot/app/web/admin_api_impl/themes.py b/backend/bot/app/web/admin_api_impl/themes.py index 21efbe4..b68ed08 100644 --- a/backend/bot/app/web/admin_api_impl/themes.py +++ b/backend/bot/app/web/admin_api_impl/themes.py @@ -136,10 +136,6 @@ def _favicon_digest(url: str) -> Optional[str]: return match.group(1) if match else None -def _emoji_to_codepoints(value: str) -> str: - return "_".join(f"{ord(char):x}" for char in str(value or "").strip()) - - def prune_unused_appearance_assets(settings: Settings) -> None: keep_logos = { filename @@ -156,15 +152,6 @@ def prune_unused_appearance_assets(settings: Settings) -> None: ] if digest } - keep_emoji_prefixes = set() - if ( - getattr(settings, "WEBAPP_LOGO_USE_EMOJI", False) - and str(getattr(settings, "WEBAPP_LOGO_EMOJI_FONT", "") or "").strip() - == "noto-color-animated" - ): - codepoints = _emoji_to_codepoints(getattr(settings, "WEBAPP_LOGO_EMOJI", "")) - if codepoints: - keep_emoji_prefixes.add(f"{codepoints}.512.") for path in WEBAPP_UPLOADED_LOGO_DIR.glob("logo-*"): if path.is_file() and path.name not in keep_logos: @@ -184,10 +171,9 @@ def prune_unused_appearance_assets(settings: Settings) -> None: except OSError: logger.warning("Failed to remove unused webapp favicon set %s", path, exc_info=True) + # Emoji logos were removed; purge any leftover animated-emoji cache files. for path in WEBAPP_EMOJI_CACHE_DIR.glob("*.512.*"): - if path.is_file() and not any( - path.name.startswith(prefix) for prefix in keep_emoji_prefixes - ): + if path.is_file(): try: path.unlink() except OSError: @@ -389,7 +375,6 @@ async def admin_appearance_logo_upload_route(request: web.Request) -> web.Respon request, { "WEBAPP_LOGO_URL": logo_url, - "WEBAPP_LOGO_USE_EMOJI": False, **( {"WEBAPP_LOGO_FAVICON_URL": favicon_payload["favicon_url"]} if favicon_payload.get("favicon_url") diff --git a/backend/bot/app/web/admin_api_impl/webapp_runtime.py b/backend/bot/app/web/admin_api_impl/webapp_runtime.py index 83a8f57..00d98e3 100644 --- a/backend/bot/app/web/admin_api_impl/webapp_runtime.py +++ b/backend/bot/app/web/admin_api_impl/webapp_runtime.py @@ -13,9 +13,6 @@ WEBAPP_APPEARANCE_SETTING_KEYS = frozenset( { "WEBAPP_TITLE", "WEBAPP_LOGO_URL", - "WEBAPP_LOGO_USE_EMOJI", - "WEBAPP_LOGO_EMOJI", - "WEBAPP_LOGO_EMOJI_FONT", "WEBAPP_FAVICON_URL", "WEBAPP_FAVICON_USE_CUSTOM", "WEBAPP_LOGO_FAVICON_URL", diff --git a/backend/bot/app/web/admin_settings_manifest.py b/backend/bot/app/web/admin_settings_manifest.py index 7545b32..8c06835 100644 --- a/backend/bot/app/web/admin_settings_manifest.py +++ b/backend/bot/app/web/admin_settings_manifest.py @@ -181,27 +181,7 @@ SETTINGS_MANIFEST: List[SettingField] = [ SettingField( "WEBAPP_PRIMARY_COLOR", "color", "appearance", "Основной цвет", placeholder="#00fe7a" ), - SettingField("WEBAPP_LOGO_USE_EMOJI", "bool", "appearance", "Использовать эмоджи-логотип"), SettingField("WEBAPP_LOGO_URL", "url", "appearance", "URL логотипа"), - SettingField("WEBAPP_LOGO_EMOJI", "string", "appearance", "Эмоджи-логотип", placeholder="🫥"), - SettingField( - "WEBAPP_LOGO_EMOJI_FONT", - "string", - "appearance", - "Шрифт эмоджи-логотипа", - "Выберите шрифт для отображения эмодзи-логотипа", - choices=( - ("system", "Системный (по умолчанию)"), - ("noto-color", "Noto Color Emoji"), - ("noto-color-animated", "Noto Color Emoji Animated"), - ("noto-emoji", "Noto Emoji"), - ("twemoji", "Twitter Emoji"), - ("openmoji", "OpenMoji"), - ("apple", "Apple Color Emoji (local)"), - ("segoe", "Segoe UI Emoji (local)"), - ("noto-local", "Noto Emoji (local)"), - ), - ), SettingField( "WEBAPP_FAVICON_USE_CUSTOM", "bool", diff --git a/backend/bot/app/web/webapp/_runtime.py b/backend/bot/app/web/webapp/_runtime.py index 4da0199..2a578f1 100644 --- a/backend/bot/app/web/webapp/_runtime.py +++ b/backend/bot/app/web/webapp/_runtime.py @@ -82,7 +82,6 @@ WEBAPP_DEFAULT_LOGO_PATH = "/webapp-default-logo.webp" WEBAPP_DEFAULT_FAVICON_DIGEST = "19b2a242e5b7bc2d" WEBAPP_DEFAULT_FAVICON_DIR = WEBAPP_DEFAULT_BRAND_DIR / "favicons" / WEBAPP_DEFAULT_FAVICON_DIGEST WEBAPP_DEFAULT_FAVICON_URL = f"{WEBAPP_FAVICON_PATH}/{WEBAPP_DEFAULT_FAVICON_DIGEST}/icon-180.png" -WEBAPP_EMOJI_CACHE_DIR = APP_ROOT / "data" / "webapp-emoji" WEBAPP_CONFIG_PLACEHOLDER = "" WEBAPP_I18N_PLACEHOLDER = "" WEBAPP_JS_PLACEHOLDER = "" @@ -92,7 +91,6 @@ DEV_MOCK_END_MARKER = "" WEBAPP_RATE_LIMIT_WINDOW_SECONDS = 60 WEBAPP_RATE_LIMIT_MAX_REQUESTS = 30 WEBAPP_LOGO_MAX_BYTES = 2 * 1024 * 1024 -WEBAPP_EMOJI_MAX_BYTES = 4 * 1024 * 1024 WEBAPP_THEME_CSS_MAX_BYTES = 512 * 1024 WEBAPP_THEME_ASSET_MAX_BYTES = 1024 * 1024 WEBAPP_THEME_ASSET_CONTENT_TYPES = { diff --git a/backend/bot/app/web/webapp/application.py b/backend/bot/app/web/webapp/application.py index 3dce8e2..a5ead66 100644 --- a/backend/bot/app/web/webapp/application.py +++ b/backend/bot/app/web/webapp/application.py @@ -33,7 +33,6 @@ def create_subscription_webapp_application( async def _startup(app_obj: web.Application) -> None: await _ensure_shared_http_session() await _warm_webapp_logo_cache(app_obj) - await _warm_webapp_animated_emoji_cache(app_obj) await warm_subscription_guides_config(app_obj) async def _shutdown(app_obj: web.Application) -> None: diff --git a/backend/bot/app/web/webapp/assets.py b/backend/bot/app/web/webapp/assets.py index cd53216..7b2aba8 100644 --- a/backend/bot/app/web/webapp/assets.py +++ b/backend/bot/app/web/webapp/assets.py @@ -206,9 +206,6 @@ async def theme_asset_route(request: web.Request) -> web.Response: def _resolve_webapp_logo_url(settings: Settings) -> str: - if getattr(settings, "WEBAPP_LOGO_USE_EMOJI", False): - return "" - raw_logo_url = (getattr(settings, "WEBAPP_LOGO_URL", None) or "").strip() if not raw_logo_url: return WEBAPP_DEFAULT_LOGO_PATH @@ -303,29 +300,8 @@ def _uploaded_webapp_logo_response(filename: str) -> web.Response: return response -def _emoji_to_codepoints(value: str) -> str: - return "_".join(f"{ord(char):x}" for char in str(value or "").strip()) - - -def _webapp_emoji_disk_path(codepoints: str, ext: str) -> Path: - return WEBAPP_EMOJI_CACHE_DIR / f"{codepoints}.512.{ext}" - - -def _webapp_animated_emoji_source_url(codepoints: str, ext: str) -> str: - return f"https://fonts.gstatic.com/s/e/notoemoji/latest/{codepoints}/512.{ext}" - - -def _webapp_animated_emoji_asset_path(emoji: str, ext: str = "gif") -> str: - codepoints = _emoji_to_codepoints(emoji) - if not codepoints or ext not in {"gif", "webp"}: - return "" - return f"/webapp-emoji/{codepoints}/512.{ext}" - - async def webapp_logo_route(request: web.Request) -> web.Response: settings: Settings = request.app["settings"] - if getattr(settings, "WEBAPP_LOGO_USE_EMOJI", False): - raise web.HTTPNotFound(text="webapp_logo_disabled") raw_logo_url = (settings.WEBAPP_LOGO_URL or "").strip() if not raw_logo_url: raise web.HTTPNotFound(text="webapp_logo_not_configured") @@ -521,37 +497,8 @@ def _webapp_default_brand_file_response(path: Path, content_type: str) -> web.Re return web.Response(body=body, content_type=content_type) -async def webapp_animated_emoji_route(request: web.Request) -> web.Response: - codepoints = str(request.match_info.get("codepoints") or "").strip().lower() - ext = str(request.match_info.get("ext") or "").strip().lower() - if not re.fullmatch(r"[0-9a-f]+(?:_[0-9a-f]+)*", codepoints) or ext not in {"gif", "webp"}: - raise web.HTTPNotFound(text="webapp_emoji_not_found") - - emoji_cache_key = f"{codepoints}:{ext}" - emoji_caches: Dict[str, Tuple[bytes, str]] = request.app.setdefault("webapp_emoji_cache", {}) - emoji_cache = emoji_caches.get(emoji_cache_key) - if emoji_cache is None: - cache_lock: asyncio.Lock = request.app.setdefault("webapp_emoji_cache_lock", asyncio.Lock()) - async with cache_lock: - emoji_cache = emoji_caches.get(emoji_cache_key) - if emoji_cache is None: - emoji_cache = await _load_or_fetch_webapp_animated_emoji(codepoints, ext) - if emoji_cache: - emoji_caches[emoji_cache_key] = emoji_cache - - if not emoji_cache: - raise web.HTTPNotFound(text="webapp_emoji_unavailable") - - body, content_type = emoji_cache - response = web.Response(body=body, content_type=content_type) - response.headers["Cache-Control"] = "public, max-age=31536000, immutable" - return response - - async def _warm_webapp_logo_cache(app: web.Application) -> None: settings: Settings = app["settings"] - if getattr(settings, "WEBAPP_LOGO_USE_EMOJI", False): - return raw_logo_url = (settings.WEBAPP_LOGO_URL or "").strip() if not raw_logo_url or not _is_proxyable_webapp_logo_url(raw_logo_url): return @@ -573,111 +520,6 @@ async def _warm_webapp_logo_cache(app: web.Application) -> None: ) -async def _warm_webapp_animated_emoji_cache(app: web.Application) -> None: - settings: Settings = app["settings"] - if not getattr(settings, "WEBAPP_LOGO_USE_EMOJI", False): - return - if str(settings.WEBAPP_LOGO_EMOJI_FONT or "").strip() != "noto-color-animated": - return - - codepoints = _emoji_to_codepoints(settings.WEBAPP_LOGO_EMOJI) - if not codepoints: - return - - app.setdefault("webapp_emoji_cache", {}) - app.setdefault("webapp_emoji_cache_lock", asyncio.Lock()) - emoji_caches: Dict[str, Tuple[bytes, str]] = app["webapp_emoji_cache"] - - for ext in ("gif", "webp"): - emoji_cache_key = f"{codepoints}:{ext}" - if emoji_cache_key in emoji_caches: - continue - loaded_emoji = await _load_or_fetch_webapp_animated_emoji(codepoints, ext) - if loaded_emoji: - emoji_caches[emoji_cache_key] = loaded_emoji - if ext == "gif": - return - - -async def _load_or_fetch_webapp_animated_emoji( - codepoints: str, ext: str -) -> Optional[Tuple[bytes, str]]: - disk_emoji = await asyncio.to_thread(_read_webapp_animated_emoji_from_disk, codepoints, ext) - if disk_emoji: - return disk_emoji - - fetched_emoji = await _fetch_webapp_animated_emoji(codepoints, ext) - if fetched_emoji: - await asyncio.to_thread( - _write_webapp_animated_emoji_to_disk, codepoints, ext, fetched_emoji - ) - return fetched_emoji - - -def _read_webapp_animated_emoji_from_disk(codepoints: str, ext: str) -> Optional[Tuple[bytes, str]]: - path = _webapp_emoji_disk_path(codepoints, ext) - try: - body = path.read_bytes() - except OSError: - return None - - if not body or len(body) > WEBAPP_EMOJI_MAX_BYTES: - return None - return body, "image/gif" if ext == "gif" else "image/webp" - - -def _write_webapp_animated_emoji_to_disk( - codepoints: str, ext: str, emoji: Tuple[bytes, str] -) -> None: - body, _content_type = emoji - if not body or len(body) > WEBAPP_EMOJI_MAX_BYTES: - return - - path = _webapp_emoji_disk_path(codepoints, ext) - try: - WEBAPP_EMOJI_CACHE_DIR.mkdir(parents=True, exist_ok=True) - path.write_bytes(body) - except OSError as exc: - logger.warning("Failed to write WEBAPP animated emoji cache: %s", exc) - - -async def _fetch_webapp_animated_emoji(codepoints: str, ext: str) -> Optional[Tuple[bytes, str]]: - try: - session = await _get_shared_http_session() - timeout = ClientTimeout(total=4) - source_url = _webapp_animated_emoji_source_url(codepoints, ext) - async with session.get( - source_url, - allow_redirects=False, - headers={"Accept": "image/gif,image/webp,image/*,*/*;q=0.8"}, - timeout=timeout, - ) as response: - if response.status != 200: - return None - - content_type = ( - (response.headers.get("Content-Type") or "").split(";", 1)[0].strip().lower() - ) - expected_content_type = "image/gif" if ext == "gif" else "image/webp" - if content_type and content_type != expected_content_type: - return None - - body = bytearray() - async for chunk in response.content.iter_chunked(64 * 1024): - body.extend(chunk) - if len(body) > WEBAPP_EMOJI_MAX_BYTES: - logger.warning("WEBAPP animated emoji exceeded the 4 MiB limit.") - return None - - if not body: - return None - - return bytes(body), expected_content_type - except Exception as exc: - logger.warning("Failed to fetch WEBAPP animated emoji: %s", exc) - return None - - async def _load_or_fetch_webapp_logo(logo_url: str) -> Optional[Tuple[bytes, str]]: disk_logo = await asyncio.to_thread(_read_webapp_logo_from_disk, logo_url) if disk_logo: @@ -1144,9 +986,6 @@ def _build_webapp_bootstrap_payload(request: web.Request) -> Dict[str, Any]: "themesDir": settings.WEBAPP_THEMES_DIR, "themePreviewKey": preview_key, "logoUrl": cached["logo_url"], - "logoUseEmoji": bool(settings.WEBAPP_LOGO_USE_EMOJI), - "logoEmoji": settings.WEBAPP_LOGO_EMOJI, - "logoEmojiFont": settings.WEBAPP_LOGO_EMOJI_FONT, "faviconUrl": cached["favicon_url"], "faviconUseCustom": bool(settings.WEBAPP_FAVICON_USE_CUSTOM), "apiBase": "/api", @@ -1308,12 +1147,6 @@ async def index_route(request: web.Request) -> web.Response: f'', ) brand_asset_url = cached["logo_url"] - if ( - not brand_asset_url - and settings.WEBAPP_LOGO_USE_EMOJI - and settings.WEBAPP_LOGO_EMOJI_FONT == "noto-color-animated" - ): - brand_asset_url = _webapp_animated_emoji_asset_path(settings.WEBAPP_LOGO_EMOJI) if brand_asset_url: html = html.replace( "", diff --git a/backend/bot/app/web/webapp/routes.py b/backend/bot/app/web/webapp/routes.py index c9b7c13..e6535b4 100644 --- a/backend/bot/app/web/webapp/routes.py +++ b/backend/bot/app/web/webapp/routes.py @@ -46,10 +46,6 @@ def setup_subscription_webapp_routes(app: web.Application) -> None: rf"{WEBAPP_FAVICON_PATH}/{{digest:[0-9a-f]{{16}}}}/{{filename:[A-Za-z0-9_.-]+}}", webapp_favicon_route, ) - app.router.add_get( - r"/webapp-emoji/{codepoints:[0-9a-f_]+}/512.{ext:gif|webp}", - webapp_animated_emoji_route, - ) app.router.add_get("/subscription_webapp.{asset_hash:[0-9a-f]{8}}.css", css_asset_route) app.router.add_get("/subscription_webapp.css", css_asset_route) app.router.add_get( diff --git a/backend/bot/services/email_templates.py b/backend/bot/services/email_templates.py index f8ed96a..0b09c1f 100644 --- a/backend/bot/services/email_templates.py +++ b/backend/bot/services/email_templates.py @@ -48,8 +48,6 @@ def _safe_color(value: Optional[str]) -> str: def _public_logo_url(settings: Settings) -> Optional[str]: """Email recipients can't reach the in-app /webapp-logo proxy, so only a stored public https URL can be used directly. Anything else is dropped.""" - if getattr(settings, "WEBAPP_LOGO_USE_EMOJI", False): - return None raw = (settings.WEBAPP_LOGO_URL or "").strip() if not raw: return None diff --git a/backend/bot/services/settings_override_service.py b/backend/bot/services/settings_override_service.py index 822b0ec..7702a32 100644 --- a/backend/bot/services/settings_override_service.py +++ b/backend/bot/services/settings_override_service.py @@ -28,10 +28,7 @@ from db.dal import app_settings_dal logger = logging.getLogger(__name__) APPEARANCE_OVERRIDE_KEYS = { - "WEBAPP_LOGO_USE_EMOJI", "WEBAPP_LOGO_URL", - "WEBAPP_LOGO_EMOJI", - "WEBAPP_LOGO_EMOJI_FONT", "WEBAPP_FAVICON_USE_CUSTOM", "WEBAPP_FAVICON_URL", "WEBAPP_LOGO_FAVICON_URL", @@ -167,12 +164,6 @@ def _appearance_snapshot(settings: Settings) -> Dict[str, Any]: snapshot["WEBAPP_FAVICON_URL"] = favicon_url if getattr(settings, "WEBAPP_FAVICON_USE_CUSTOM", False): snapshot["WEBAPP_FAVICON_USE_CUSTOM"] = True - if getattr(settings, "WEBAPP_LOGO_USE_EMOJI", False): - snapshot["WEBAPP_LOGO_USE_EMOJI"] = True - snapshot["WEBAPP_LOGO_EMOJI"] = getattr(settings, "WEBAPP_LOGO_EMOJI", "") - emoji_font = getattr(settings, "WEBAPP_LOGO_EMOJI_FONT", "") - if emoji_font and emoji_font != "system": - snapshot["WEBAPP_LOGO_EMOJI_FONT"] = emoji_font primary_color = getattr(settings, "WEBAPP_PRIMARY_COLOR", None) if primary_color and primary_color != "#00fe7a": snapshot["WEBAPP_PRIMARY_COLOR"] = primary_color diff --git a/backend/config/settings.py b/backend/config/settings.py index 4b16f1a..6d8f5ae 100644 --- a/backend/config/settings.py +++ b/backend/config/settings.py @@ -59,9 +59,6 @@ class WebAppSettings(BaseModel): title: str primary_color: str logo_url: Optional[str] - logo_use_emoji: bool - logo_emoji: str - logo_emoji_font: str favicon_use_custom: bool favicon_url: Optional[str] logo_favicon_url: Optional[str] @@ -395,15 +392,6 @@ class Settings(BaseSettings): ), ) WEBAPP_LOGO_URL: Optional[str] = Field(default=None) - WEBAPP_LOGO_USE_EMOJI: bool = Field(default=False) - WEBAPP_LOGO_EMOJI: str = Field(default="🫥") - WEBAPP_LOGO_EMOJI_FONT: str = Field( - default="system", - description=( - "Emoji font for logo fallback: system, noto-color, noto-color-animated, " - "noto-emoji, twemoji, openmoji, apple, segoe, noto-local" - ), - ) WEBAPP_FAVICON_USE_CUSTOM: bool = Field(default=False) WEBAPP_FAVICON_URL: Optional[str] = Field(default=None) WEBAPP_LOGO_FAVICON_URL: Optional[str] = Field(default=None) @@ -566,9 +554,6 @@ class Settings(BaseSettings): title=self.WEBAPP_TITLE, primary_color=self.WEBAPP_PRIMARY_COLOR, logo_url=self.WEBAPP_LOGO_URL, - logo_use_emoji=self.WEBAPP_LOGO_USE_EMOJI, - logo_emoji=self.WEBAPP_LOGO_EMOJI, - logo_emoji_font=self.WEBAPP_LOGO_EMOJI_FONT, favicon_use_custom=self.WEBAPP_FAVICON_USE_CUSTOM, favicon_url=self.WEBAPP_FAVICON_URL, logo_favicon_url=self.WEBAPP_LOGO_FAVICON_URL, @@ -810,21 +795,6 @@ class Settings(BaseSettings): def ignore_deprecated_webapp_logo_url_env(cls, _value): return None - @field_validator("WEBAPP_LOGO_USE_EMOJI", mode="before") - @classmethod - def ignore_deprecated_webapp_logo_use_emoji_env(cls, _value): - return False - - @field_validator("WEBAPP_LOGO_EMOJI", mode="before") - @classmethod - def ignore_deprecated_webapp_logo_emoji_env(cls, _value): - return "🫥" - - @field_validator("WEBAPP_LOGO_EMOJI_FONT", mode="before") - @classmethod - def ignore_deprecated_webapp_logo_emoji_font_env(cls, _value): - return "system" - @field_validator("WEBAPP_FAVICON_USE_CUSTOM", mode="before") @classmethod def ignore_deprecated_webapp_favicon_use_custom_env(cls, _value): diff --git a/deploy/docker/frontend/nginx.conf b/deploy/docker/frontend/nginx.conf index 00bcfab..fa71122 100644 --- a/deploy/docker/frontend/nginx.conf +++ b/deploy/docker/frontend/nginx.conf @@ -84,7 +84,7 @@ server { proxy_set_header X-Forwarded-Proto $scheme; } - location ~ ^/(webapp-logo|webapp-uploaded-logo|webapp-favicon|webapp-emoji|webapp-theme-css|webapp-theme-assets)/ { + location ~ ^/(webapp-logo|webapp-uploaded-logo|webapp-favicon|webapp-theme-css|webapp-theme-assets)/ { proxy_pass http://backend:8081; proxy_http_version 1.1; proxy_set_header Host $host; diff --git a/docs-site/scripts/generate-email-previews.py b/docs-site/scripts/generate-email-previews.py index 84d54a4..ac36473 100644 --- a/docs-site/scripts/generate-email-previews.py +++ b/docs-site/scripts/generate-email-previews.py @@ -64,7 +64,6 @@ def settings(): DEFAULT_LANGUAGE=LANGUAGE, EMAIL_CODE_TTL_SECONDS=600, WEBAPP_LOGO_URL="", - WEBAPP_LOGO_USE_EMOJI=False, WEBAPP_PRIMARY_COLOR="#00fe7a", WEBAPP_TITLE="remnawave-minishop", ) diff --git a/docs/configuration/env-vars.md b/docs/configuration/env-vars.md index e110aed..ca5b671 100644 --- a/docs/configuration/env-vars.md +++ b/docs/configuration/env-vars.md @@ -161,9 +161,6 @@ | `TELEGRAM_OAUTH_REQUEST_ACCESS` | `.env` | Дополнительные разрешения, например `write`. | | `WEBAPP_PRIMARY_COLOR` | Админка | Устаревшее env-поле, игнорируется. | | `WEBAPP_LOGO_URL` | Админка | Устаревшее env-поле, игнорируется. | -| `WEBAPP_LOGO_USE_EMOJI` | Админка | Устаревшее env-поле, игнорируется. | -| `WEBAPP_LOGO_EMOJI` | Админка | Устаревшее env-поле, игнорируется. | -| `WEBAPP_LOGO_EMOJI_FONT` | Админка | Устаревшее env-поле, игнорируется. | | `WEBAPP_FAVICON_USE_CUSTOM` | Админка | Устаревшее env-поле, игнорируется. | | `WEBAPP_FAVICON_URL` | Админка | Устаревшее env-поле, игнорируется. | | `WEBAPP_LOGO_FAVICON_URL` | Админка | Устаревшее env-поле, игнорируется. | diff --git a/docs/features/admin-panel.md b/docs/features/admin-panel.md index 1847075..afe87d4 100644 --- a/docs/features/admin-panel.md +++ b/docs/features/admin-panel.md @@ -9,7 +9,7 @@ - блокировка пользователей, входящий список тикетов поддержки, рассылки, промокоды и просмотр логов; - ручная синхронизация с Remnawave; - редактор разрешенных настроек приложения из manifest-файла; -- раздел **Внешний вид** для логотипа, emoji-логотипа, выбора темы, accent-цвета, масштаба логотипа и предпросмотра тем; +- раздел **Внешний вид** для логотипа, выбора темы, accent-цвета, масштаба логотипа и предпросмотра тем; - раздел **Инструкции подключения** для встроенной страницы установки, поведения кнопок бота и Remnawave Subscription Page config; - раздел **Бэкапы** для просмотра локальных ZIP-архивов, загрузки архива и восстановления БД/compose-папки; - редактор JSON-каталога тарифов; @@ -49,7 +49,7 @@ В manifest сейчас входят: - общие параметры: язык, валюта, ссылки поддержки, документы, обязательный канал, Remnawave-доступы и поведение `/start`; -- внешний вид и доступность Web App: название, цвет, логотип, emoji-логотип и `WEBAPP_ENABLED`; +- внешний вид и доступность Web App: название, цвет, логотип и `WEBAPP_ENABLED`; - инструкции подключения: `SUBSCRIPTION_GUIDES_ENABLED`, `SUBSCRIPTION_GUIDES_BOT_MENU_ENABLED`, чтение конфига из Remnawave Panel, JSON-переопределение и резервный путь к файлу; - legacy-тарифы без JSON-каталога: периоды подписки, RUB/Stars цены, реферальные бонусы и пакеты трафика; - платежные провайдеры: включение методов, порядок кнопок, публичные параметры и секреты YooKassa, FreeKassa, Platega, SeverPay, Wata, CryptoPay, Heleket и Stars, а также текст и иконки кнопок оплаты; @@ -102,7 +102,7 @@ ## Внешний вид -Раздел **Внешний вид** объединяет настройки бренда и темы Web App. Логотип можно загрузить файлом или по HTTPS-ссылке; backend сохраняет файл в `data/webapp-logo/uploads` и подставляет локальный URL. Если включен emoji-логотип, картинка скрывается, а для emoji можно выбрать системный, Twemoji, Noto Color, animated Noto и другие варианты отрисовки. +Раздел **Внешний вид** объединяет настройки бренда и темы Web App. Логотип можно загрузить файлом или по HTTPS-ссылке; backend сохраняет файл в `data/webapp-logo/uploads` и подставляет локальный URL. Если логотип не задан, показывается логотип проекта по умолчанию. Favicon генерируется из логотипа или загружается отдельно. В блоке тем админка читает каталог из `WEBAPP_THEMES_DIR`, показывает встроенные и кастомные темы, позволяет выбрать текущую тему, изменить accent, включить или выключить тему для админки и настроить масштаб логотипа на главной и экране входа. Кнопка предпросмотра открывает `/home?theme_preview=` и не меняет глобальную тему до сохранения. diff --git a/docs/features/web-app.md b/docs/features/web-app.md index a92408e..7e2a869 100644 --- a/docs/features/web-app.md +++ b/docs/features/web-app.md @@ -67,7 +67,7 @@ SUPPORT_TICKET_RATE_LIMIT_PER_HOUR=5 Если `WEBAPP_ENABLED=False`, пользовательское веб-приложение и админ-панель не регистрируются. Чтобы снова попасть в админку, включите `WEBAPP_ENABLED=True` в `.env` и перезапустите backend/frontend контейнеры. -Внешний вид настраивается в админке: раздел **Внешний вид** управляет логотипом, emoji-логотипом, accent-цветом, выбранной темой и масштабом логотипа. Кастомные темы читаются из `WEBAPP_THEMES_DIR`, а `WEBAPP_DEFAULT_THEME` может принудительно выбрать тему по ключу. Подробный контракт `theme.json`, CSS/asset-роуты и пайплайн создания темы описаны в [webapp-themes.md](webapp-themes.md). +Внешний вид настраивается в админке: раздел **Внешний вид** управляет логотипом, accent-цветом, выбранной темой и масштабом логотипа. Кастомные темы читаются из `WEBAPP_THEMES_DIR`, а `WEBAPP_DEFAULT_THEME` может принудительно выбрать тему по ключу. Подробный контракт `theme.json`, CSS/asset-роуты и пайплайн создания темы описаны в [webapp-themes.md](webapp-themes.md). ## Авторизация diff --git a/docs/features/webapp-themes.md b/docs/features/webapp-themes.md index 4ee81c9..0757939 100644 --- a/docs/features/webapp-themes.md +++ b/docs/features/webapp-themes.md @@ -13,7 +13,6 @@ Web App поддерживает файловые темы, предпросмо - включить или выключить применение темы в админ-панели; - настроить масштаб логотипа на главной и экране входа; - загрузить логотип файлом или по HTTPS-ссылке; -- включить emoji-логотип и выбрать способ его отрисовки; - открыть предпросмотр темы через `/home?theme_preview=`. Через файлы темы можно менять намного больше: @@ -51,7 +50,7 @@ WEBAPP_DEFAULT_THEME= В compose-примерах `data/themes` - это локальная папка рядом с выбранным `docker-compose.yml`; она монтируется в контейнер как `/app/data/themes`. Правки в `backend/bot/app/web/themes` попадают в прод только при сборке собственного образа; опубликованный образ их не видит. -Важно: `WEBAPP_PRIMARY_COLOR`, `WEBAPP_LOGO_URL`, `WEBAPP_LOGO_USE_EMOJI`, `WEBAPP_LOGO_EMOJI` и `WEBAPP_LOGO_EMOJI_FONT` больше не являются рабочим способом первичной настройки через `.env`. Эти значения редактируются в админке и сохраняются как overrides в базе. Тема при этом может использовать сохраненный primary color как fallback accent. +Важно: `WEBAPP_PRIMARY_COLOR` и `WEBAPP_LOGO_URL` больше не являются рабочим способом первичной настройки через `.env`. Эти значения редактируются в админке и сохраняются как overrides в базе. Тема при этом может использовать сохраненный primary color как fallback accent. ## Контракт `theme.json` diff --git a/docs/getting-started/configuration.md b/docs/getting-started/configuration.md index 7941128..93230c7 100644 --- a/docs/getting-started/configuration.md +++ b/docs/getting-started/configuration.md @@ -87,7 +87,7 @@ openssl rand -hex 32 Перед первым запуском создайте каталоги и отдайте их пользователю контейнера: ```bash -mkdir -p data/themes data/webapp-logo data/webapp-emoji data/tariffs +mkdir -p data/themes data/webapp-logo data/tariffs touch data/locales-overrides.json chown -R 10001:10001 data chmod -R u+rwX data diff --git a/docs/getting-started/deployment.md b/docs/getting-started/deployment.md index e5ff0bb..dadfc08 100644 --- a/docs/getting-started/deployment.md +++ b/docs/getting-started/deployment.md @@ -377,7 +377,7 @@ distributed lock; код подготовлен к нескольким репл Перед первым запуском на сервере заранее дайте права пользователю контейнера `10001`: ```bash -mkdir -p data/themes data/webapp-logo data/webapp-emoji data/tariffs +mkdir -p data/themes data/webapp-logo data/tariffs touch data/locales-overrides.json chown -R 10001:10001 data chmod -R u+rwX data diff --git a/frontend/src/App.svelte b/frontend/src/App.svelte index c50d228..13aaa80 100644 --- a/frontend/src/App.svelte +++ b/frontend/src/App.svelte @@ -358,13 +358,9 @@ } = $accountStore); $: brandTitle = CFG.title || "/minishop"; - $: brandEmoji = CFG.logoEmoji || "🫥"; - $: brandEmojiFont = CFG.logoEmojiFont || "system"; $: brand = normalizeBrand({ title: brandTitle, - logoUrl: CFG.logoUseEmoji ? "" : CFG.logoUrl, - emoji: brandEmoji, - emojiFont: brandEmojiFont, + logoUrl: CFG.logoUrl, }); $: faviconBrand = normalizeBrand({ ...brand, @@ -2077,9 +2073,6 @@ ]); return [ "WEBAPP_LOGO_URL", - "WEBAPP_LOGO_USE_EMOJI", - "WEBAPP_LOGO_EMOJI", - "WEBAPP_LOGO_EMOJI_FONT", "WEBAPP_FAVICON_URL", "WEBAPP_FAVICON_USE_CUSTOM", "WEBAPP_LOGO_FAVICON_URL", diff --git a/frontend/src/PreviewBoard.svelte b/frontend/src/PreviewBoard.svelte index fd64391..f43375a 100644 --- a/frontend/src/PreviewBoard.svelte +++ b/frontend/src/PreviewBoard.svelte @@ -31,7 +31,7 @@ export let mockData = {}; const title = config.title || "/minishop"; - const logoEmoji = config.logoEmoji || "🫥"; + const logoUrl = config.logoUrl || "/webapp-default-logo.webp"; const plans = mockData.plans || []; const sub = mockData.subscription || {}; const methods = mockData.payment_methods || []; @@ -78,7 +78,7 @@
    @@ -113,7 +113,7 @@
    -
    {logoEmoji}
    +
    {title}
    @@ -198,7 +198,7 @@
    -
    {logoEmoji}
    +
    {title}
    @@ -221,7 +221,7 @@
    -
    {logoEmoji}
    +
    {title}
    @@ -252,7 +252,7 @@ diff --git a/frontend/src/admin/sections/AppearanceSection.svelte b/frontend/src/admin/sections/AppearanceSection.svelte index 21aef22..13d0fdc 100644 --- a/frontend/src/admin/sections/AppearanceSection.svelte +++ b/frontend/src/admin/sections/AppearanceSection.svelte @@ -1,16 +1,10 @@ @@ -33,7 +44,7 @@ step={sliderStep} {disabled} onValueChange={handleValueChange} - {onValueCommit} + onValueCommit={handleValueCommit} {...$$restProps} > diff --git a/tests/test_webapp_assets.py b/tests/test_webapp_assets.py index 3dc6777..795c6a6 100644 --- a/tests/test_webapp_assets.py +++ b/tests/test_webapp_assets.py @@ -646,6 +646,7 @@ class WebAppAssetTests(unittest.IsolatedAsyncioTestCase): def test_initial_theme_head_markup_includes_css_and_tokens(self): cfg = builtin_webapp_themes_config("#123456") theme = cfg.theme_by_key("light") + theme.tokens.home_logo_scale = 135 request = SimpleNamespace(get=lambda key, default="": "nonce-value") markup = subscription_webapp._initial_theme_head_markup(request, theme, "#123456") @@ -653,6 +654,7 @@ class WebAppAssetTests(unittest.IsolatedAsyncioTestCase): self.assertIn("/webapp-theme-css/light/style.css?v=", markup) self.assertIn('nonce="nonce-value"', markup) self.assertIn("--accent:#123456", markup) + self.assertIn("--home-logo-scale:1.35", markup) self.assertIn("color-scheme:light", markup) def test_theme_asset_version_bumps_for_saved_default_css_theme(self): From 602cbc7ee6aba244ff848cfdc2baa984b2f20834 Mon Sep 17 00:00:00 2001 From: 3252a8 <3252a8@proton.me> Date: Wed, 3 Jun 2026 11:53:11 +0300 Subject: [PATCH 36/58] fix: restore draggable logo scale slider --- frontend/src/admin/sections/AppearanceSection.svelte | 12 ++++++++---- frontend/src/lib/components/ui/range-input.svelte | 2 +- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/frontend/src/admin/sections/AppearanceSection.svelte b/frontend/src/admin/sections/AppearanceSection.svelte index 13d0fdc..968c41c 100644 --- a/frontend/src/admin/sections/AppearanceSection.svelte +++ b/frontend/src/admin/sections/AppearanceSection.svelte @@ -300,13 +300,17 @@ themesStore.setThemeHomeLogoScale(theme.key, value); } + function isThemeControlTarget(target) { + return target?.closest?.("button,input,label,.admin-theme-card-option,.ui-range-input"); + } + function selectTheme(theme, event = null) { - if (event?.target?.closest?.("button,input,label")) return; + if (isThemeControlTarget(event?.target)) return; if (!themesSaving) themesStore.setCurrentTheme(theme.key); } function handleThemeKeydown(event, theme) { - if (event?.target?.closest?.("button,input,label")) return; + if (isThemeControlTarget(event?.target)) return; if (event.key !== "Enter" && event.key !== " ") return; event.preventDefault(); selectTheme(theme); @@ -581,7 +585,7 @@ /> {at("themes_use_in_admin", {}, "Использовать в админке")} -
  • +
  • + {at("user_label_hwid_devices", {}, "HWID-устройства")}{hwidLimitLabel(openedUserDetail.active_subscription)} +
  • +
    + +
    + + {at("user_hwid_limit_input", {}, "Лимит устройств")} + {at( + "user_hwid_limit_input_hint", + {}, + "Пусто — тариф/default; 0 или галочка — безлимит." + )} + + +
    + +
    +
    ({ ...s, openedUserDetail: res, @@ -163,6 +168,8 @@ export function createUsersStore({ api, onToast, at, routePrefix = "" }) { regularUnlimitedDraft: Boolean(sub?.regular_unlimited_override), regularBonusGbDraft: regularBonusBytes > 0 ? +(regularBonusBytes / 1024 ** 3).toFixed(2) : "", + hwidUnlimitedDraft: hasHwidLimit && hwidLimit === 0, + hwidDeviceLimitDraft: hasHwidLimit && hwidLimit > 0 ? String(hwidLimit) : "", grantTrafficGbDraft: "", grantTrafficKindDraft: "regular", })); @@ -551,6 +558,51 @@ export function createUsersStore({ api, onToast, at, routePrefix = "" }) { } } + async function saveHwidDeviceLimit() { + let s; + state.update((st) => { + s = st; + return st; + }); + if (!s.openedUser) return; + state.update((st) => ({ ...st, userActionBusy: true })); + try { + const unlimited = Boolean(s.hwidUnlimitedDraft); + const raw = s.hwidDeviceLimitDraft; + const useDefault = !unlimited && (raw === "" || raw === null || raw === undefined); + let limit = null; + if (!unlimited && !useDefault) { + limit = Number(raw); + if (!Number.isInteger(limit) || limit < 0 || limit > 1_000_000) { + onToast( + at( + "hwid_limit_invalid", + {}, + "Введите целое число устройств от 0 до 1 000 000 или включите безлимит" + ) + ); + return; + } + } + const res = await api(`/admin/users/${s.openedUser.user_id}/hwid-device-limit`, { + method: "POST", + body: JSON.stringify({ + unlimited, + use_default: useDefault, + hwid_device_limit: unlimited ? 0 : limit, + }), + }); + if (res?.ok) { + onToast(at("hwid_limit_saved", {}, "Лимит устройств сохранён")); + await openUser(s.openedUser, { skipPush: true }); + } else { + onToast(res?.error || at("error", {}, "Ошибка")); + } + } finally { + state.update((st) => ({ ...st, userActionBusy: false })); + } + } + async function grantTraffic() { let s; state.update((st) => { @@ -635,6 +687,7 @@ export function createUsersStore({ api, onToast, at, routePrefix = "" }) { deleteUser, savePremiumTrafficOverride, saveRegularTrafficOverride, + saveHwidDeviceLimit, grantTraffic, loadUserLogs, setUserLogsPage, diff --git a/frontend/src/lib/webapp/demoDataset.js b/frontend/src/lib/webapp/demoDataset.js index e35e514..c667237 100644 --- a/frontend/src/lib/webapp/demoDataset.js +++ b/frontend/src/lib/webapp/demoDataset.js @@ -14,7 +14,7 @@ export const DEMO_DATASET = { payments: 482, logs: 1600, supportTickets: 3, - translationKeys: 1961, + translationKeys: 1994, settingsFields: 223, }, }, @@ -11088,8 +11088,8 @@ export const DEMO_DATASET = { provider: "trial", is_throttled: false, install_share_token: "1a0d471f37fdbd7429931a9a75d6e221", - hwid_device_limit: 0, - extra_hwid_devices: 0, + hwid_device_limit: 4, + extra_hwid_devices: 2, }, subscriptions: [ { @@ -11122,8 +11122,8 @@ export const DEMO_DATASET = { provider: "trial", is_throttled: false, install_share_token: "1a0d471f37fdbd7429931a9a75d6e221", - hwid_device_limit: 0, - extra_hwid_devices: 0, + hwid_device_limit: 4, + extra_hwid_devices: 2, }, ], total_paid: 0, @@ -105180,6 +105180,812 @@ export const DEMO_DATASET = { }, }, }, + { + key: "admin_user_hwid_limit_button", + audience: "internal", + values: { + ru: { + base: "📱 HWID лимит", + fallback: "📱 HWID лимит", + effective: "📱 HWID лимит", + override: "", + overridden: false, + updated_at: null, + updated_by: null, + }, + en: { + base: "📱 HWID limit", + fallback: "📱 HWID лимит", + effective: "📱 HWID limit", + override: "", + overridden: false, + updated_at: null, + updated_by: null, + }, + }, + }, + { + key: "admin_user_hwid_limit_label", + audience: "internal", + values: { + ru: { + base: "📱 HWID-устройства:", + fallback: "📱 HWID-устройства:", + effective: "📱 HWID-устройства:", + override: "", + overridden: false, + updated_at: null, + updated_by: null, + }, + en: { + base: "📱 HWID devices:", + fallback: "📱 HWID-устройства:", + effective: "📱 HWID devices:", + override: "", + overridden: false, + updated_at: null, + updated_by: null, + }, + }, + }, + { + key: "admin_hwid_limit_title", + audience: "internal", + values: { + ru: { + base: "📱 Лимит HWID-устройств", + fallback: "📱 Лимит HWID-устройств", + effective: "📱 Лимит HWID-устройств", + override: "", + overridden: false, + updated_at: null, + updated_by: null, + }, + en: { + base: "📱 HWID device limit", + fallback: "📱 Лимит HWID-устройств", + effective: "📱 HWID device limit", + override: "", + overridden: false, + updated_at: null, + updated_by: null, + }, + }, + }, + { + key: "admin_hwid_limit_hint", + audience: "internal", + values: { + ru: { + base: "Задайте ручной базовый лимит устройств для пользователя. 0 означает безлимит; сброс вернёт тарифный или default-лимит.", + fallback: + "Задайте ручной базовый лимит устройств для пользователя. 0 означает безлимит; сброс вернёт тарифный или default-лимит.", + effective: + "Задайте ручной базовый лимит устройств для пользователя. 0 означает безлимит; сброс вернёт тарифный или default-лимит.", + override: "", + overridden: false, + updated_at: null, + updated_by: null, + }, + en: { + base: "Set a manual base device limit for this user. 0 means unlimited; reset returns to the tariff or default limit.", + fallback: + "Задайте ручной базовый лимит устройств для пользователя. 0 означает безлимит; сброс вернёт тарифный или default-лимит.", + effective: + "Set a manual base device limit for this user. 0 means unlimited; reset returns to the tariff or default limit.", + override: "", + overridden: false, + updated_at: null, + updated_by: null, + }, + }, + }, + { + key: "admin_hwid_limit_current", + audience: "internal", + values: { + ru: { + base: "Сейчас: {current}", + fallback: "Сейчас: {current}", + effective: "Сейчас: {current}", + override: "", + overridden: false, + updated_at: null, + updated_by: null, + }, + en: { + base: "Current: {current}", + fallback: "Сейчас: {current}", + effective: "Current: {current}", + override: "", + overridden: false, + updated_at: null, + updated_by: null, + }, + }, + }, + { + key: "admin_hwid_limit_state_default", + audience: "internal", + values: { + ru: { + base: "тарифный / default", + fallback: "тарифный / default", + effective: "тарифный / default", + override: "", + overridden: false, + updated_at: null, + updated_by: null, + }, + en: { + base: "tariff / default", + fallback: "тарифный / default", + effective: "tariff / default", + override: "", + overridden: false, + updated_at: null, + updated_by: null, + }, + }, + }, + { + key: "admin_hwid_limit_state_unlimited", + audience: "internal", + values: { + ru: { + base: "безлимит", + fallback: "безлимит", + effective: "безлимит", + override: "", + overridden: false, + updated_at: null, + updated_by: null, + }, + en: { + base: "unlimited", + fallback: "безлимит", + effective: "unlimited", + override: "", + overridden: false, + updated_at: null, + updated_by: null, + }, + }, + }, + { + key: "admin_hwid_limit_state_count", + audience: "internal", + values: { + ru: { + base: "{count} устройств", + fallback: "{count} устройств", + effective: "{count} устройств", + override: "", + overridden: false, + updated_at: null, + updated_by: null, + }, + en: { + base: "{count} devices", + fallback: "{count} устройств", + effective: "{count} devices", + override: "", + overridden: false, + updated_at: null, + updated_by: null, + }, + }, + }, + { + key: "admin_hwid_limit_state_with_extra", + audience: "internal", + values: { + ru: { + base: "{total} устройств ({base} + {extra})", + fallback: "{total} устройств ({base} + {extra})", + effective: "{total} устройств ({base} + {extra})", + override: "", + overridden: false, + updated_at: null, + updated_by: null, + }, + en: { + base: "{total} devices ({base} + {extra})", + fallback: "{total} устройств ({base} + {extra})", + effective: "{total} devices ({base} + {extra})", + override: "", + overridden: false, + updated_at: null, + updated_by: null, + }, + }, + }, + { + key: "admin_hwid_limit_btn_set_number", + audience: "internal", + values: { + ru: { + base: "🔢 Задать число", + fallback: "🔢 Задать число", + effective: "🔢 Задать число", + override: "", + overridden: false, + updated_at: null, + updated_by: null, + }, + en: { + base: "🔢 Set number", + fallback: "🔢 Задать число", + effective: "🔢 Set number", + override: "", + overridden: false, + updated_at: null, + updated_by: null, + }, + }, + }, + { + key: "admin_hwid_limit_btn_unlimited", + audience: "internal", + values: { + ru: { + base: "♾ Включить безлимит", + fallback: "♾ Включить безлимит", + effective: "♾ Включить безлимит", + override: "", + overridden: false, + updated_at: null, + updated_by: null, + }, + en: { + base: "♾ Make unlimited", + fallback: "♾ Включить безлимит", + effective: "♾ Make unlimited", + override: "", + overridden: false, + updated_at: null, + updated_by: null, + }, + }, + }, + { + key: "admin_hwid_limit_btn_reset", + audience: "internal", + values: { + ru: { + base: "↩️ Вернуть тариф/default", + fallback: "↩️ Вернуть тариф/default", + effective: "↩️ Вернуть тариф/default", + override: "", + overridden: false, + updated_at: null, + updated_by: null, + }, + en: { + base: "↩️ Use tariff/default", + fallback: "↩️ Вернуть тариф/default", + effective: "↩️ Use tariff/default", + override: "", + overridden: false, + updated_at: null, + updated_by: null, + }, + }, + }, + { + key: "admin_hwid_limit_prompt", + audience: "internal", + values: { + ru: { + base: "Введите лимит HWID-устройств для пользователя {user_id}. 0 означает безлимит:", + fallback: + "Введите лимит HWID-устройств для пользователя {user_id}. 0 означает безлимит:", + effective: + "Введите лимит HWID-устройств для пользователя {user_id}. 0 означает безлимит:", + override: "", + overridden: false, + updated_at: null, + updated_by: null, + }, + en: { + base: "Enter the HWID device limit for user {user_id}. 0 means unlimited:", + fallback: + "Введите лимит HWID-устройств для пользователя {user_id}. 0 означает безлимит:", + effective: "Enter the HWID device limit for user {user_id}. 0 means unlimited:", + override: "", + overridden: false, + updated_at: null, + updated_by: null, + }, + }, + }, + { + key: "admin_hwid_limit_invalid", + audience: "internal", + values: { + ru: { + base: "❌ Введите целое число устройств от 0 до 1 000 000.", + fallback: "❌ Введите целое число устройств от 0 до 1 000 000.", + effective: "❌ Введите целое число устройств от 0 до 1 000 000.", + override: "", + overridden: false, + updated_at: null, + updated_by: null, + }, + en: { + base: "❌ Enter an integer device count from 0 to 1,000,000.", + fallback: "❌ Введите целое число устройств от 0 до 1 000 000.", + effective: "❌ Enter an integer device count from 0 to 1,000,000.", + override: "", + overridden: false, + updated_at: null, + updated_by: null, + }, + }, + }, + { + key: "admin_hwid_limit_no_subscription", + audience: "internal", + values: { + ru: { + base: "У пользователя нет активной подписки", + fallback: "У пользователя нет активной подписки", + effective: "У пользователя нет активной подписки", + override: "", + overridden: false, + updated_at: null, + updated_by: null, + }, + en: { + base: "User has no active subscription", + fallback: "У пользователя нет активной подписки", + effective: "User has no active subscription", + override: "", + overridden: false, + updated_at: null, + updated_by: null, + }, + }, + }, + { + key: "admin_hwid_limit_state_missing", + audience: "internal", + values: { + ru: { + base: "⚠️ Целевой пользователь не найден в состоянии. Откройте карточку пользователя заново.", + fallback: + "⚠️ Целевой пользователь не найден в состоянии. Откройте карточку пользователя заново.", + effective: + "⚠️ Целевой пользователь не найден в состоянии. Откройте карточку пользователя заново.", + override: "", + overridden: false, + updated_at: null, + updated_by: null, + }, + en: { + base: "⚠️ Target user missing in state. Open the user card again.", + fallback: + "⚠️ Целевой пользователь не найден в состоянии. Откройте карточку пользователя заново.", + effective: "⚠️ Target user missing in state. Open the user card again.", + override: "", + overridden: false, + updated_at: null, + updated_by: null, + }, + }, + }, + { + key: "admin_hwid_limit_set", + audience: "internal", + values: { + ru: { + base: "✅ Лимит HWID-устройств для пользователя {user_id}: {current}", + fallback: "✅ Лимит HWID-устройств для пользователя {user_id}: {current}", + effective: "✅ Лимит HWID-устройств для пользователя {user_id}: {current}", + override: "", + overridden: false, + updated_at: null, + updated_by: null, + }, + en: { + base: "✅ HWID device limit for user {user_id}: {current}", + fallback: "✅ Лимит HWID-устройств для пользователя {user_id}: {current}", + effective: "✅ HWID device limit for user {user_id}: {current}", + override: "", + overridden: false, + updated_at: null, + updated_by: null, + }, + }, + }, + { + key: "admin_hwid_limit_saved", + audience: "internal", + values: { + ru: { + base: "✅ Лимит HWID-устройств сохранён", + fallback: "✅ Лимит HWID-устройств сохранён", + effective: "✅ Лимит HWID-устройств сохранён", + override: "", + overridden: false, + updated_at: null, + updated_by: null, + }, + en: { + base: "✅ HWID device limit saved", + fallback: "✅ Лимит HWID-устройств сохранён", + effective: "✅ HWID device limit saved", + override: "", + overridden: false, + updated_at: null, + updated_by: null, + }, + }, + }, + { + key: "admin_hwid_limit_save_error", + audience: "internal", + values: { + ru: { + base: "❌ Не удалось сохранить лимит HWID-устройств", + fallback: "❌ Не удалось сохранить лимит HWID-устройств", + effective: "❌ Не удалось сохранить лимит HWID-устройств", + override: "", + overridden: false, + updated_at: null, + updated_by: null, + }, + en: { + base: "❌ Failed to save HWID device limit", + fallback: "❌ Не удалось сохранить лимит HWID-устройств", + effective: "❌ Failed to save HWID device limit", + override: "", + overridden: false, + updated_at: null, + updated_by: null, + }, + }, + }, + { + key: "user_label_hwid_devices", + audience: "internal", + values: { + ru: { + base: "HWID-устройства", + fallback: "HWID-устройства", + effective: "HWID-устройства", + override: "", + overridden: false, + updated_at: null, + updated_by: null, + }, + en: { + base: "HWID devices", + fallback: "HWID-устройства", + effective: "HWID devices", + override: "", + overridden: false, + updated_at: null, + updated_by: null, + }, + }, + }, + { + key: "user_hwid_limit_card_title", + audience: "internal", + values: { + ru: { + base: "HWID-устройства", + fallback: "HWID-устройства", + effective: "HWID-устройства", + override: "", + overridden: false, + updated_at: null, + updated_by: null, + }, + en: { + base: "HWID devices", + fallback: "HWID-устройства", + effective: "HWID devices", + override: "", + overridden: false, + updated_at: null, + updated_by: null, + }, + }, + }, + { + key: "user_hwid_limit_card_hint", + audience: "internal", + values: { + ru: { + base: "Ручной лимит устройств для пользователя. Пустое поле вернёт тарифный или default-лимит.", + fallback: + "Ручной лимит устройств для пользователя. Пустое поле вернёт тарифный или default-лимит.", + effective: + "Ручной лимит устройств для пользователя. Пустое поле вернёт тарифный или default-лимит.", + override: "", + overridden: false, + updated_at: null, + updated_by: null, + }, + en: { + base: "Manual device limit for this user. Empty value returns to the tariff or default limit.", + fallback: + "Ручной лимит устройств для пользователя. Пустое поле вернёт тарифный или default-лимит.", + effective: + "Manual device limit for this user. Empty value returns to the tariff or default limit.", + override: "", + overridden: false, + updated_at: null, + updated_by: null, + }, + }, + }, + { + key: "user_hwid_limit_input", + audience: "internal", + values: { + ru: { + base: "Лимит устройств", + fallback: "Лимит устройств", + effective: "Лимит устройств", + override: "", + overridden: false, + updated_at: null, + updated_by: null, + }, + en: { + base: "Device limit", + fallback: "Лимит устройств", + effective: "Device limit", + override: "", + overridden: false, + updated_at: null, + updated_by: null, + }, + }, + }, + { + key: "user_hwid_limit_input_hint", + audience: "internal", + values: { + ru: { + base: "Пусто — тариф/default; 0 или галочка — безлимит.", + fallback: "Пусто — тариф/default; 0 или галочка — безлимит.", + effective: "Пусто — тариф/default; 0 или галочка — безлимит.", + override: "", + overridden: false, + updated_at: null, + updated_by: null, + }, + en: { + base: "Empty means tariff/default; 0 or the checkbox means unlimited.", + fallback: "Пусто — тариф/default; 0 или галочка — безлимит.", + effective: "Empty means tariff/default; 0 or the checkbox means unlimited.", + override: "", + overridden: false, + updated_at: null, + updated_by: null, + }, + }, + }, + { + key: "user_hwid_limit_default_placeholder", + audience: "internal", + values: { + ru: { + base: "Тариф", + fallback: "Тариф", + effective: "Тариф", + override: "", + overridden: false, + updated_at: null, + updated_by: null, + }, + en: { + base: "Tariff", + fallback: "Тариф", + effective: "Tariff", + override: "", + overridden: false, + updated_at: null, + updated_by: null, + }, + }, + }, + { + key: "user_hwid_limit_save", + audience: "internal", + values: { + ru: { + base: "Сохранить", + fallback: "Сохранить", + effective: "Сохранить", + override: "", + overridden: false, + updated_at: null, + updated_by: null, + }, + en: { + base: "Save", + fallback: "Сохранить", + effective: "Save", + override: "", + overridden: false, + updated_at: null, + updated_by: null, + }, + }, + }, + { + key: "user_hwid_limit_status", + audience: "internal", + values: { + ru: { + base: "Сейчас: {current}", + fallback: "Сейчас: {current}", + effective: "Сейчас: {current}", + override: "", + overridden: false, + updated_at: null, + updated_by: null, + }, + en: { + base: "Current: {current}", + fallback: "Сейчас: {current}", + effective: "Current: {current}", + override: "", + overridden: false, + updated_at: null, + updated_by: null, + }, + }, + }, + { + key: "user_hwid_limit_default", + audience: "internal", + values: { + ru: { + base: "Тарифный / default", + fallback: "Тарифный / default", + effective: "Тарифный / default", + override: "", + overridden: false, + updated_at: null, + updated_by: null, + }, + en: { + base: "Tariff / default", + fallback: "Тарифный / default", + effective: "Tariff / default", + override: "", + overridden: false, + updated_at: null, + updated_by: null, + }, + }, + }, + { + key: "user_hwid_limit_unlimited", + audience: "internal", + values: { + ru: { + base: "Безлимит", + fallback: "Безлимит", + effective: "Безлимит", + override: "", + overridden: false, + updated_at: null, + updated_by: null, + }, + en: { + base: "Unlimited", + fallback: "Безлимит", + effective: "Unlimited", + override: "", + overridden: false, + updated_at: null, + updated_by: null, + }, + }, + }, + { + key: "user_hwid_limit_count", + audience: "internal", + values: { + ru: { + base: "{count} устройств", + fallback: "{count} устройств", + effective: "{count} устройств", + override: "", + overridden: false, + updated_at: null, + updated_by: null, + }, + en: { + base: "{count} devices", + fallback: "{count} устройств", + effective: "{count} devices", + override: "", + overridden: false, + updated_at: null, + updated_by: null, + }, + }, + }, + { + key: "user_hwid_limit_with_extra", + audience: "internal", + values: { + ru: { + base: "{total} устройств ({base} + {extra})", + fallback: "{total} устройств ({base} + {extra})", + effective: "{total} устройств ({base} + {extra})", + override: "", + overridden: false, + updated_at: null, + updated_by: null, + }, + en: { + base: "{total} devices ({base} + {extra})", + fallback: "{total} устройств ({base} + {extra})", + effective: "{total} devices ({base} + {extra})", + override: "", + overridden: false, + updated_at: null, + updated_by: null, + }, + }, + }, + { + key: "hwid_limit_saved", + audience: "internal", + values: { + ru: { + base: "Лимит устройств сохранён", + fallback: "Лимит устройств сохранён", + effective: "Лимит устройств сохранён", + override: "", + overridden: false, + updated_at: null, + updated_by: null, + }, + en: { + base: "Device limit saved", + fallback: "Лимит устройств сохранён", + effective: "Device limit saved", + override: "", + overridden: false, + updated_at: null, + updated_by: null, + }, + }, + }, + { + key: "hwid_limit_invalid", + audience: "internal", + values: { + ru: { + base: "Введите целое число устройств от 0 до 1 000 000 или включите безлимит", + fallback: "Введите целое число устройств от 0 до 1 000 000 или включите безлимит", + effective: "Введите целое число устройств от 0 до 1 000 000 или включите безлимит", + override: "", + overridden: false, + updated_at: null, + updated_by: null, + }, + en: { + base: "Enter an integer device count from 0 to 1,000,000 or enable unlimited", + fallback: "Введите целое число устройств от 0 до 1 000 000 или включите безлимит", + effective: "Enter an integer device count from 0 to 1,000,000 or enable unlimited", + override: "", + overridden: false, + updated_at: null, + updated_by: null, + }, + }, + }, ], }, { diff --git a/locales/en.json b/locales/en.json index 202a86a..c6e3fb8 100644 --- a/locales/en.json +++ b/locales/en.json @@ -284,6 +284,8 @@ "admin_user_premium_override_label": "🌟 Premium override:", "admin_user_premium_override_unlimited": "Unlimited", "admin_user_premium_override_bonus_value": "+{gb} GB", + "admin_user_hwid_limit_button": "📱 HWID limit", + "admin_user_hwid_limit_label": "📱 HWID devices:", "admin_premium_override_title": "🌟 Premium override", "admin_premium_override_hint": "Grant free extra premium traffic or unlimited premium access for this user.", "admin_premium_override_current": "Current: {current}", @@ -300,6 +302,23 @@ "admin_premium_override_bonus_set": "✅ Premium override: +{gb} GB for user {user_id}", "admin_premium_override_saved": "✅ Premium override saved", "admin_premium_override_save_error": "❌ Failed to save premium override", + "admin_hwid_limit_title": "📱 HWID device limit", + "admin_hwid_limit_hint": "Set a manual base device limit for this user. 0 means unlimited; reset returns to the tariff or default limit.", + "admin_hwid_limit_current": "Current: {current}", + "admin_hwid_limit_state_default": "tariff / default", + "admin_hwid_limit_state_unlimited": "unlimited", + "admin_hwid_limit_state_count": "{count} devices", + "admin_hwid_limit_state_with_extra": "{total} devices ({base} + {extra})", + "admin_hwid_limit_btn_set_number": "🔢 Set number", + "admin_hwid_limit_btn_unlimited": "♾ Make unlimited", + "admin_hwid_limit_btn_reset": "↩️ Use tariff/default", + "admin_hwid_limit_prompt": "Enter the HWID device limit for user {user_id}. 0 means unlimited:", + "admin_hwid_limit_invalid": "❌ Enter an integer device count from 0 to 1,000,000.", + "admin_hwid_limit_no_subscription": "User has no active subscription", + "admin_hwid_limit_state_missing": "⚠️ Target user missing in state. Open the user card again.", + "admin_hwid_limit_set": "✅ HWID device limit for user {user_id}: {current}", + "admin_hwid_limit_saved": "✅ HWID device limit saved", + "admin_hwid_limit_save_error": "❌ Failed to save HWID device limit", "admin_user_traffic_grant_button": "🎁 Grant GB", "admin_traffic_grant_title": "🎁 Grant traffic", "admin_traffic_grant_hint": "Credit GB to the user's balance — same effect as a top-up purchase, but without payment. The panel limit and squads refresh immediately.", @@ -1313,7 +1332,21 @@ "admin_user_label_main_traffic": "Main Traffic", "admin_user_traffic_left": "Left: {left}", "admin_user_label_premium_squads": "Premium Squads", + "user_label_hwid_devices": "HWID devices", "user_override_unlimited_short": "Unlimited", + "user_hwid_limit_card_title": "HWID devices", + "user_hwid_limit_card_hint": "Manual device limit for this user. Empty value returns to the tariff or default limit.", + "user_hwid_limit_input": "Device limit", + "user_hwid_limit_input_hint": "Empty means tariff/default; 0 or the checkbox means unlimited.", + "user_hwid_limit_default_placeholder": "Tariff", + "user_hwid_limit_save": "Save", + "user_hwid_limit_status": "Current: {current}", + "user_hwid_limit_default": "Tariff / default", + "user_hwid_limit_unlimited": "Unlimited", + "user_hwid_limit_count": "{count} devices", + "user_hwid_limit_with_extra": "{total} devices ({base} + {extra})", + "hwid_limit_saved": "Device limit saved", + "hwid_limit_invalid": "Enter an integer device count from 0 to 1,000,000 or enable unlimited", "user_premium_override_card_title": "Premium traffic", "user_premium_override_card_hint": "Unlimited access and extra volume for premium squads on top of the tariff.", "user_regular_override_card_title": "Main traffic", diff --git a/locales/ru.json b/locales/ru.json index f624495..ee6aa30 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -284,6 +284,8 @@ "admin_user_premium_override_label": "🌟 Премиум-оверрайд:", "admin_user_premium_override_unlimited": "Безлимит", "admin_user_premium_override_bonus_value": "+{gb} GB", + "admin_user_hwid_limit_button": "📱 HWID лимит", + "admin_user_hwid_limit_label": "📱 HWID-устройства:", "admin_premium_override_title": "🌟 Премиум-оверрайд", "admin_premium_override_hint": "Бесплатно увеличьте лимит премиум-трафика или сделайте его безлимитным для этого пользователя.", "admin_premium_override_current": "Сейчас: {current}", @@ -300,6 +302,23 @@ "admin_premium_override_bonus_set": "✅ Премиум-оверрайд: +{gb} GB для пользователя {user_id}", "admin_premium_override_saved": "✅ Премиум-оверрайд сохранён", "admin_premium_override_save_error": "❌ Не удалось сохранить премиум-оверрайд", + "admin_hwid_limit_title": "📱 Лимит HWID-устройств", + "admin_hwid_limit_hint": "Задайте ручной базовый лимит устройств для пользователя. 0 означает безлимит; сброс вернёт тарифный или default-лимит.", + "admin_hwid_limit_current": "Сейчас: {current}", + "admin_hwid_limit_state_default": "тарифный / default", + "admin_hwid_limit_state_unlimited": "безлимит", + "admin_hwid_limit_state_count": "{count} устройств", + "admin_hwid_limit_state_with_extra": "{total} устройств ({base} + {extra})", + "admin_hwid_limit_btn_set_number": "🔢 Задать число", + "admin_hwid_limit_btn_unlimited": "♾ Включить безлимит", + "admin_hwid_limit_btn_reset": "↩️ Вернуть тариф/default", + "admin_hwid_limit_prompt": "Введите лимит HWID-устройств для пользователя {user_id}. 0 означает безлимит:", + "admin_hwid_limit_invalid": "❌ Введите целое число устройств от 0 до 1 000 000.", + "admin_hwid_limit_no_subscription": "У пользователя нет активной подписки", + "admin_hwid_limit_state_missing": "⚠️ Целевой пользователь не найден в состоянии. Откройте карточку пользователя заново.", + "admin_hwid_limit_set": "✅ Лимит HWID-устройств для пользователя {user_id}: {current}", + "admin_hwid_limit_saved": "✅ Лимит HWID-устройств сохранён", + "admin_hwid_limit_save_error": "❌ Не удалось сохранить лимит HWID-устройств", "admin_user_traffic_grant_button": "🎁 Выдать ГБ", "admin_traffic_grant_title": "🎁 Выдать трафик", "admin_traffic_grant_hint": "Зачисление ГБ на баланс пользователя — как при докупке, но без оплаты. Лимит и сквады в панели обновятся сразу.", @@ -1313,7 +1332,21 @@ "admin_user_label_main_traffic": "Основной трафик", "admin_user_traffic_left": "Осталось: {left}", "admin_user_label_premium_squads": "Premium-сквады", + "user_label_hwid_devices": "HWID-устройства", "user_override_unlimited_short": "Безлимит", + "user_hwid_limit_card_title": "HWID-устройства", + "user_hwid_limit_card_hint": "Ручной лимит устройств для пользователя. Пустое поле вернёт тарифный или default-лимит.", + "user_hwid_limit_input": "Лимит устройств", + "user_hwid_limit_input_hint": "Пусто — тариф/default; 0 или галочка — безлимит.", + "user_hwid_limit_default_placeholder": "Тариф", + "user_hwid_limit_save": "Сохранить", + "user_hwid_limit_status": "Сейчас: {current}", + "user_hwid_limit_default": "Тарифный / default", + "user_hwid_limit_unlimited": "Безлимит", + "user_hwid_limit_count": "{count} устройств", + "user_hwid_limit_with_extra": "{total} устройств ({base} + {extra})", + "hwid_limit_saved": "Лимит устройств сохранён", + "hwid_limit_invalid": "Введите целое число устройств от 0 до 1 000 000 или включите безлимит", "user_premium_override_card_title": "Премиум-трафик", "user_premium_override_card_hint": "Безлимит и дополнительный объём для премиум-сквадов поверх тарифа.", "user_regular_override_card_title": "Основной трафик", diff --git a/tests/test_admin_traffic_grants.py b/tests/test_admin_traffic_grants.py index 7b01022..efe5834 100644 --- a/tests/test_admin_traffic_grants.py +++ b/tests/test_admin_traffic_grants.py @@ -50,6 +50,149 @@ def _make_settings(payload: dict, tmpdir: str) -> Settings: class AdminGrantTopupTests(unittest.IsolatedAsyncioTestCase): + async def test_hwid_limit_sync_pushes_effective_device_limit(self): + with tempfile.TemporaryDirectory() as tmpdir: + settings = _make_settings(_tariffs_config_payload(), tmpdir) + panel_service = AsyncMock(spec=PanelApiService) + panel_service.update_user_details_on_panel = AsyncMock(return_value={"response": {}}) + service = SubscriptionService(settings, panel_service) + + db_user = SimpleNamespace( + user_id=42, + first_name="Tester", + last_name=None, + username="tester", + language_code="ru", + panel_user_uuid="panel-uuid", + email=None, + telegram_id=42, + ) + sub = SimpleNamespace( + subscription_id=7, + user_id=42, + panel_user_uuid="panel-uuid", + end_date=datetime.now(timezone.utc) + timedelta(days=10), + tariff_key="standard", + hwid_device_limit=4, + extra_hwid_devices=0, + ) + + with ( + patch( + "bot.services.subscription_service.user_dal.get_user_by_id", + new=AsyncMock(return_value=db_user), + ), + patch( + "bot.services.subscription_service.subscription_dal.get_active_subscription_by_user_id", + new=AsyncMock(return_value=sub), + ), + patch( + "bot.services.subscription_service.tariff_dal.sum_active_hwid_devices", + new=AsyncMock(return_value=2), + ), + ): + effective_limit = await service.sync_hwid_device_limit_to_panel(AsyncMock(), 42) + + self.assertEqual(effective_limit, 6) + self.assertEqual(sub.extra_hwid_devices, 2) + panel_service.update_user_details_on_panel.assert_awaited_once() + panel_payload = panel_service.update_user_details_on_panel.await_args.args[1] + self.assertEqual(panel_payload["hwidDeviceLimit"], 6) + + async def test_hwid_limit_sync_keeps_zero_unlimited(self): + with tempfile.TemporaryDirectory() as tmpdir: + settings = _make_settings(_tariffs_config_payload(), tmpdir) + panel_service = AsyncMock(spec=PanelApiService) + panel_service.update_user_details_on_panel = AsyncMock(return_value={"response": {}}) + service = SubscriptionService(settings, panel_service) + + db_user = SimpleNamespace( + user_id=42, + first_name="Tester", + last_name=None, + username="tester", + language_code="ru", + panel_user_uuid="panel-uuid", + email=None, + telegram_id=42, + ) + sub = SimpleNamespace( + subscription_id=7, + user_id=42, + panel_user_uuid="panel-uuid", + end_date=datetime.now(timezone.utc) + timedelta(days=10), + tariff_key="standard", + hwid_device_limit=0, + extra_hwid_devices=0, + ) + + with ( + patch( + "bot.services.subscription_service.user_dal.get_user_by_id", + new=AsyncMock(return_value=db_user), + ), + patch( + "bot.services.subscription_service.subscription_dal.get_active_subscription_by_user_id", + new=AsyncMock(return_value=sub), + ), + patch( + "bot.services.subscription_service.tariff_dal.sum_active_hwid_devices", + new=AsyncMock(return_value=3), + ), + ): + effective_limit = await service.sync_hwid_device_limit_to_panel(AsyncMock(), 42) + + self.assertEqual(effective_limit, 0) + panel_payload = panel_service.update_user_details_on_panel.await_args.args[1] + self.assertEqual(panel_payload["hwidDeviceLimit"], 0) + + async def test_hwid_limit_sync_treats_missing_default_as_unlimited(self): + with tempfile.TemporaryDirectory() as tmpdir: + settings = _make_settings(_tariffs_config_payload(), tmpdir) + panel_service = AsyncMock(spec=PanelApiService) + panel_service.update_user_details_on_panel = AsyncMock(return_value={"response": {}}) + service = SubscriptionService(settings, panel_service) + + db_user = SimpleNamespace( + user_id=42, + first_name="Tester", + last_name=None, + username="tester", + language_code="ru", + panel_user_uuid="panel-uuid", + email=None, + telegram_id=42, + ) + sub = SimpleNamespace( + subscription_id=7, + user_id=42, + panel_user_uuid="panel-uuid", + end_date=datetime.now(timezone.utc) + timedelta(days=10), + tariff_key="standard", + hwid_device_limit=None, + extra_hwid_devices=0, + ) + + with ( + patch( + "bot.services.subscription_service.user_dal.get_user_by_id", + new=AsyncMock(return_value=db_user), + ), + patch( + "bot.services.subscription_service.subscription_dal.get_active_subscription_by_user_id", + new=AsyncMock(return_value=sub), + ), + patch( + "bot.services.subscription_service.tariff_dal.sum_active_hwid_devices", + new=AsyncMock(return_value=2), + ), + ): + effective_limit = await service.sync_hwid_device_limit_to_panel(AsyncMock(), 42) + + self.assertEqual(effective_limit, 0) + panel_payload = panel_service.update_user_details_on_panel.await_args.args[1] + self.assertEqual(panel_payload["hwidDeviceLimit"], 0) + async def test_regular_grant_increases_balance_and_panel_limit(self): with tempfile.TemporaryDirectory() as tmpdir: settings = _make_settings(_tariffs_config_payload(), tmpdir) diff --git a/tests/test_admin_user_hwid_limit.py b/tests/test_admin_user_hwid_limit.py new file mode 100644 index 0000000..d86b13f --- /dev/null +++ b/tests/test_admin_user_hwid_limit.py @@ -0,0 +1,140 @@ +import json +import unittest +from types import SimpleNamespace +from unittest.mock import AsyncMock, patch + +from bot.app.web.admin_api_impl import users as admin_users + + +class FakeSession: + def __init__(self): + self.committed = False + self.rolled_back = False + self.refreshed = None + + async def __aenter__(self): + return self + + async def __aexit__(self, exc_type, exc, tb): + return False + + async def commit(self): + self.committed = True + + async def rollback(self): + self.rolled_back = True + + async def refresh(self, obj): + self.refreshed = obj + + +class FakeRequest: + def __init__(self, body, session, subscription_service): + self.app = { + "settings": SimpleNamespace(), + "async_session_factory": lambda: session, + "subscription_service": subscription_service, + } + self.match_info = {"user_id": "42"} + self._body = body + + async def json(self): + return self._body + + +class AdminUserHwidLimitRouteTests(unittest.IsolatedAsyncioTestCase): + async def test_unlimited_payload_stores_zero_and_syncs_panel(self): + session = FakeSession() + active = SimpleNamespace(hwid_device_limit=3) + subscription_service = SimpleNamespace( + sync_hwid_device_limit_to_panel=AsyncMock(return_value=0) + ) + request = FakeRequest( + {"unlimited": True, "hwid_device_limit": 999}, session, subscription_service + ) + + with ( + patch.object(admin_users, "_require_admin_user_id", return_value=100), + patch.object( + admin_users.subscription_dal, + "get_active_subscription_by_user_id", + AsyncMock(return_value=active), + ), + patch.object(admin_users.message_log_dal, "create_message_log", AsyncMock()), + patch.object(admin_users, "_invalidate_after_admin_user_mutation", AsyncMock()), + patch.object( + admin_users, + "_serialize_subscription", + return_value={"hwid_device_limit": 0}, + ), + ): + response = await admin_users.admin_user_hwid_device_limit_route(request) + + self.assertEqual(response.status, 200) + self.assertEqual(json.loads(response.text)["subscription"]["hwid_device_limit"], 0) + self.assertEqual(active.hwid_device_limit, 0) + subscription_service.sync_hwid_device_limit_to_panel.assert_awaited_once_with(session, 42) + self.assertTrue(session.committed) + self.assertEqual(session.refreshed, active) + + async def test_use_default_payload_stores_null_override(self): + session = FakeSession() + active = SimpleNamespace(hwid_device_limit=5) + subscription_service = SimpleNamespace( + sync_hwid_device_limit_to_panel=AsyncMock(return_value=3) + ) + request = FakeRequest({"use_default": True}, session, subscription_service) + + with ( + patch.object(admin_users, "_require_admin_user_id", return_value=100), + patch.object( + admin_users.subscription_dal, + "get_active_subscription_by_user_id", + AsyncMock(return_value=active), + ), + patch.object(admin_users.message_log_dal, "create_message_log", AsyncMock()), + patch.object(admin_users, "_invalidate_after_admin_user_mutation", AsyncMock()), + patch.object( + admin_users, + "_serialize_subscription", + return_value={"hwid_device_limit": None}, + ), + ): + response = await admin_users.admin_user_hwid_device_limit_route(request) + + self.assertEqual(response.status, 200) + self.assertIsNone(json.loads(response.text)["subscription"]["hwid_device_limit"]) + self.assertIsNone(active.hwid_device_limit) + subscription_service.sync_hwid_device_limit_to_panel.assert_awaited_once_with(session, 42) + + async def test_negative_limit_is_rejected(self): + session = FakeSession() + subscription_service = SimpleNamespace( + sync_hwid_device_limit_to_panel=AsyncMock(return_value=None) + ) + request = FakeRequest({"hwid_device_limit": -1}, session, subscription_service) + + with patch.object(admin_users, "_require_admin_user_id", return_value=100): + 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() + + async def test_over_max_limit_is_rejected(self): + session = FakeSession() + subscription_service = SimpleNamespace( + sync_hwid_device_limit_to_panel=AsyncMock(return_value=None) + ) + request = FakeRequest({"hwid_device_limit": 1_000_001}, session, subscription_service) + + with patch.object(admin_users, "_require_admin_user_id", return_value=100): + 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() + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_can_topup_devices_flag.py b/tests/test_can_topup_devices_flag.py index 552276e..920a494 100644 --- a/tests/test_can_topup_devices_flag.py +++ b/tests/test_can_topup_devices_flag.py @@ -157,6 +157,16 @@ class CanTopupDevicesFlagTests(unittest.TestCase): payload = _serialize_subscription(settings, _active(max_devices=0), None, "en") self.assertFalse(payload["can_topup_devices"]) + def test_flag_is_false_when_max_devices_is_missing(self): + # Missing device limit is unlimited for Remnawave HWID limits. + with tempfile.TemporaryDirectory() as tmpdir: + settings = _make_settings( + tmpdir, + _tariffs_payload(hwid_rub=[{"count": 1, "price": 50}]), + ) + payload = _serialize_subscription(settings, _active(max_devices=None), None, "en") + self.assertFalse(payload["can_topup_devices"]) + def test_flag_is_false_when_tariff_has_no_hwid_packages(self): with tempfile.TemporaryDirectory() as tmpdir: settings = _make_settings(tmpdir, _tariffs_payload()) diff --git a/tests/test_locale_overrides.py b/tests/test_locale_overrides.py index 1df311f..f5ff91c 100644 --- a/tests/test_locale_overrides.py +++ b/tests/test_locale_overrides.py @@ -306,7 +306,9 @@ def test_admin_locale_keys_are_split_into_smaller_internal_groups(): "inline_financial_description": "admin_dashboard", "inline_system_stats_message": "admin_dashboard", "admin_user_card_title": "admin_users", + "admin_hwid_limit_title": "admin_users", "user_card_open_profile_button": "admin_users", + "user_hwid_limit_card_title": "admin_users", "user_premium_override_card_title": "admin_users", "traffic_grant_regular_done": "admin_users", "admin_payment_detail_title": "admin_payments", diff --git a/tests/test_webapp_route_contract.py b/tests/test_webapp_route_contract.py index 91d6533..6e202d1 100644 --- a/tests/test_webapp_route_contract.py +++ b/tests/test_webapp_route_contract.py @@ -173,6 +173,10 @@ class WebAppRouteContractTests(unittest.TestCase): "POST", "/api/admin/users/{user_id}/regular-traffic-override", ): "admin_user_regular_traffic_override_route", + ( + "POST", + "/api/admin/users/{user_id}/hwid-device-limit", + ): "admin_user_hwid_device_limit_route", ("POST", "/api/admin/users/{user_id}/traffic-grant"): "admin_user_traffic_grant_route", ("DELETE", "/api/admin/users/{user_id}"): "admin_user_delete_route", ("GET", "/api/admin/payments"): "admin_payments_list_route", From fbb89793cbb0b970bf6411e6068bd8299758e63e Mon Sep 17 00:00:00 2001 From: 3252a8 <3252a8@proton.me> Date: Wed, 3 Jun 2026 23:51:46 +0300 Subject: [PATCH 42/58] fix: separate HWID device renewal flows Keep one-off device top-ups scoped to the active subscription term and move device renewal into subscription checkout. Carry HWID renewal metadata through provider callbacks and webhooks, including YooKassa saved-card flows. Add admin extension controls, docs, demo data, and regression coverage. --- backend/bot/app/web/admin_api_impl/users.py | 8 +- backend/bot/app/web/webapp/billing.py | 65 ++++-- backend/bot/app/web/webapp/payloads.py | 1 + backend/bot/app/web/webapp/serializers.py | 124 ++++++++++- .../bot/handlers/user/subscription/core.py | 42 +++- .../bot/keyboards/inline/user_keyboards.py | 83 +++++-- backend/bot/payment_providers/base.py | 1 + backend/bot/payment_providers/cryptopay.py | 9 +- backend/bot/payment_providers/platega.py | 1 + .../bot/payment_providers/shared/callbacks.py | 23 ++ .../bot/payment_providers/shared/common.py | 18 +- .../bot/payment_providers/shared/success.py | 71 +++++- .../bot/payment_providers/shared/webhooks.py | 2 +- backend/bot/payment_providers/stars.py | 1 + backend/bot/payment_providers/wata.py | 1 + backend/bot/payment_providers/yookassa.py | 205 +++++++++++++++--- .../subscription_service_impl/devices.py | 142 +++++++++++- .../subscription_service_impl/lifecycle.py | 114 ++++++++-- .../subscription_service_impl/payments.py | 3 +- .../subscription_service_impl/renewal.py | 46 ++++ backend/db/dal/payment_dal.py | 23 ++ backend/db/dal/tariff_dal.py | 59 ++++- docs/features/notifications.md | 2 +- docs/features/tariffs.md | 5 +- frontend/src/App.svelte | 2 + .../src/admin/sections/UserDetailModal.svelte | 35 +++ frontend/src/lib/admin/stores/usersStore.js | 4 +- frontend/src/lib/webapp/billingActions.js | 3 +- frontend/src/lib/webapp/demoDataset.js | 76 ++++--- .../src/lib/webapp/stores/billingStore.js | 19 +- frontend/src/styles/admin.css | 31 +++ frontend/src/styles/webapp.css | 37 ++++ frontend/src/webapp/PaymentDialogs.svelte | 137 +++++++++++- frontend/src/webapp/TariffDialogs.svelte | 32 +-- locales/en.json | 27 ++- locales/ru.json | 27 ++- tests/test_admin_user_hwid_limit.py | 73 ++++++- tests/test_auto_renew_wiring.py | 56 ++++- tests/test_hwid_device_bonus_extension.py | 72 ++++++ tests/test_hwid_device_topup.py | 109 ++++++++++ tests/test_hwid_tariff_switch_conversion.py | 91 ++++++++ tests/test_payment_provider_registry.py | 62 +++++- tests/test_payment_webhook_notifications.py | 87 ++++++++ tests/test_subscription_service_behavior.py | 159 ++++++++++++++ tests/test_user_bot_menu.py | 60 +++++ tests/test_webapp_device_topup_options.py | 201 ++++++++++++++++- tests/test_webapp_payment_status.py | 2 +- tests/test_yookassa_hwid_webhook.py | 183 ++++++++++++++++ 48 files changed, 2410 insertions(+), 224 deletions(-) create mode 100644 tests/test_hwid_device_bonus_extension.py create mode 100644 tests/test_payment_webhook_notifications.py diff --git a/backend/bot/app/web/admin_api_impl/users.py b/backend/bot/app/web/admin_api_impl/users.py index ab33723..c6a061d 100644 --- a/backend/bot/app/web/admin_api_impl/users.py +++ b/backend/bot/app/web/admin_api_impl/users.py @@ -1515,6 +1515,8 @@ async def admin_user_extend_route(request: web.Request) -> web.Response: return _error(400, "invalid_days") if days <= 0: return _error(400, "invalid_days") + extend_hwid_devices = payload.get("extend_hwid_devices") + extend_hwid_devices = True if extend_hwid_devices is None else bool(extend_hwid_devices) subscription_service = request.app.get("subscription_service") if subscription_service is None: @@ -1527,6 +1529,7 @@ async def admin_user_extend_route(request: web.Request) -> web.Response: target_id, days, "admin_extend_subscription_webapp", + extend_hwid_devices=extend_hwid_devices, ) if not new_end: await session.rollback() @@ -1537,7 +1540,10 @@ async def admin_user_extend_route(request: web.Request) -> web.Response: { "user_id": actor_id, "event_type": "admin_extend_subscription_webapp", - "content": f"+{days}d -> {new_end.isoformat()}", + "content": ( + f"+{days}d -> {new_end.isoformat()} " + f"(hwid={'yes' if extend_hwid_devices else 'no'})" + ), "is_admin_event": True, "target_user_id": target_id, }, diff --git a/backend/bot/app/web/webapp/billing.py b/backend/bot/app/web/webapp/billing.py index ef1ec17..f91b8f5 100644 --- a/backend/bot/app/web/webapp/billing.py +++ b/backend/bot/app/web/webapp/billing.py @@ -121,10 +121,11 @@ async def create_payment_route(request: web.Request) -> web.Response: hwid_quote: Optional[Dict[str, Any]] = None requested_sale_mode = _sale_mode_base(str(payment_payload.sale_mode or "")) + if tariffs_config and requested_sale_mode == "hwid_devices_renewal": + return _json_error(400, "invalid_plan", "Device renewal is part of subscription renewal") if tariffs_config and requested_sale_mode in { "hwid_device", "hwid_devices", - "hwid_devices_renewal", }: tariff_key = str(payment_payload.tariff_key or "").strip() if not tariff_key: @@ -318,7 +319,7 @@ async def create_payment_route(request: web.Request) -> web.Response: user_id=user_id, device_count=int(payment_units), tariff_key=sale_tariff_key, - renewal=_sale_mode_base(sale_mode) == "hwid_devices_renewal", + renewal=False, currency=currency, ) if not hwid_quote: @@ -331,6 +332,25 @@ async def create_payment_route(request: web.Request) -> web.Response: else: price = float(hwid_quote["price"]) stars_price = None + elif _sale_mode_base(sale_mode) == "subscription" and bool( + payment_payload.renew_hwid_devices + ): + currency = "stars" if method == "stars" else default_currency + sale_tariff_key = _sale_mode_tariff_key(sale_mode) + if sale_tariff_key: + hwid_quote = await subscription_service.quote_hwid_device_renewal_for_subscription( + session, + user_id=user_id, + target_tariff_key=sale_tariff_key, + months=int(payment_units), + currency=currency, + ) + if hwid_quote: + if method == "stars": + stars_price = int(stars_price or 0) + int(hwid_quote["price"]) + else: + price = float(price or 0) + float(hwid_quote["price"]) + stars_price = None admin_ids = {int(item) for item in (settings.ADMIN_IDS or [])} is_admin = bool(db_user.telegram_id and int(db_user.telegram_id) in admin_ids) return await _create_subscription_payment( @@ -691,7 +711,6 @@ async def device_topup_options_route(request: web.Request) -> web.Response: return _json_error(400, "device_topup_unavailable", "Device top-up is not available") lang = db_user.language_code or settings.DEFAULT_LANGUAGE active = await subscription_service.get_active_subscription_details(session, user_id) - renewal_available = bool(active and active.get("device_topup_renewal_available")) extra_hwid_valid_until = active.get("extra_hwid_devices_valid_until") if active else None extra_hwid_valid_until_text = ( active.get("extra_hwid_devices_valid_until_text") if active else None @@ -713,7 +732,7 @@ async def device_topup_options_route(request: web.Request) -> web.Response: user_id=user_id, device_count=count, tariff_key=tariff.key, - renewal=renewal_available, + renewal=False, currency=default_currency, ) if count in currency_counts @@ -725,7 +744,7 @@ async def device_topup_options_route(request: web.Request) -> web.Response: user_id=user_id, device_count=count, tariff_key=tariff.key, - renewal=renewal_available, + renewal=False, currency="stars", ) if count in stars_counts @@ -733,28 +752,27 @@ async def device_topup_options_route(request: web.Request) -> web.Response: ) if not currency_quote and not stars_quote: continue - sale_mode_for_plan = "hwid_devices_renewal" if renewal_available else "hwid_devices" + quote = currency_quote or stars_quote + valid_from = quote.get("valid_from") + valid_until = quote.get("valid_until") plan = { - "id": f"{tariff.key}:hwid:{count}{':renewal' if renewal_available else ''}", + "id": f"{tariff.key}:hwid:{count}", "tariff_key": tariff.key, "tariff_name": tariff.name(lang), "billing_model": tariff.billing_model, - "sale_mode": sale_mode_for_plan, + "sale_mode": "hwid_devices", + "renewal": False, "months": count, "device_count": count, "price": float(currency_quote.get("price") if currency_quote else 0), "currency": default_currency_code, "title": f"+{count}", "subtitle": tariff.name(lang), - "valid_from": _billing_iso_datetime( - (currency_quote or stars_quote).get("valid_from") - ), - "valid_until": _billing_iso_datetime( - (currency_quote or stars_quote).get("valid_until") - ), - "proration_ratio": float( - (currency_quote or stars_quote).get("proration_ratio") or 0 - ), + "valid_from": _billing_iso_datetime(valid_from), + "valid_from_text": _billing_datetime_text(valid_from), + "valid_until": _billing_iso_datetime(valid_until), + "valid_until_text": _billing_datetime_text(valid_until), + "proration_ratio": float(quote.get("proration_ratio") or 0), } if stars_quote and int(stars_quote.get("price") or 0) > 0: plan["stars_price"] = int(stars_quote["price"]) @@ -770,10 +788,8 @@ async def device_topup_options_route(request: web.Request) -> web.Response: else int(sub.extra_hwid_devices or 0), "extra_hwid_devices_valid_until": _billing_iso_datetime(extra_hwid_valid_until), "extra_hwid_devices_valid_until_text": extra_hwid_valid_until_text, - "renewal_available": renewal_available, - "renewal_recommended_count": int(active.get("extra_hwid_devices") or 0) - if active and renewal_available - else 0, + "renewal_available": False, + "renewal_recommended_count": 0, "plans": plans, } ) @@ -939,7 +955,11 @@ async def payment_status_route(request: web.Request) -> web.Response: payment = await _refresh_yookassa_payment_status(request, session, payment) payment = await _refresh_wata_payment_status(request, session, payment) if payment.status == "succeeded": - await invalidate_webapp_user_caches(request.app["settings"], user_id) + await invalidate_webapp_user_caches( + request.app["settings"], + user_id, + include_devices=True, + ) return web.json_response( { "ok": True, @@ -1037,6 +1057,7 @@ async def _create_subscription_payment( description=description, sale_mode=sale_mode, traffic_gb=traffic_gb, + hwid_device_count=hwid_quote.get("device_count") if hwid_quote else None, hwid_valid_from=hwid_quote.get("valid_from") if hwid_quote else None, hwid_valid_until=hwid_quote.get("valid_until") if hwid_quote else None, hwid_pricing_period_months=hwid_quote.get("pricing_period_months") diff --git a/backend/bot/app/web/webapp/payloads.py b/backend/bot/app/web/webapp/payloads.py index e03221e..eba2a99 100644 --- a/backend/bot/app/web/webapp/payloads.py +++ b/backend/bot/app/web/webapp/payloads.py @@ -49,6 +49,7 @@ class WebAppPaymentCreatePayload(BaseModel): device_count: Any = None tariff_key: Optional[constr(max_length=128)] = None sale_mode: Optional[constr(max_length=64)] = None + renew_hwid_devices: Optional[bool] = None description: Optional[constr(max_length=4096)] = None comment: Optional[constr(max_length=4096)] = None note: Optional[constr(max_length=4096)] = None diff --git a/backend/bot/app/web/webapp/serializers.py b/backend/bot/app/web/webapp/serializers.py index de07635..1c3294b 100644 --- a/backend/bot/app/web/webapp/serializers.py +++ b/backend/bot/app/web/webapp/serializers.py @@ -69,13 +69,30 @@ async def _build_user_payload(request: web.Request, user_id: int) -> Dict[str, A and settings.TRIAL_DURATION_DAYS > 0 and not await subscription_service.has_trial_blocking_subscription(session, user_id) ) + lang = _normalize_language(db_user.language_code or settings.DEFAULT_LANGUAGE) + plans_payload = _serialize_plans( + settings, + lang, + subscription_options=cached["subscription_options"], + stars_subscription_options=cached["stars_subscription_options"], + traffic_packages=cached["traffic_packages"], + stars_traffic_packages=cached["stars_traffic_packages"], + ) + await _attach_hwid_renewal_quotes_to_plans( + session, + subscription_service, + user_id=user_id, + settings=settings, + active=active, + local_sub=local_sub, + plans=plans_payload, + ) avatar = await _ensure_cached_telegram_avatar(request, session, db_user) try: await session.commit() except Exception: await session.rollback() - lang = _normalize_language(db_user.language_code or settings.DEFAULT_LANGUAGE) 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_notifications_status = normalize_telegram_notification_status( @@ -128,14 +145,7 @@ async def _build_user_payload(request: web.Request, user_id: int) -> Dict[str, A ), "bonus_details": _serialize_referral_bonus_details(settings, lang), }, - "plans": _serialize_plans( - settings, - lang, - subscription_options=cached["subscription_options"], - stars_subscription_options=cached["stars_subscription_options"], - traffic_packages=cached["traffic_packages"], - stars_traffic_packages=cached["stars_traffic_packages"], - ), + "plans": plans_payload, "payment_methods": _serialize_payment_methods( settings, request.app, @@ -438,6 +448,102 @@ def _serialize_subscription( } +def _webapp_iso_datetime(value: Optional[Any]) -> Optional[str]: + if not value: + return None + if isinstance(value, datetime): + normalized = value if value.tzinfo else value.replace(tzinfo=timezone.utc) + return normalized.isoformat() + return str(value) + + +def _webapp_datetime_text(value: Optional[Any]) -> Optional[str]: + if not value: + return None + if isinstance(value, datetime): + normalized = value if value.tzinfo else value.replace(tzinfo=timezone.utc) + return normalized.strftime("%d.%m.%Y %H:%M") + return str(value) + + +async def _attach_hwid_renewal_quotes_to_plans( + session: AsyncSession, + subscription_service: SubscriptionService, + *, + user_id: int, + settings: Settings, + active: Optional[Dict[str, Any]], + local_sub: Optional[Any], + plans: List[Dict[str, Any]], +) -> None: + quote_method = getattr(subscription_service, "quote_hwid_device_renewal_for_subscription", None) + if not callable(quote_method): + return + if not active or not local_sub or not settings.tariffs_config: + return + if not active.get("end_date") or int(active.get("extra_hwid_devices") or 0) <= 0: + return + + default_currency = default_currency_key_for_settings(settings) + default_currency_code = payment_currency_code(default_currency) + for plan in plans: + if str(plan.get("sale_mode") or "subscription") != "subscription": + continue + target_tariff_key = str(plan.get("tariff_key") or "").strip() + if not target_tariff_key: + continue + try: + months = int(plan.get("months") or 0) + except (TypeError, ValueError): + continue + if months <= 0: + continue + try: + currency_quote = await quote_method( + session, + user_id=user_id, + target_tariff_key=target_tariff_key, + months=months, + currency=default_currency, + ) + stars_quote = await quote_method( + session, + user_id=user_id, + target_tariff_key=target_tariff_key, + months=months, + currency="stars", + ) + except Exception: + logger.exception( + "Failed to quote HWID renewal for plan %s/%s", + target_tariff_key, + months, + ) + continue + quote = currency_quote or stars_quote + if not quote: + continue + valid_from = quote.get("valid_from") + valid_until = quote.get("valid_until") + active_until = quote.get("active_until") + renewal = { + "available": True, + "device_count": int(quote.get("device_count") or 0), + "price": float(currency_quote.get("price") if currency_quote else 0), + "currency": default_currency_code, + "valid_from": _webapp_iso_datetime(valid_from), + "valid_from_text": _webapp_datetime_text(valid_from), + "valid_until": _webapp_iso_datetime(valid_until), + "valid_until_text": _webapp_datetime_text(valid_until), + "active_until": _webapp_iso_datetime(active_until), + "active_until_text": _webapp_datetime_text(active_until), + "pricing_period_months": int(quote.get("pricing_period_months") or months), + } + if stars_quote and int(stars_quote.get("price") or 0) > 0: + renewal["stars_price"] = int(stars_quote["price"]) + plan["hwid_renewal"] = renewal + + def _build_install_share_link( request: Optional[web.Request], settings: Settings, diff --git a/backend/bot/handlers/user/subscription/core.py b/backend/bot/handlers/user/subscription/core.py index d9b3e2f..3ab73bc 100644 --- a/backend/bot/handlers/user/subscription/core.py +++ b/backend/bot/handlers/user/subscription/core.py @@ -319,7 +319,11 @@ async def select_tariff_callback( @router.callback_query(F.data.startswith("tariff:period:")) async def select_tariff_period_callback( - callback: types.CallbackQuery, i18n_data: dict, settings: Settings, session: AsyncSession + callback: types.CallbackQuery, + i18n_data: dict, + settings: Settings, + session: AsyncSession, + subscription_service: SubscriptionService, ): current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE) i18n: JsonI18n = i18n_data.get("i18n_instance") @@ -333,7 +337,9 @@ async def select_tariff_period_callback( await callback.answer(get_text("error_try_again"), show_alert=True) return tariff_key, months_raw = parts[2], parts[3] - callback_context = parts[4] if len(parts) > 4 else None + callback_tokens = [part for part in parts[4:] if part] + callback_context = "bot" if "bot" in callback_tokens else None + renew_hwid_devices = "no_hwid" not in callback_tokens tariff = config.require(tariff_key) months = int(months_raw) default_currency = default_currency_key_for_settings(settings) @@ -343,6 +349,22 @@ async def select_tariff_period_callback( if price_rub is None: await callback.answer(get_text("error_try_again"), show_alert=True) return + hwid_renewal_quote = await subscription_service.quote_hwid_device_renewal_for_subscription( + session, + user_id=callback.from_user.id, + target_tariff_key=tariff.key, + months=months, + currency=default_currency, + ) + hwid_renewal_stars_quote = ( + await subscription_service.quote_hwid_device_renewal_for_subscription( + session, + user_id=callback.from_user.id, + target_tariff_key=tariff.key, + months=months, + currency="stars", + ) + ) markup = get_payment_method_keyboard( months, price_rub, @@ -354,6 +376,9 @@ async def select_tariff_period_callback( sale_mode=sale_mode_with_callback_context(f"subscription@{tariff.key}", callback_context), back_callback=f"tariff:select:{tariff.key}{callback_suffix_for_context(callback_context)}", user_id=callback.from_user.id, + hwid_renewal_quote=hwid_renewal_quote, + hwid_renewal_stars_quote=hwid_renewal_stars_quote, + hwid_renewal_selected=bool(renew_hwid_devices), ) await callback.message.edit_text(get_text("choose_payment_method"), reply_markup=markup) await callback.answer() @@ -577,7 +602,6 @@ async def hwid_devices_list_callback( if not packages: await callback.answer(get_text("no_hwid_device_packages_available"), show_alert=True) return - renewal_available = bool(active.get("device_topup_renewal_available")) markup = get_hwid_device_packages_keyboard( tariff, packages, @@ -585,14 +609,11 @@ async def hwid_devices_list_callback( i18n, settings, back_callback="main_action:my_devices", - renewal=renewal_available, - ) - text_key = ( - "select_hwid_device_renewal_package" if renewal_available else "select_hwid_device_package" + renewal=False, ) await callback.message.edit_text( get_text( - text_key, + "select_hwid_device_package", date=active.get("extra_hwid_devices_valid_until_text") or "", ), reply_markup=markup, @@ -640,6 +661,7 @@ async def hwid_devices_package_callback( await callback.answer(get_text("error_try_again"), show_alert=True) return sale_mode_base = "hwid_devices_renewal" if action == "renewal_package" else "hwid_devices" + renewal = action == "renewal_package" default_currency = default_currency_key_for_settings(settings) currency_code = default_payment_currency_code_for_settings(settings) currency_quote = await subscription_service.quote_hwid_device_topup( @@ -647,7 +669,7 @@ async def hwid_devices_package_callback( user_id=callback.from_user.id, device_count=count, tariff_key=tariff.key, - renewal=action == "renewal_package", + renewal=renewal, currency=default_currency, ) stars_quote = await subscription_service.quote_hwid_device_topup( @@ -655,7 +677,7 @@ async def hwid_devices_package_callback( user_id=callback.from_user.id, device_count=count, tariff_key=tariff.key, - renewal=action == "renewal_package", + renewal=renewal, currency="stars", ) if not currency_quote and not stars_quote: diff --git a/backend/bot/keyboards/inline/user_keyboards.py b/backend/bot/keyboards/inline/user_keyboards.py index d0a5790..0489ff7 100644 --- a/backend/bot/keyboards/inline/user_keyboards.py +++ b/backend/bot/keyboards/inline/user_keyboards.py @@ -14,6 +14,13 @@ from config.tariffs_config import ( ) BOT_MENU_CONTEXT = "bot" +HWID_RENEWAL_TOKEN = "hwid_renewal" + + +def sale_mode_tokens(sale_mode: Optional[str]) -> Tuple[str, ...]: + if not sale_mode or "|" not in sale_mode: + return () + return tuple(token.strip() for token in str(sale_mode).split("|")[1:] if token.strip()) def callback_context_from_back_callback(back_callback: Optional[str]) -> Optional[str]: @@ -24,16 +31,36 @@ def callback_context_from_back_callback(back_callback: Optional[str]) -> Optiona def sale_mode_with_callback_context(sale_mode: str, context: Optional[str]) -> str: sale_mode = sale_mode or "subscription" - if not context or "|" in sale_mode: + if not context or context in sale_mode_tokens(sale_mode): return sale_mode return f"{sale_mode}|{context}" +def sale_mode_with_token(sale_mode: str, token: str) -> str: + sale_mode = sale_mode or "subscription" + token = str(token or "").strip() + if not token or token in sale_mode_tokens(sale_mode): + return sale_mode + return f"{sale_mode}|{token}" + + +def sale_mode_without_token(sale_mode: str, token: str) -> str: + sale_mode = sale_mode or "subscription" + token = str(token or "").strip() + if not token or "|" not in sale_mode: + return sale_mode + base, *tokens = sale_mode.split("|") + kept = [item for item in tokens if item.strip() and item.strip() != token] + return "|".join([base, *kept]) + + +def sale_mode_has_token(sale_mode: Optional[str], token: str) -> bool: + return str(token or "").strip() in sale_mode_tokens(sale_mode) + + def callback_context_from_sale_mode(sale_mode: Optional[str]) -> Optional[str]: - if not sale_mode or "|" not in sale_mode: - return None - context = str(sale_mode).split("|", 1)[1].strip() - return context or None + tokens = sale_mode_tokens(sale_mode) + return BOT_MENU_CONTEXT if BOT_MENU_CONTEXT in tokens else None def callback_suffix_for_context(context: Optional[str]) -> str: @@ -484,6 +511,9 @@ def get_payment_method_keyboard( back_callback: Optional[str] = None, user_id: Optional[int] = None, is_admin: Optional[bool] = None, + hwid_renewal_quote: Optional[Dict[str, Any]] = None, + hwid_renewal_stars_quote: Optional[Dict[str, Any]] = None, + hwid_renewal_selected: bool = True, ) -> InlineKeyboardMarkup: _ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs) builder = InlineKeyboardBuilder() @@ -492,12 +522,39 @@ def get_payment_method_keyboard( return str(int(val)) if float(val).is_integer() else f"{val:g}" value_str = _format_value(months) - import logging as _kbd_logging - - _kbd_logging.info( - "payment_method_keyboard build: order=%s", - settings.payment_methods_order, - ) + payment_sale_mode = sale_mode + selected_hwid_quote = hwid_renewal_quote or hwid_renewal_stars_quote + if selected_hwid_quote: + tariff_key = None + sale_mode_main = str(sale_mode or "").split("|", 1)[0] + if "@" in sale_mode_main: + tariff_key = sale_mode_main.split("@", 1)[1] + context = callback_context_from_sale_mode(sale_mode) + toggle_tokens = [f"tariff:period:{tariff_key}:{value_str}"] + if context: + toggle_tokens.append(context) + toggle_tokens.append("no_hwid" if hwid_renewal_selected else "hwid") + builder.row( + InlineKeyboardButton( + text=_( + "payment_hwid_renewal_toggle_on" + if hwid_renewal_selected + else "payment_hwid_renewal_toggle_off", + count=int(selected_hwid_quote.get("device_count") or 0), + price=( + hwid_renewal_quote.get("price") + if hwid_renewal_quote + else hwid_renewal_stars_quote.get("price") + ), + currency_symbol=currency_symbol_val, + ), + callback_data=":".join(toggle_tokens), + ) + ) + if hwid_renewal_selected: + payment_sale_mode = sale_mode_with_token(sale_mode, HWID_RENEWAL_TOKEN) + else: + payment_sale_mode = sale_mode_without_token(sale_mode, HWID_RENEWAL_TOKEN) from bot.payment_providers import get_provider_spec, provider_telegram_button_text for method in settings.payment_methods_order: @@ -518,7 +575,7 @@ def get_payment_method_keyboard( value=value_str, rub_price=price, stars_price=stars_price, - sale_mode=sale_mode, + sale_mode=payment_sale_mode, ) if not callback_data: continue @@ -577,7 +634,7 @@ def get_yk_autopay_choice_keyboard( builder.row( InlineKeyboardButton( text=_(key="yookassa_autopay_pay_saved_card_button"), - callback_data=f"pay_yk_saved_list:{value_str}:{price_str}{suffix}", + callback_data=f"pay_yk_saved_list:{value_str}:{price_str}:0{suffix}", ) ) builder.row( diff --git a/backend/bot/payment_providers/base.py b/backend/bot/payment_providers/base.py index 88d7f4f..9f5cf65 100644 --- a/backend/bot/payment_providers/base.py +++ b/backend/bot/payment_providers/base.py @@ -114,6 +114,7 @@ class WebAppPaymentContext: sale_mode: str currency: str = "RUB" traffic_gb: Optional[float] = None + hwid_device_count: Optional[int] = None hwid_valid_from: Optional[Any] = None hwid_valid_until: Optional[Any] = None hwid_pricing_period_months: Optional[int] = None diff --git a/backend/bot/payment_providers/cryptopay.py b/backend/bot/payment_providers/cryptopay.py index 94457f5..4efbd18 100644 --- a/backend/bot/payment_providers/cryptopay.py +++ b/backend/bot/payment_providers/cryptopay.py @@ -192,6 +192,7 @@ class CryptoPayService: sale_mode: str = "subscription", url_kind: str = "bot", hwid_quote: Optional[dict] = None, + hwid_device_count: Optional[int] = None, currency: Optional[str] = None, ) -> Optional[str]: if not self.configured or not self.client: @@ -210,7 +211,11 @@ class CryptoPayService: return None sale_base = sale_mode_base(sale_mode) - amounts = payment_record_amounts(months=months, sale_mode=sale_mode) + amounts = payment_record_amounts( + months=months, + sale_mode=sale_mode, + hwid_device_count=hwid_device_count, + ) try: payment_record = await payment_dal.create_payment_record( session, @@ -252,6 +257,7 @@ class CryptoPayService: "payment_db_id": str(payment_record.payment_id), "sale_mode": sale_mode, "traffic_gb": str(months) if sale_mode_is_traffic(sale_mode) else None, + "hwid_devices": amounts.purchased_hwid_devices, } ) try: @@ -513,6 +519,7 @@ async def create_webapp_payment(ctx: WebAppPaymentContext) -> web.Response: } if ctx.hwid_valid_from and ctx.hwid_valid_until else None, + hwid_device_count=ctx.hwid_device_count, ) if not url: return payment_failed() diff --git a/backend/bot/payment_providers/platega.py b/backend/bot/payment_providers/platega.py index 9fb8ab6..e4f15fc 100644 --- a/backend/bot/payment_providers/platega.py +++ b/backend/bot/payment_providers/platega.py @@ -609,6 +609,7 @@ async def _create_webapp_payment(ctx: WebAppPaymentContext, variant: str) -> web months=ctx.months, sale_mode=ctx.sale_mode, traffic_gb=ctx.traffic_gb, + hwid_device_count=ctx.hwid_device_count, ) payment = await create_webapp_payment_record( ctx, diff --git a/backend/bot/payment_providers/shared/callbacks.py b/backend/bot/payment_providers/shared/callbacks.py index 66cc7c7..d646df2 100644 --- a/backend/bot/payment_providers/shared/callbacks.py +++ b/backend/bot/payment_providers/shared/callbacks.py @@ -8,8 +8,10 @@ from aiogram import types from sqlalchemy.ext.asyncio import AsyncSession from bot.keyboards.inline.user_keyboards import ( + HWID_RENEWAL_TOKEN, get_payment_url_keyboard, payment_methods_back_callback, + sale_mode_has_token, ) from bot.middlewares.i18n import JsonI18n from db.dal import payment_dal @@ -123,6 +125,27 @@ async def quote_hwid_callback_parts( subscription_service, currency: str = "rub", ) -> tuple[Optional[PaymentCallbackParts], Optional[dict]]: + base = sale_mode_base(parts.sale_mode) + if base == "subscription" and sale_mode_has_token(parts.sale_mode, HWID_RENEWAL_TOKEN): + try: + months = int(parts.months) + except (TypeError, ValueError): + return None, None + quote = await subscription_service.quote_hwid_device_renewal_for_subscription( + session, + user_id=user_id, + target_tariff_key=sale_mode_tariff_key(parts.sale_mode), + months=months, + currency=currency, + ) + if not quote: + return parts, None + quoted_parts = PaymentCallbackParts( + months=months, + price=float(parts.price or 0) + float(quote.get("price") or 0), + sale_mode=parts.sale_mode, + ) + return quoted_parts, quote if not sale_mode_is_hwid_devices(parts.sale_mode): return parts, None device_count = parse_positive_int_units(parts.months) diff --git a/backend/bot/payment_providers/shared/common.py b/backend/bot/payment_providers/shared/common.py index 53ac7b9..19680f8 100644 --- a/backend/bot/payment_providers/shared/common.py +++ b/backend/bot/payment_providers/shared/common.py @@ -100,6 +100,11 @@ def build_payment_record_payload( base = sale_mode_base(sale_mode) is_traffic = sale_mode_is_traffic(sale_mode) is_hwid = sale_mode_is_hwid_devices(sale_mode) + hwid_devices = int(float(months)) if is_hwid else None + if hwid_quote: + quote_devices = parse_positive_int_units(hwid_quote.get("device_count")) + if quote_devices is not None: + hwid_devices = quote_devices payload = { "user_id": user_id, "amount": amount, @@ -111,9 +116,9 @@ def build_payment_record_payload( "sale_mode": sale_mode, "tariff_key": sale_mode_tariff_key(sale_mode), "purchased_gb": float(months) if is_traffic else None, - "purchased_hwid_devices": int(float(months)) if is_hwid else None, + "purchased_hwid_devices": hwid_devices, } - if hwid_quote and is_hwid: + if hwid_quote and hwid_devices is not None: payload.update( { "hwid_valid_from": hwid_quote.get("valid_from"), @@ -164,14 +169,20 @@ def payment_record_amounts( months: Any, sale_mode: str, traffic_gb: Optional[float] = None, + hwid_device_count: Optional[int] = None, ) -> PaymentRecordAmounts: traffic_sale = sale_mode_is_traffic(sale_mode) hwid_devices_sale = sale_mode_is_hwid_devices(sale_mode) units = traffic_gb if traffic_sale and traffic_gb is not None else months + purchased_hwid_devices = int(float(months)) if hwid_devices_sale else None + if not hwid_devices_sale and hwid_device_count is not None: + parsed_hwid_devices = parse_positive_int_units(hwid_device_count) + if parsed_hwid_devices is not None: + purchased_hwid_devices = parsed_hwid_devices return PaymentRecordAmounts( months=int(float(units)) if traffic_sale else int(float(months)), purchased_gb=float(units) if traffic_sale else None, - purchased_hwid_devices=int(float(months)) if hwid_devices_sale else None, + purchased_hwid_devices=purchased_hwid_devices, tariff_key=sale_mode_tariff_key(sale_mode), traffic_sale=traffic_sale, hwid_devices_sale=hwid_devices_sale, @@ -281,6 +292,7 @@ async def create_webapp_payment_record( months=ctx.months, sale_mode=ctx.sale_mode, traffic_gb=ctx.traffic_gb, + hwid_device_count=ctx.hwid_device_count, ) return await create_base_payment_record( ctx.session, diff --git a/backend/bot/payment_providers/shared/success.py b/backend/bot/payment_providers/shared/success.py index 26e342a..317c399 100644 --- a/backend/bot/payment_providers/shared/success.py +++ b/backend/bot/payment_providers/shared/success.py @@ -156,6 +156,28 @@ def append_hwid_renewal_note( return f"{text}\n\n{note}" +def append_hwid_renewed_note( + text: str, + translator: Translator, + *, + count: Any, + valid_until: Optional[datetime], +) -> str: + try: + count_int = int(count or 0) + except (TypeError, ValueError): + count_int = 0 + if count_int <= 0: + return text + date_text = valid_until.strftime("%Y-%m-%d") if valid_until else "" + note = translator( + "payment_successful_hwid_devices_renewed_note", + count=format_human_units(count_int), + date=date_text, + ) + return f"{text}\n\n{note}" + + async def send_success_message_to_user( *, bot: Bot, @@ -320,8 +342,37 @@ async def finalize_successful_payment( req.log_prefix, req.payment.payment_id, ) + try: + await payment_dal.update_payment_status_by_db_id( + req.session, + req.payment.payment_id, + "activation_failed", + ) + await req.session.commit() + except Exception: + await req.session.rollback() + logging.exception( + "%s: failed to mark payment %s activation_failed.", + req.log_prefix, + req.payment.payment_id, + ) return None + try: + from bot.app.web.webapp.cache_helpers import invalidate_webapp_user_caches + + await invalidate_webapp_user_caches( + req.settings, + req.user_id, + include_devices=True, + ) + except Exception: + logging.exception( + "%s: failed to invalidate webapp caches for user %s.", + req.log_prefix, + req.user_id, + ) + db_user, language = await resolve_user_language( req.session, user_id=req.user_id, @@ -363,12 +414,20 @@ async def finalize_successful_payment( ) ) if is_subscription and activation: - success_text = append_hwid_renewal_note( - success_text, - translator, - count=activation.get("hwid_devices_renewal_recommended_count"), - valid_until=activation.get("hwid_devices_valid_until"), - ) + if activation.get("hwid_devices_renewed_count"): + success_text = append_hwid_renewed_note( + success_text, + translator, + count=activation.get("hwid_devices_renewed_count"), + valid_until=final_end_date or activation.get("hwid_devices_renewed_until"), + ) + else: + success_text = append_hwid_renewal_note( + success_text, + translator, + count=activation.get("hwid_devices_renewal_recommended_count"), + valid_until=activation.get("hwid_devices_valid_until"), + ) if req.text_prefix: success_text = f"{req.text_prefix}\n{success_text}" diff --git a/backend/bot/payment_providers/shared/webhooks.py b/backend/bot/payment_providers/shared/webhooks.py index 474df6b..204614e 100644 --- a/backend/bot/payment_providers/shared/webhooks.py +++ b/backend/bot/payment_providers/shared/webhooks.py @@ -51,7 +51,7 @@ async def notify_user_payment_failed( message_key: str = "payment_failed", ) -> None: """Send the localized ``payment_failed`` text to the user; never raises.""" - db_user = payment.user or await user_dal.get_user_by_id(session, payment.user_id) + db_user = await user_dal.get_user_by_id(session, payment.user_id) language = ( db_user.language_code if db_user and db_user.language_code else settings.DEFAULT_LANGUAGE ) diff --git a/backend/bot/payment_providers/stars.py b/backend/bot/payment_providers/stars.py index 0a69ad1..70c7064 100644 --- a/backend/bot/payment_providers/stars.py +++ b/backend/bot/payment_providers/stars.py @@ -344,6 +344,7 @@ async def create_webapp_payment(ctx: WebAppPaymentContext) -> web.Response: months=ctx.months, sale_mode=ctx.sale_mode, traffic_gb=ctx.traffic_gb, + hwid_device_count=ctx.hwid_device_count, ) payment = await create_webapp_payment_record( ctx, diff --git a/backend/bot/payment_providers/wata.py b/backend/bot/payment_providers/wata.py index 422db61..3d0109e 100644 --- a/backend/bot/payment_providers/wata.py +++ b/backend/bot/payment_providers/wata.py @@ -978,6 +978,7 @@ async def create_webapp_payment(ctx: WebAppPaymentContext) -> web.Response: months=ctx.months, sale_mode=ctx.sale_mode, traffic_gb=ctx.traffic_gb, + hwid_device_count=ctx.hwid_device_count, ) months_for_lookup = ( reuse_amounts.months if sale_mode_base(ctx.sale_mode) == "subscription" else None diff --git a/backend/bot/payment_providers/yookassa.py b/backend/bot/payment_providers/yookassa.py index 3c420fa..65f79b9 100644 --- a/backend/bot/payment_providers/yookassa.py +++ b/backend/bot/payment_providers/yookassa.py @@ -448,6 +448,36 @@ def _metadata_value_present(value: Optional[Any]) -> bool: return value is not None and str(value).strip() != "" +def _metadata_int(value: Optional[Any]) -> Optional[int]: + if not _metadata_value_present(value): + return None + try: + return int(float(str(value).strip())) + except (TypeError, ValueError): + return None + + +def _metadata_float(value: Optional[Any]) -> Optional[float]: + if not _metadata_value_present(value): + return None + try: + return float(str(value).strip()) + except (TypeError, ValueError): + return None + + +def _metadata_datetime(value: Optional[Any]) -> Optional[datetime]: + if not _metadata_value_present(value): + return None + try: + parsed = datetime.fromisoformat(str(value).strip().replace("Z", "+00:00")) + except (TypeError, ValueError): + return None + if parsed.tzinfo is None: + return parsed.replace(tzinfo=timezone.utc) + return parsed + + def _resolve_yookassa_activation_amounts( *, sale_mode_base: str, @@ -559,6 +589,11 @@ async def process_successful_payment( months_for_record = int(subscription_months) if sale_mode_base == "subscription" else 0 payment_value = float(amount_data.get("value", 0.0)) yk_payment_id_from_hook = payment_info_from_webhook.get("id") + hwid_valid_from = _metadata_datetime(metadata.get("hwid_valid_from")) + hwid_valid_until = _metadata_datetime(metadata.get("hwid_valid_until")) + hwid_pricing_period_months = _metadata_int(metadata.get("hwid_pricing_period_months")) + hwid_proration_ratio = _metadata_float(metadata.get("hwid_proration_ratio")) + hwid_full_price = _metadata_float(metadata.get("hwid_full_price")) if _is_hwid_device_sale_base(sale_mode_base) and hwid_devices_count <= 0: logging.error( @@ -574,6 +609,19 @@ async def process_successful_payment( yk_payment_id_from_hook, ) return + if sale_mode_base == "subscription" and hwid_devices_count > 0: + if ( + not hwid_valid_from + or not hwid_valid_until + or hwid_valid_from >= hwid_valid_until + or hwid_full_price is None + ): + logging.error( + "YooKassa subscription+HWID payment %s has invalid HWID metadata: %s", + yk_payment_id_from_hook, + metadata, + ) + return payment_record = None # If this is an auto-renewal (no payment_db_id in metadata), ensure a payment record exists @@ -600,6 +648,16 @@ async def process_successful_payment( or f"Auto-renewal for {months_for_record or subscription_months} months", provider="yookassa", provider_payment_id=yk_payment_id_from_hook, + sale_mode=sale_mode, + tariff_key=_sale_mode_tariff_key(sale_mode), + purchased_hwid_devices=( + hwid_devices_count if hwid_devices_count > 0 else None + ), + hwid_valid_from=hwid_valid_from, + hwid_valid_until=hwid_valid_until, + hwid_pricing_period_months=hwid_pricing_period_months, + hwid_proration_ratio=hwid_proration_ratio, + hwid_full_price=hwid_full_price, ) payment_db_id = payment_record.payment_id except Exception as e_ensure: @@ -1315,6 +1373,36 @@ def _parse_offer_payload(payload: str) -> Optional[Tuple[float, float, str]]: return None +def _parse_saved_list_payload(payload: str) -> Optional[Tuple[float, float, int, str]]: + parts = payload.split(":") + if len(parts) < 2: + return None + try: + months = float(parts[0]) + price = float(parts[1]) + except (ValueError, IndexError): + return None + + page = 0 + sale_mode = "subscription" + if len(parts) > 2: + try: + page = int(parts[2]) + sale_mode = parts[3] if len(parts) > 3 else "subscription" + except ValueError: + sale_mode = parts[2] + return months, price, page, sale_mode + + +def _metadata_iso(value: Any) -> Optional[str]: + if value is None: + return None + if hasattr(value, "isoformat"): + return value.isoformat() + text = str(value).strip() + return text or None + + def _format_saved_payment_method_title( get_text, network: Optional[str], last4: Optional[str], is_default: bool ) -> str: @@ -1363,6 +1451,9 @@ async def _initiate_yk_payment( return False sale_base = _sale_mode_base(sale_mode) + hwid_device_count = None + if hwid_quote: + hwid_device_count = parse_positive_int_units(hwid_quote.get("device_count")) payment_description = ( get_text("payment_description_traffic", traffic_gb=_format_value(months)) if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"} @@ -1379,12 +1470,14 @@ async def _initiate_yk_payment( "status": "pending_yookassa", "description": payment_description, "subscription_duration_months": int(months) if sale_base == "subscription" else None, - "sale_mode": sale_base, + "sale_mode": sale_mode, "tariff_key": sale_mode.split("@", 1)[1].split("|", 1)[0] if "@" in sale_mode else None, "purchased_gb": float(months) if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"} else None, - "purchased_hwid_devices": int(months) if sale_base in HWID_DEVICE_SALE_BASES else None, + "purchased_hwid_devices": ( + int(months) if sale_base in HWID_DEVICE_SALE_BASES else hwid_device_count + ), "hwid_valid_from": hwid_quote.get("valid_from") if hwid_quote else None, "hwid_valid_until": hwid_quote.get("valid_until") if hwid_quote else None, "hwid_pricing_period_months": hwid_quote.get("pricing_period_months") @@ -1430,6 +1523,19 @@ async def _initiate_yk_payment( yookassa_metadata["traffic_gb"] = str(months) if sale_base in HWID_DEVICE_SALE_BASES: yookassa_metadata["hwid_devices"] = str(months) + elif hwid_device_count: + yookassa_metadata["hwid_devices"] = str(hwid_device_count) + if hwid_quote and hwid_device_count: + hwid_metadata = { + "hwid_valid_from": _metadata_iso(hwid_quote.get("valid_from")), + "hwid_valid_until": _metadata_iso(hwid_quote.get("valid_until")), + "hwid_pricing_period_months": hwid_quote.get("pricing_period_months"), + "hwid_proration_ratio": hwid_quote.get("proration_ratio"), + "hwid_full_price": hwid_quote.get("full_price"), + } + yookassa_metadata.update( + {key: str(value) for key, value in hwid_metadata.items() if value is not None} + ) if payment_method_id: yookassa_metadata["used_saved_payment_method_id"] = payment_method_id @@ -1709,22 +1815,6 @@ async def pay_yk_callback_handler( months, price_rub, sale_mode = parsed hwid_quote = None - if _sale_mode_base(sale_mode) in HWID_DEVICE_SALE_BASES: - quoted_parts, hwid_quote = await quote_hwid_callback_parts( - session=session, - user_id=callback.from_user.id, - parts=PaymentCallbackParts(months=months, price=price_rub, sale_mode=sale_mode), - subscription_service=yookassa_service.subscription_service, - currency=default_currency_key_for_settings(settings), - ) - if not quoted_parts: - try: - await callback.answer(get_text("error_try_again"), show_alert=True) - except Exception: - pass - return - months = quoted_parts.months - price_rub = quoted_parts.price user_id = callback.from_user.id currency_code_for_yk = default_payment_currency_code_for_settings(settings) autopay_enabled = bool( @@ -1786,6 +1876,22 @@ async def pay_yk_callback_handler( pass return + quoted_parts, hwid_quote = await quote_hwid_callback_parts( + session=session, + user_id=callback.from_user.id, + parts=PaymentCallbackParts(months=months, price=price_rub, sale_mode=sale_mode), + subscription_service=yookassa_service.subscription_service, + currency=default_currency_key_for_settings(settings), + ) + if not quoted_parts: + try: + await callback.answer(get_text("error_try_again"), show_alert=True) + except Exception: + pass + return + months = quoted_parts.months + price_rub = quoted_parts.price + await _initiate_yk_payment( callback, settings=settings, @@ -1863,6 +1969,22 @@ async def pay_yk_new_card_handler( return months, price_rub, sale_mode = parsed + hwid_quote = None + quoted_parts, hwid_quote = await quote_hwid_callback_parts( + session=session, + user_id=callback.from_user.id, + parts=PaymentCallbackParts(months=months, price=price_rub, sale_mode=sale_mode), + subscription_service=yookassa_service.subscription_service, + currency=default_currency_key_for_settings(settings), + ) + if not quoted_parts: + try: + await callback.answer(get_text("error_try_again"), show_alert=True) + except Exception: + pass + return + months = quoted_parts.months + price_rub = quoted_parts.price user_id = callback.from_user.id currency_code_for_yk = default_payment_currency_code_for_settings(settings) autopay_enabled = bool( @@ -1889,6 +2011,7 @@ async def pay_yk_new_card_handler( save_payment_method=autopay_enabled and autopay_require_binding, back_callback=payment_methods_back_callback(_format_value(months), sale_mode, price_rub), sale_mode=sale_mode, + hwid_quote=hwid_quote, ) try: await callback.answer() @@ -1928,27 +2051,15 @@ async def pay_yk_saved_list_handler( pass return - parts = data_payload.split(":") - if len(parts) < 2: + parsed_saved_list = _parse_saved_list_payload(data_payload) + if not parsed_saved_list: logging.error(f"pay_yk_saved_list payload missing components: {callback.data}") try: await callback.answer(get_text("error_try_again"), show_alert=True) except Exception: pass return - - try: - months = float(parts[0]) - price_rub = float(parts[1]) - page = int(parts[2]) if len(parts) > 2 else 0 - sale_mode = parts[3] if len(parts) > 3 else "subscription" - except (ValueError, IndexError): - logging.error(f"pay_yk_saved_list payload parsing error: {callback.data}") - try: - await callback.answer(get_text("error_try_again"), show_alert=True) - except Exception: - pass - return + months, price_rub, page, sale_mode = parsed_saved_list autopay_enabled = bool( settings.yookassa_autopayments_active @@ -2138,6 +2249,24 @@ async def pay_yk_use_saved_handler( method_identifier = parts[2] user_id = callback.from_user.id + base_months = months + base_price_rub = price_rub + hwid_quote = None + quoted_parts, hwid_quote = await quote_hwid_callback_parts( + session=session, + user_id=user_id, + parts=PaymentCallbackParts(months=months, price=price_rub, sale_mode=sale_mode), + subscription_service=yookassa_service.subscription_service, + currency=default_currency_key_for_settings(settings), + ) + if not quoted_parts: + try: + await callback.answer(get_text("error_try_again"), show_alert=True) + except Exception: + pass + return + months = quoted_parts.months + price_rub = quoted_parts.price try: saved_methods = await user_billing_dal.list_user_payment_methods( @@ -2182,10 +2311,13 @@ async def pay_yk_use_saved_handler( price_rub=price_rub, currency_code_for_yk=currency_code_for_yk, save_payment_method=False, - back_callback=f"pay_yk_saved_list:{_format_value(months)}:{price_rub}:{sale_mode}", + back_callback=( + f"pay_yk_saved_list:{_format_value(base_months)}:{base_price_rub}:0:{sale_mode}" + ), payment_method_id=selected_method.provider_payment_method_id, selected_method_internal_id=selected_method.method_id, sale_mode=sale_mode, + hwid_quote=hwid_quote, ) try: await callback.answer() @@ -2754,6 +2886,7 @@ async def create_webapp_payment(ctx: WebAppPaymentContext) -> web.Response: months=ctx.months, sale_mode=ctx.sale_mode, traffic_gb=ctx.traffic_gb, + hwid_device_count=ctx.hwid_device_count, ) payment = await create_webapp_payment_record( ctx, @@ -2775,8 +2908,8 @@ async def create_webapp_payment(ctx: WebAppPaymentContext) -> web.Response: } if amounts.traffic_sale: metadata["traffic_gb"] = format_number_for_payload(ctx.traffic_gb or ctx.months) - if amounts.hwid_devices_sale: - metadata["hwid_devices"] = str(int(float(ctx.months))) + if amounts.purchased_hwid_devices: + metadata["hwid_devices"] = str(int(amounts.purchased_hwid_devices)) if amounts.tariff_key: metadata["tariff_key"] = amounts.tariff_key response = await service.create_payment( diff --git a/backend/bot/services/subscription_service_impl/devices.py b/backend/bot/services/subscription_service_impl/devices.py index f19ea3f..917e137 100644 --- a/backend/bot/services/subscription_service_impl/devices.py +++ b/backend/bot/services/subscription_service_impl/devices.py @@ -116,6 +116,64 @@ class HwidDeviceMixin: packages = package_set.for_currency(currency) return next((pkg for pkg in packages if int(pkg.count) == int(device_count)), None) + @staticmethod + def _quote_hwid_full_period_package_price( + tariff: Tariff, + *, + device_count: int, + period_months: int, + currency: str, + ) -> Optional[Dict[str, Any]]: + package_set = tariff.hwid_device_packages + if not package_set: + return None + try: + target_count = int(device_count) + months = max(1, int(period_months)) + except (TypeError, ValueError): + return None + if target_count <= 0: + return None + + packages = [ + package + for package in package_set.for_currency(currency) + if int(getattr(package, "count", 0) or 0) > 0 + ] + if not packages: + return None + + best: Dict[int, tuple[float, List[Any]]] = {0: (0.0, [])} + for count in range(1, target_count + 1): + best_for_count: Optional[tuple[float, List[Any]]] = None + for package in packages: + package_count = int(package.count) + previous = best.get(count - package_count) + if previous is None: + continue + price = previous[0] + float(package.price_for_period(months)) + selected = [*previous[1], package] + if best_for_count is None or price < best_for_count[0]: + best_for_count = (price, selected) + if best_for_count is not None: + best[count] = best_for_count + + resolved = best.get(target_count) + if resolved is None: + return None + full_price, selected_packages = resolved + rounded_price = HwidDeviceMixin._round_hwid_price(full_price, currency=currency) + if currency == "stars": + rounded_price = float(int(math.ceil(rounded_price))) + return { + "price": rounded_price, + "full_price": float(full_price), + "pricing_period_months": months, + "proration_ratio": 1.0, + "currency": currency, + "package_counts": [int(package.count) for package in selected_packages], + } + def _quote_hwid_package_price( self, *, @@ -128,16 +186,10 @@ class HwidDeviceMixin: ) -> Dict[str, Any]: period_months = max(1, int(getattr(sub, "duration_months", None) or 1)) full_price = float(package.price_for_period(period_months)) - period_start = self._as_aware_utc(getattr(sub, "start_date", None)) - period_end = self._as_aware_utc(getattr(sub, "end_date", None)) or valid_until - inferred_period_start = add_months(period_end, -period_months) - if not period_start or period_start >= period_end or period_start < inferred_period_start: - period_start = inferred_period_start - - basis_seconds = max(1.0, (period_end - period_start).total_seconds()) + basis_seconds = max(1.0, float(period_months * 30 * 24 * 60 * 60)) billable_start = max(now, valid_from) billable_seconds = max(0.0, (valid_until - billable_start).total_seconds()) - ratio = billable_seconds / basis_seconds + ratio = min(1.0, billable_seconds / basis_seconds) raw_price = full_price * ratio price = self._round_hwid_price(raw_price, currency=currency) min_price = getattr(package, "min_price", None) @@ -230,6 +282,80 @@ class HwidDeviceMixin: ) return quote + async def quote_hwid_device_renewal_for_subscription( + self, + session: AsyncSession, + *, + user_id: int, + target_tariff_key: str, + months: int, + currency: str = "rub", + now: Optional[datetime] = None, + ) -> Optional[Dict[str, Any]]: + try: + period_months = int(months) + except (TypeError, ValueError): + return None + if period_months <= 0: + return None + + db_user = await user_dal.get_user_by_id(session, user_id) + if not db_user or not db_user.panel_user_uuid: + return None + sub = await subscription_dal.get_active_subscription_by_user_id( + session, user_id, db_user.panel_user_uuid + ) + if not sub or not sub.end_date: + return None + + now = now or datetime.now(timezone.utc) + subscription_end = self._as_aware_utc(sub.end_date) + if not subscription_end or subscription_end <= now: + return None + + try: + tariff = self._resolve_tariff(target_tariff_key) + except Exception: + return None + if not tariff or tariff.billing_model != "period": + return None + base_hwid_limit = self._base_hwid_limit_for_tariff(tariff) + if base_hwid_limit in (None, 0): + return None + + entitlement_summary = await tariff_dal.get_hwid_device_entitlement_summary( + session, + subscription_id=sub.subscription_id, + at=now, + ) + active_devices = int(entitlement_summary.get("active_devices") or 0) + if active_devices <= 0: + return None + + price_quote = self._quote_hwid_full_period_package_price( + tariff, + device_count=active_devices, + period_months=period_months, + currency=currency, + ) + if not price_quote: + return None + + valid_from = subscription_end + valid_until = add_months(valid_from, period_months) + price_quote.update( + { + "subscription_id": sub.subscription_id, + "tariff_key": tariff.key, + "device_count": active_devices, + "renewal": True, + "valid_from": valid_from, + "valid_until": valid_until, + "active_until": entitlement_summary.get("active_until"), + } + ) + return price_quote + async def activate_hwid_device_topup( self, session: AsyncSession, diff --git a/backend/bot/services/subscription_service_impl/lifecycle.py b/backend/bot/services/subscription_service_impl/lifecycle.py index ff34104..40c62ab 100644 --- a/backend/bot/services/subscription_service_impl/lifecycle.py +++ b/backend/bot/services/subscription_service_impl/lifecycle.py @@ -178,6 +178,7 @@ class SubscriptionLifecycleMixin: user_id: int, target_tariff_key: str, mode: str, + payment_id: Optional[int] = None, ) -> Optional[Dict[str, Any]]: config = self._tariffs_config() if not config: @@ -336,7 +337,7 @@ class SubscriptionLifecycleMixin: "from_tariff_key": before_tariff_key, "to_tariff_key": target.key, "mode": mode, - "payment_id": None, + "payment_id": payment_id, "days_before": options.get("remaining_days"), "days_after": (updated.end_date - now).days if updated.end_date and target.billing_model == "period" @@ -454,27 +455,11 @@ class SubscriptionLifecycleMixin: user_id, tariff_key, "paid_diff", + payment_id=payment_db_id, ) if result: sub = await subscription_dal.get_active_subscription_by_user_id(session, user_id) if sub: - await tariff_dal.create_tariff_change( - session, - { - "subscription_id": sub.subscription_id, - "from_tariff_key": None, - "to_tariff_key": tariff_key, - "mode": "paid_diff", - "payment_id": payment_db_id, - "days_before": None, - "days_after": (sub.end_date - datetime.now(timezone.utc)).days - if sub.end_date - else None, - "converted_bytes": None, - "eff_price_before": None, - "eff_price_after": sub.effective_monthly_price_rub, - }, - ) result["end_date"] = sub.end_date result["is_active"] = sub.is_active db_user = await user_dal.get_user_by_id(session, user_id) @@ -494,10 +479,29 @@ class SubscriptionLifecycleMixin: await self._record_payment_context( session, payment_db_id, - sale_mode=sale_mode_base, + sale_mode=sale_mode, tariff_key=tariff.key if tariff else tariff_key, purchased_gb=None, ) + payment = await payment_dal.get_payment_by_db_id(session, payment_db_id) + try: + hwid_renewal_devices = int(getattr(payment, "purchased_hwid_devices", 0) or 0) + except (TypeError, ValueError): + hwid_renewal_devices = 0 + try: + hwid_renewal_price = ( + float(getattr(payment, "hwid_full_price", 0) or 0) + if hwid_renewal_devices > 0 + else 0.0 + ) + except (TypeError, ValueError): + hwid_renewal_price = 0.0 + hwid_renewal_valid_from = self._as_aware_utc( + getattr(payment, "hwid_valid_from", None) if payment else None + ) + hwid_renewal_valid_until = self._as_aware_utc( + getattr(payment, "hwid_valid_until", None) if payment else None + ) db_user = await user_dal.get_user_by_id(session, user_id) if not db_user: @@ -569,6 +573,26 @@ class SubscriptionLifecycleMixin: promo_code_id_from_payment = None final_end_date = start_date + timedelta(days=duration_days_total) + if hwid_renewal_devices > 0 and hwid_renewal_valid_until and applied_promo_bonus_days: + hwid_renewal_valid_until = hwid_renewal_valid_until + timedelta( + days=applied_promo_bonus_days + ) + if payment: + payment.hwid_valid_until = hwid_renewal_valid_until + elif applied_promo_bonus_days > 0 and current_active_sub: + try: + await tariff_dal.extend_hwid_device_purchases_for_subscription_bonus( + session, + subscription_id=current_active_sub.subscription_id, + at=datetime.now(timezone.utc), + subscription_end_before=start_date, + delta=timedelta(days=applied_promo_bonus_days), + ) + except Exception: + logging.exception( + "Failed to extend HWID device purchases for promo payment bonus of user %s", + user_id, + ) await subscription_dal.deactivate_other_active_subscriptions( session, panel_user_uuid, panel_sub_link_id ) @@ -614,7 +638,8 @@ class SubscriptionLifecycleMixin: premium_topup_balance_bytes, premium_topup_used_bytes, ) - effective_monthly_price = float(payment_amount) / max(1, months_int) + subscription_amount_for_pricing = max(0.0, float(payment_amount) - hwid_renewal_price) + effective_monthly_price = subscription_amount_for_pricing / max(1, months_int) regular_bonus_carry = int(getattr(current_active_sub, "regular_bonus_bytes", 0) or 0) regular_unl_carry = bool(getattr(current_active_sub, "regular_unlimited_override", False)) traffic_limit_bytes = self._traffic_limit_for_period_tariff( @@ -698,6 +723,31 @@ class SubscriptionLifecycleMixin: final_subscription_url = updated_panel_user.get("subscriptionUrl") final_panel_short_uuid = updated_panel_user.get("shortUuid", panel_short_uuid) + hwid_devices_renewed_count = 0 + hwid_devices_renewed_until = None + if hwid_renewal_devices > 0: + if ( + hwid_renewal_valid_from + and hwid_renewal_valid_until + and hwid_renewal_valid_from < hwid_renewal_valid_until + ): + await tariff_dal.create_hwid_device_purchase( + session, + subscription_id=new_or_updated_sub.subscription_id, + payment_id=payment_db_id, + purchased_devices=hwid_renewal_devices, + valid_from=hwid_renewal_valid_from, + valid_until=hwid_renewal_valid_until, + ) + hwid_devices_renewed_count = hwid_renewal_devices + hwid_devices_renewed_until = hwid_renewal_valid_until + else: + logging.warning( + "Skipping HWID renewal purchase for payment %s: invalid window %s -> %s", + payment_db_id, + hwid_renewal_valid_from, + hwid_renewal_valid_until, + ) await self._send_payment_success_email( db_user=db_user, @@ -718,8 +768,12 @@ class SubscriptionLifecycleMixin: "subscription_url": final_subscription_url, "applied_promo_bonus_days": applied_promo_bonus_days, "tariff_key": tariff.key if tariff else None, - "hwid_devices_renewal_recommended_count": extra_hwid_devices, - "hwid_devices_valid_until": hwid_devices_valid_until, + "hwid_devices_renewal_recommended_count": 0 + if hwid_devices_renewed_count + else extra_hwid_devices, + "hwid_devices_valid_until": hwid_devices_renewed_until or hwid_devices_valid_until, + "hwid_devices_renewed_count": hwid_devices_renewed_count, + "hwid_devices_renewed_until": hwid_devices_renewed_until, } async def extend_active_subscription_days( @@ -728,6 +782,7 @@ class SubscriptionLifecycleMixin: user_id: int, bonus_days: int, reason: str = "bonus", + extend_hwid_devices: bool = True, ) -> Optional[datetime]: reason_lower = (reason or "").lower() apply_main_traffic_limit = any( @@ -798,6 +853,21 @@ class SubscriptionLifecycleMixin: updated_sub_model = await subscription_dal.update_subscription_end_date( session, active_sub.subscription_id, new_end_date_obj ) + if updated_sub_model and extend_hwid_devices: + try: + await tariff_dal.extend_hwid_device_purchases_for_subscription_bonus( + session, + subscription_id=active_sub.subscription_id, + at=now_utc, + subscription_end_before=current_end_date, + delta=timedelta(days=bonus_days), + ) + except Exception: + logging.exception( + "Failed to extend HWID device purchases for %s bonus of user %s", + reason, + user_id, + ) if ( apply_main_traffic_limit diff --git a/backend/bot/services/subscription_service_impl/payments.py b/backend/bot/services/subscription_service_impl/payments.py index 012c4c4..3562405 100644 --- a/backend/bot/services/subscription_service_impl/payments.py +++ b/backend/bot/services/subscription_service_impl/payments.py @@ -38,7 +38,8 @@ class PaymentContextMixin: payment.sale_mode = sale_mode payment.tariff_key = tariff_key payment.purchased_gb = purchased_gb - payment.purchased_hwid_devices = purchased_hwid_devices + if purchased_hwid_devices is not None: + payment.purchased_hwid_devices = purchased_hwid_devices if hwid_valid_from is not None: payment.hwid_valid_from = hwid_valid_from if hwid_valid_until is not None: diff --git a/backend/bot/services/subscription_service_impl/renewal.py b/backend/bot/services/subscription_service_impl/renewal.py index f58e8eb..ed9596d 100644 --- a/backend/bot/services/subscription_service_impl/renewal.py +++ b/backend/bot/services/subscription_service_impl/renewal.py @@ -42,6 +42,8 @@ class RenewalMixin: months = sub.duration_months or 1 currency = default_payment_currency_code_for_settings(self.settings) + tariff_key = str(getattr(sub, "tariff_key", "") or "").strip() or None + sale_mode = f"subscription@{tariff_key}" if tariff_key else "subscription" amount = None tariffs_config = ( self._tariffs_config() if callable(getattr(self, "_tariffs_config", None)) else None @@ -62,11 +64,55 @@ class RenewalMixin: logging.error(f"Auto-renew price missing for {months} months") return False + hwid_quote = None + quote_hwid_renewal = getattr( + self, + "quote_hwid_device_renewal_for_subscription", + None, + ) + if tariff_key and callable(quote_hwid_renewal): + try: + hwid_quote = await quote_hwid_renewal( + session, + user_id=sub.user_id, + target_tariff_key=tariff_key, + months=int(months), + currency=default_currency_key_for_settings(self.settings), + ) + except Exception: + logging.exception( + "Failed to quote HWID devices for auto-renew user %s", + sub.user_id, + ) + hwid_quote = None + if hwid_quote: + amount = float(amount) + float(hwid_quote.get("price") or 0) + metadata = { "user_id": str(sub.user_id), "auto_renew_for_subscription_id": str(sub.subscription_id), "subscription_months": str(months), + "sale_mode": sale_mode, } + if hwid_quote: + metadata["hwid_devices"] = str(int(hwid_quote.get("device_count") or 0)) + for source_key, metadata_key in ( + ("valid_from", "hwid_valid_from"), + ("valid_until", "hwid_valid_until"), + ): + value = hwid_quote.get(source_key) + if value: + metadata[metadata_key] = ( + value.isoformat() if hasattr(value, "isoformat") else str(value) + ) + for key in ( + "pricing_period_months", + "proration_ratio", + "full_price", + ): + value = hwid_quote.get(key) + if value is not None: + metadata[f"hwid_{key}"] = str(value) resp = await yk.create_payment( amount=float(amount), currency=currency, diff --git a/backend/db/dal/payment_dal.py b/backend/db/dal/payment_dal.py index eec2f4e..f47338a 100644 --- a/backend/db/dal/payment_dal.py +++ b/backend/db/dal/payment_dal.py @@ -51,6 +51,15 @@ async def ensure_payment_with_provider_id( description: str, provider: str, provider_payment_id: str, + sale_mode: Optional[str] = None, + tariff_key: Optional[str] = None, + purchased_gb: Optional[float] = None, + purchased_hwid_devices: Optional[int] = None, + hwid_valid_from: Optional[Any] = None, + hwid_valid_until: Optional[Any] = None, + hwid_pricing_period_months: Optional[int] = None, + hwid_proration_ratio: Optional[float] = None, + hwid_full_price: Optional[float] = None, ) -> Payment: """Idempotently create a payment record for a provider event. @@ -72,6 +81,20 @@ async def ensure_payment_with_provider_id( "provider_payment_id": provider_payment_id, "provider": provider, } + optional_fields = { + "sale_mode": sale_mode, + "tariff_key": tariff_key, + "purchased_gb": purchased_gb, + "purchased_hwid_devices": purchased_hwid_devices, + "hwid_valid_from": hwid_valid_from, + "hwid_valid_until": hwid_valid_until, + "hwid_pricing_period_months": hwid_pricing_period_months, + "hwid_proration_ratio": hwid_proration_ratio, + "hwid_full_price": hwid_full_price, + } + payment_payload.update( + {field: value for field, value in optional_fields.items() if value is not None} + ) return await create_payment_record(session, payment_payload) diff --git a/backend/db/dal/tariff_dal.py b/backend/db/dal/tariff_dal.py index 0d2c6de..1cd1036 100644 --- a/backend/db/dal/tariff_dal.py +++ b/backend/db/dal/tariff_dal.py @@ -1,5 +1,5 @@ import inspect -from datetime import datetime, timezone +from datetime import datetime, timedelta, timezone from typing import Any, Dict, List, Optional from sqlalchemy import and_, delete, func, or_, select, update @@ -189,6 +189,63 @@ async def expire_hwid_device_purchases( return result.rowcount or 0 +def _normalize_aware_utc(value: datetime) -> datetime: + if value.tzinfo is None: + return value.replace(tzinfo=timezone.utc) + return value + + +async def extend_hwid_device_purchases_for_subscription_bonus( + session: AsyncSession, + *, + subscription_id: int, + at: Optional[datetime] = None, + subscription_end_before: Optional[datetime] = None, + delta: timedelta, +) -> int: + if delta.total_seconds() <= 0: + return 0 + at = _normalize_aware_utc(at or datetime.now(timezone.utc)) + end_before = _normalize_aware_utc(subscription_end_before) if subscription_end_before else None + + target_records: List[HwidDevicePurchase] = [] + if end_before: + tail_result = await session.execute( + select(HwidDevicePurchase).where( + and_( + HwidDevicePurchase.subscription_id == subscription_id, + HwidDevicePurchase.purchased_devices > 0, + HwidDevicePurchase.valid_until.is_not(None), + HwidDevicePurchase.valid_until >= end_before, + HwidDevicePurchase.valid_until > at, + or_( + HwidDevicePurchase.valid_from.is_(None), + HwidDevicePurchase.valid_from < end_before, + ), + ) + ) + ) + target_records = list(tail_result.scalars().all()) + + if not target_records: + active_result = await session.execute( + select(HwidDevicePurchase).where( + and_( + *_hwid_active_conditions(subscription_id, at), + HwidDevicePurchase.valid_until.is_not(None), + ) + ) + ) + target_records = list(active_result.scalars().all()) + + for record in target_records: + if record.valid_until is not None: + record.valid_until = _normalize_aware_utc(record.valid_until) + delta + if target_records: + await session.flush() + return len(target_records) + + async def create_tariff_change( session: AsyncSession, change_data: Dict[str, Any], diff --git a/docs/features/notifications.md b/docs/features/notifications.md index 76864fe..8450d4e 100644 --- a/docs/features/notifications.md +++ b/docs/features/notifications.md @@ -18,7 +18,7 @@ Minishop отправляет уведомления в Telegram и на email. | Успешная покупка отдельного пакета трафика | Пользователь | ✓ | ✓ | Для `traffic` / `traffic_package`; email отправляется, если SMTP настроен и у пользователя есть email. | | Успешная докупка обычного трафика к тарифу | Пользователь | ✓ | ✓ | Для `topup`; email отправляется, если SMTP настроен и у пользователя есть email. | | Успешная покупка premium-трафика | Пользователь | ✓ | ✓ | Для `premium_topup`; email отправляется, если SMTP настроен и у пользователя есть email. | -| Успешная покупка HWID-устройств | Пользователь | ✓ | ✓ | Отправляется после оплаты `hwid_devices` или `hwid_devices_renewal`; email отправляется, если SMTP настроен и у пользователя есть email. | +| Успешная покупка HWID-устройств | Пользователь | ✓ | ✓ | Отправляется после отдельной оплаты `hwid_devices`; при продлении устройств вместе с подпиской добавляется примечание к уведомлению об успешной оплате подписки. Email отправляется, если SMTP настроен и у пользователя есть email. | | Платное повышение тарифа | Пользователь | ✓ | ✓ | Для `tariff_upgrade`; email отправляется, если SMTP настроен и у пользователя есть email. | | Способ оплаты YooKassa привязан | Пользователь | ✓ | ✓ | Отправляется после успешного сохранения платежного метода через webhook YooKassa; email отправляется, если SMTP настроен и у пользователя есть email. | | Ошибка оплаты по webhook провайдера | Пользователь | ✓ | ✓ | Отправляется, когда платежный провайдер сообщает о неуспешном платеже; email отправляется, если SMTP настроен и у пользователя есть email. | diff --git a/docs/features/tariffs.md b/docs/features/tariffs.md index 0bd0c50..dd335f8 100644 --- a/docs/features/tariffs.md +++ b/docs/features/tariffs.md @@ -280,7 +280,10 @@ limit_after = current_used + balance_after - полная цена HWID-пакета берется из `prices[duration_months]`; если периода нет, используется fallback `price * duration_months`; - фактическая цена докупки считается пропорционально оплачиваемому окну `valid_from -> valid_until` относительно периода подписки и фиксируется в платежe; - для Telegram Stars цена округляется вверх до целого Stars, для платежной валюты — вверх до копеек; `min_price` защищает от микроплатежей в конце периода; -- при продлении подписки докупленные устройства не продлеваются автоматически: старая докупка действует до прежнего `end_date`, а для нового срока создается отдельная `hwid_devices_renewal`-покупка; +- кнопка докупки устройств всегда покупает устройства только для текущей активной подписки и только до текущего срока ее действия; +- при продлении подписки пользователь видит отдельный чекбокс продления действующих докупленных устройств; чекбокс включен по умолчанию, цена считается по текущему тарифу и добавляется в тот же платеж подписки; +- если пользователь продлил подписку без продления устройств, старая докупка продолжает действовать до своего `valid_until`, а Web App показывает предупреждение о возможном временном возврате к базовому лимиту; +- админские продления, промокоды и реферальные бонусы добавляют фиксированное количество дней отдельно к подписке и к действующим докупкам устройств, не склеивая даты окончания; - `traffic`-тарифы не показывают и не принимают докупку HWID-устройств, потому что у них нет срока подписки; - при смене тарифа базовый лимит берется из целевого тарифа, а неиспользованная стоимость HWID-докупок в платежной валюте конвертируется в дни нового period-тарифа или GB traffic-тарифа; XTR/Stars-докупки не конвертируются без явного курса и продолжают жить по своему `valid_until`; - история докупок пишется в `hwid_device_purchases`; diff --git a/frontend/src/App.svelte b/frontend/src/App.svelte index 13aaa80..9457e17 100644 --- a/frontend/src/App.svelte +++ b/frontend/src/App.svelte @@ -1520,6 +1520,7 @@ selectedPlan: null, selectedTariffKey: "", paymentStep: "tariff", + renewHwidDevices: true, selectedMethod: payload.payment_methods?.[0]?.id || "", })); const currentQuery = currentSearchParams(); @@ -2421,6 +2422,7 @@ bind:paymentStep={$billingStore.paymentStep} bind:selectedMethod={$billingStore.selectedMethod} bind:selectedPlan={$billingStore.selectedPlan} + bind:renewHwidDevices={$billingStore.renewHwidDevices} bind:selectedTariffKey={$billingStore.selectedTariffKey} bind:setPasswordCode={$accountStore.setPasswordCode} bind:setPasswordConfirm={$accountStore.setPasswordConfirm} diff --git a/frontend/src/admin/sections/UserDetailModal.svelte b/frontend/src/admin/sections/UserDetailModal.svelte index 1cc4a8a..1bfac32 100644 --- a/frontend/src/admin/sections/UserDetailModal.svelte +++ b/frontend/src/admin/sections/UserDetailModal.svelte @@ -759,6 +759,41 @@ {at("user_btn_extend", {}, "Продлить")}
    + {#if Number(openedUserDetail?.active_subscription?.extra_hwid_devices || 0) > 0} + + {/if}
    diff --git a/frontend/src/lib/admin/stores/usersStore.js b/frontend/src/lib/admin/stores/usersStore.js index 1442c49..716e1e8 100644 --- a/frontend/src/lib/admin/stores/usersStore.js +++ b/frontend/src/lib/admin/stores/usersStore.js @@ -21,6 +21,7 @@ export function createUsersStore({ api, onToast, at, routePrefix = "" }) { userDetailLoading: false, userMessageDraft: "", userExtendDays: 30, + userExtendHwidDevices: true, userActionBusy: false, userDeleteOpen: false, userBanConfirmOpen: false, @@ -133,6 +134,7 @@ export function createUsersStore({ api, onToast, at, routePrefix = "" }) { userMessageDraft: "", userMessageConfirmOpen: false, userExtendDays: 30, + userExtendHwidDevices: true, userDetailLoading: true, userDetailTab: "subscription", userReferralsOpen: false, @@ -455,7 +457,7 @@ export function createUsersStore({ api, onToast, at, routePrefix = "" }) { try { const res = await api(`/admin/users/${s.openedUser.user_id}/extend`, { method: "POST", - body: JSON.stringify({ days }), + body: JSON.stringify({ days, extend_hwid_devices: Boolean(s.userExtendHwidDevices) }), }); if (res?.ok) { onToast(at("subscription_extended", { days }, `Продлено на ${days} д.`)); diff --git a/frontend/src/lib/webapp/billingActions.js b/frontend/src/lib/webapp/billingActions.js index 4e92963..751f952 100644 --- a/frontend/src/lib/webapp/billingActions.js +++ b/frontend/src/lib/webapp/billingActions.js @@ -27,13 +27,14 @@ export function createBillingActions({ api }) { return api("/tariffs/change-payment", { method: "POST", body: JSON.stringify(body) }); } - function planPaymentBody(plan, method) { + function planPaymentBody(plan, method, options = {}) { return { months: plan.months, traffic_gb: plan.traffic_gb, device_count: plan.device_count, tariff_key: plan.tariff_key, sale_mode: plan.sale_mode, + renew_hwid_devices: Boolean(options.renewHwidDevices), method, }; } diff --git a/frontend/src/lib/webapp/demoDataset.js b/frontend/src/lib/webapp/demoDataset.js index c667237..734167f 100644 --- a/frontend/src/lib/webapp/demoDataset.js +++ b/frontend/src/lib/webapp/demoDataset.js @@ -127478,22 +127478,22 @@ export const DEMO_DATASET = { audience: "user", values: { ru: { - base: "Докупленные +{count} устройств действуют до {date}. При продлении подписки их нужно докупить заново.", + base: "Докупленные +{count} устройств действуют до {date}. Продлить их на следующий срок можно вместе с продлением подписки.", fallback: - "Докупленные +{count} устройств действуют до {date}. При продлении подписки их нужно докупить заново.", + "Докупленные +{count} устройств действуют до {date}. Продлить их на следующий срок можно вместе с продлением подписки.", effective: - "Докупленные +{count} устройств действуют до {date}. При продлении подписки их нужно докупить заново.", + "Докупленные +{count} устройств действуют до {date}. Продлить их на следующий срок можно вместе с продлением подписки.", override: "", overridden: false, updated_at: null, updated_by: null, }, en: { - base: "Your +{count} extra devices are valid until {date}. Renewing the subscription does not renew them automatically.", + base: "Your +{count} extra devices are valid until {date}. You can renew them together with the subscription.", fallback: - "Докупленные +{count} устройств действуют до {date}. При продлении подписки их нужно докупить заново.", + "Докупленные +{count} устройств действуют до {date}. Продлить их на следующий срок можно вместе с продлением подписки.", effective: - "Your +{count} extra devices are valid until {date}. Renewing the subscription does not renew them automatically.", + "Your +{count} extra devices are valid until {date}. You can renew them together with the subscription.", override: "", overridden: false, updated_at: null, @@ -127506,22 +127506,22 @@ export const DEMO_DATASET = { audience: "user", values: { ru: { - base: "Текущая докупка +{count} устройств действует до {date}. Выберите пакет, чтобы продлить устройства на новый срок подписки.", + base: "Текущая докупка +{count} устройств действует до {date}. Продление устройств выполняется вместе с продлением подписки.", fallback: - "Текущая докупка +{count} устройств действует до {date}. Выберите пакет, чтобы продлить устройства на новый срок подписки.", + "Текущая докупка +{count} устройств действует до {date}. Продление устройств выполняется вместе с продлением подписки.", effective: - "Текущая докупка +{count} устройств действует до {date}. Выберите пакет, чтобы продлить устройства на новый срок подписки.", + "Текущая докупка +{count} устройств действует до {date}. Продление устройств выполняется вместе с продлением подписки.", override: "", overridden: false, updated_at: null, updated_by: null, }, en: { - base: "Your current +{count} device top-up is valid until {date}. Choose a package to renew devices for the new subscription period.", + base: "Your current +{count} device top-up is valid until {date}. Device renewal is handled together with subscription renewal.", fallback: - "Текущая докупка +{count} устройств действует до {date}. Выберите пакет, чтобы продлить устройства на новый срок подписки.", + "Текущая докупка +{count} устройств действует до {date}. Продление устройств выполняется вместе с продлением подписки.", effective: - "Your current +{count} device top-up is valid until {date}. Choose a package to renew devices for the new subscription period.", + "Your current +{count} device top-up is valid until {date}. Device renewal is handled together with subscription renewal.", override: "", overridden: false, updated_at: null, @@ -127534,18 +127534,22 @@ export const DEMO_DATASET = { audience: "user", values: { ru: { - base: "Подписка продлена. Докупите устройства для нового срока.", - fallback: "Подписка продлена. Докупите устройства для нового срока.", - effective: "Подписка продлена. Докупите устройства для нового срока.", + base: "Подписка продлена. Если устройства не продлевались вместе с ней, текущая докупка действует до своей даты окончания.", + fallback: + "Подписка продлена. Если устройства не продлевались вместе с ней, текущая докупка действует до своей даты окончания.", + effective: + "Подписка продлена. Если устройства не продлевались вместе с ней, текущая докупка действует до своей даты окончания.", override: "", overridden: false, updated_at: null, updated_by: null, }, en: { - base: "Subscription renewed. Buy devices again for the new period.", - fallback: "Подписка продлена. Докупите устройства для нового срока.", - effective: "Subscription renewed. Buy devices again for the new period.", + base: "Subscription renewed. If devices were not renewed with it, the current device top-up remains valid until its own end date.", + fallback: + "Подписка продлена. Если устройства не продлевались вместе с ней, текущая докупка действует до своей даты окончания.", + effective: + "Subscription renewed. If devices were not renewed with it, the current device top-up remains valid until its own end date.", override: "", overridden: false, updated_at: null, @@ -134134,22 +134138,22 @@ export const DEMO_DATASET = { audience: "user", values: { ru: { - base: "У вас докуплено +{count} HWID устройств до {date}. При продлении подписки докупку нужно оформить заново.", + base: "У вас докуплено +{count} HWID устройств до {date}. При продлении подписки можно включить продление этих устройств в тот же платеж.", fallback: - "У вас докуплено +{count} HWID устройств до {date}. При продлении подписки докупку нужно оформить заново.", + "У вас докуплено +{count} HWID устройств до {date}. При продлении подписки можно включить продление этих устройств в тот же платеж.", effective: - "У вас докуплено +{count} HWID устройств до {date}. При продлении подписки докупку нужно оформить заново.", + "У вас докуплено +{count} HWID устройств до {date}. При продлении подписки можно включить продление этих устройств в тот же платеж.", override: "", overridden: false, updated_at: null, updated_by: null, }, en: { - base: "You have +{count} extra HWID devices valid until {date}. Renewing the subscription does not renew the device top-up automatically.", + base: "You have +{count} extra HWID devices valid until {date}. When renewing the subscription, you can include these devices in the same payment.", fallback: - "У вас докуплено +{count} HWID устройств до {date}. При продлении подписки докупку нужно оформить заново.", + "У вас докуплено +{count} HWID устройств до {date}. При продлении подписки можно включить продление этих устройств в тот же платеж.", effective: - "You have +{count} extra HWID devices valid until {date}. Renewing the subscription does not renew the device top-up automatically.", + "You have +{count} extra HWID devices valid until {date}. When renewing the subscription, you can include these devices in the same payment.", override: "", overridden: false, updated_at: null, @@ -135835,22 +135839,22 @@ export const DEMO_DATASET = { audience: "user", values: { ru: { - base: "У вас сейчас докуплено +{count} HWID устройств до {date}. При продлении подписки их нужно докупить заново для нового срока.", + base: "Докупленные +{count} HWID устройств действуют до {date}. Если нужно продлить их на следующий срок, включите продление устройств при оплате подписки.", fallback: - "У вас сейчас докуплено +{count} HWID устройств до {date}. При продлении подписки их нужно докупить заново для нового срока.", + "Докупленные +{count} HWID устройств действуют до {date}. Если нужно продлить их на следующий срок, включите продление устройств при оплате подписки.", effective: - "У вас сейчас докуплено +{count} HWID устройств до {date}. При продлении подписки их нужно докупить заново для нового срока.", + "Докупленные +{count} HWID устройств действуют до {date}. Если нужно продлить их на следующий срок, включите продление устройств при оплате подписки.", override: "", overridden: false, updated_at: null, updated_by: null, }, en: { - base: "You currently have +{count} extra HWID devices valid until {date}. Buy them again if you need them for the renewed subscription period.", + base: "Your +{count} extra HWID devices are valid until {date}. To keep them for the next period, enable device renewal while renewing the subscription.", fallback: - "У вас сейчас докуплено +{count} HWID устройств до {date}. При продлении подписки их нужно докупить заново для нового срока.", + "Докупленные +{count} HWID устройств действуют до {date}. Если нужно продлить их на следующий срок, включите продление устройств при оплате подписки.", effective: - "You currently have +{count} extra HWID devices valid until {date}. Buy them again if you need them for the renewed subscription period.", + "Your +{count} extra HWID devices are valid until {date}. To keep them for the next period, enable device renewal while renewing the subscription.", override: "", overridden: false, updated_at: null, @@ -139676,22 +139680,22 @@ export const DEMO_DATASET = { audience: "user", values: { ru: { - base: "Выберите пакет HWID устройств для нового срока подписки. Текущая докупка действует до {date}.", + base: "Продление докупленных HWID устройств выполняется вместе с продлением подписки. Текущая докупка действует до {date}.", fallback: - "Выберите пакет HWID устройств для нового срока подписки. Текущая докупка действует до {date}.", + "Продление докупленных HWID устройств выполняется вместе с продлением подписки. Текущая докупка действует до {date}.", effective: - "Выберите пакет HWID устройств для нового срока подписки. Текущая докупка действует до {date}.", + "Продление докупленных HWID устройств выполняется вместе с продлением подписки. Текущая докупка действует до {date}.", override: "", overridden: false, updated_at: null, updated_by: null, }, en: { - base: "Select an HWID device package for the new subscription period. Your current top-up is valid until {date}.", + base: "Purchased HWID devices are renewed together with subscription renewal. Your current top-up is valid until {date}.", fallback: - "Выберите пакет HWID устройств для нового срока подписки. Текущая докупка действует до {date}.", + "Продление докупленных HWID устройств выполняется вместе с продлением подписки. Текущая докупка действует до {date}.", effective: - "Select an HWID device package for the new subscription period. Your current top-up is valid until {date}.", + "Purchased HWID devices are renewed together with subscription renewal. Your current top-up is valid until {date}.", override: "", overridden: false, updated_at: null, diff --git a/frontend/src/lib/webapp/stores/billingStore.js b/frontend/src/lib/webapp/stores/billingStore.js index 27df0e3..4f5ca65 100644 --- a/frontend/src/lib/webapp/stores/billingStore.js +++ b/frontend/src/lib/webapp/stores/billingStore.js @@ -18,6 +18,7 @@ export function createBillingStore({ selectedTariffKey: "", selectedPlan: null, selectedMethod: "", + renewHwidDevices: true, paymentStartedWithActiveSubscription: false, topupModalOpen: false, topupKind: "regular", @@ -73,15 +74,7 @@ export function createBillingStore({ paymentPollToken += 1; } showToast(t("wa_payment_success", {}, "Payment successful")); - const payload = await loadData({ fresh: true }); - if ( - successContext.renewalSubscriptionPayment && - payload?.subscription?.device_topup_renewal_available && - payload?.subscription?.can_topup_devices - ) { - showToast(t("wa_hwid_devices_renewal_prompt")); - openDeviceTopupModal(payload.payment_methods?.[0]?.id || ""); - } + await loadData({ fresh: true }); if ( successContext.initialSubscriptionPayment && typeof onSubscriptionActivated === "function" @@ -163,6 +156,7 @@ export function createBillingStore({ selectedTariffKey: tariffKey, selectedPlan: plan, selectedMethod: s.selectedMethod || defaultMethod, + renewHwidDevices: true, paymentStartedWithActiveSubscription: Boolean(subscription?.active), }; }); @@ -179,6 +173,7 @@ export function createBillingStore({ ...s, selectedTariffKey: key, selectedPlan: plans.find((plan) => plan?.tariff_key === key) || null, + renewHwidDevices: true, })); } @@ -189,6 +184,7 @@ export function createBillingStore({ ...s, selectedPlan: s.selectedPlan || selectedTariffPlans[0] || null, paymentStep: "checkout", + renewHwidDevices: true, }; }); } @@ -355,7 +351,10 @@ export function createBillingStore({ state.update((s) => ({ ...s, payBusy: true })); try { const response = await billing.postPayment( - billing.planPaymentBody(s.selectedPlan, s.selectedMethod) + billing.planPaymentBody(s.selectedPlan, s.selectedMethod, { + renewHwidDevices: + s.renewHwidDevices && Boolean(s.selectedPlan?.hwid_renewal?.available), + }) ); const successContext = paymentSuccessContext(s, response); rememberSubscriptionActivationPending(successContext); diff --git a/frontend/src/styles/admin.css b/frontend/src/styles/admin.css index d2ae0ca..22ae20e 100644 --- a/frontend/src/styles/admin.css +++ b/frontend/src/styles/admin.css @@ -4288,6 +4288,37 @@ box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 20%, transparent); } +.admin-extend-hwid-option { + display: grid; + grid-template-columns: auto minmax(0, 1fr); + gap: 10px; + align-items: flex-start; + padding: 10px 12px; + border: 1px solid var(--admin-border); + border-radius: 8px; + background: var(--admin-surface-2); + color: var(--admin-text); + cursor: pointer; +} + +.admin-extend-hwid-option > span { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; +} + +.admin-extend-hwid-option strong { + font-size: 13px; + font-weight: 600; +} + +.admin-extend-hwid-option small { + color: var(--admin-muted); + font-size: 12px; + line-height: 1.35; +} + .admin-input-row { display: grid; grid-template-columns: minmax(72px, 1fr) auto; diff --git a/frontend/src/styles/webapp.css b/frontend/src/styles/webapp.css index 92054ae..aed67b7 100644 --- a/frontend/src/styles/webapp.css +++ b/frontend/src/styles/webapp.css @@ -1189,6 +1189,43 @@ a { overflow-wrap: anywhere; } +.hwid-renewal-option { + display: grid; + grid-template-columns: auto minmax(0, 1fr); + gap: 10px; + align-items: flex-start; + padding: 11px 12px; + border: 1px solid color-mix(in srgb, var(--accent) 34%, var(--border)); + border-radius: var(--radius); + background: color-mix(in srgb, var(--accent) 8%, var(--surface-muted)); + box-shadow: inset 0 1px 0 var(--inset-highlight); + cursor: pointer; +} + +.hwid-renewal-option > span { + display: grid; + min-width: 0; + gap: 4px; +} + +.hwid-renewal-option strong { + color: var(--text); + font-size: 12px; + line-height: 1.35; + overflow-wrap: anywhere; +} + +.hwid-renewal-option small { + color: var(--muted); + font-size: 11px; + line-height: 1.35; + overflow-wrap: anywhere; +} + +.hwid-renewal-option .hwid-renewal-warning { + color: var(--warning-text, var(--warning)); +} + .skeleton-row, .skeleton-method, .skeleton-pay-button { diff --git a/frontend/src/webapp/PaymentDialogs.svelte b/frontend/src/webapp/PaymentDialogs.svelte index d38cb2c..8b1e700 100644 --- a/frontend/src/webapp/PaymentDialogs.svelte +++ b/frontend/src/webapp/PaymentDialogs.svelte @@ -10,6 +10,7 @@ import { Tooltip } from "$components/ui/primitives.js"; import Button from "$components/ui/button.svelte"; + import Checkbox from "$components/ui/checkbox.svelte"; import Dialog from "$components/ui/dialog.svelte"; import EmailCodeScreen from "./auth/EmailCodeScreen.svelte"; import Input from "$components/ui/input.svelte"; @@ -52,6 +53,7 @@ export let selectedTariff = null; export let selectedTariffKey = ""; export let selectedTariffPlans = []; + export let renewHwidDevices = true; export let setPasswordBusy = false; export let setPasswordCode = ""; export let setPasswordConfirm = ""; @@ -72,6 +74,81 @@ function priceLabel(plan) { return priceLabelFn(plan, selectedMethod); } + function methodUsesStars() { + return String(selectedMethod || "") + .toLowerCase() + .includes("stars"); + } + function hwidRenewalFor(plan) { + return plan?.hwid_renewal?.available ? plan.hwid_renewal : null; + } + function isSubscriptionPlan(plan) { + const saleMode = String(plan?.sale_mode || "subscription").toLowerCase(); + return saleMode === "subscription"; + } + function hwidRenewalAvailableForMethod(plan) { + const renewal = hwidRenewalFor(plan); + if (!subscription?.active || !isSubscriptionPlan(plan) || !renewal) return false; + if (methodUsesStars()) return Number(renewal.stars_price || 0) > 0; + return Number(renewal.price || 0) > 0; + } + function planWithSelectedHwidRenewal(plan) { + if (!plan || !renewHwidDevices || !hwidRenewalAvailableForMethod(plan)) return plan; + const renewal = hwidRenewalFor(plan); + const withRenewal = { + ...plan, + price: Number(plan.price || 0) + Number(renewal.price || 0), + }; + if (Number(plan.stars_price || 0) > 0 && Number(renewal.stars_price || 0) > 0) { + withRenewal.stars_price = Number(plan.stars_price || 0) + Number(renewal.stars_price || 0); + } + return withRenewal; + } + function paymentPriceLabel(plan) { + return priceLabelFn(planWithSelectedHwidRenewal(plan), selectedMethod); + } + function hwidRenewalPriceLabel(plan = selectedPlan) { + const renewal = hwidRenewalFor(plan); + if (!renewal) return ""; + return priceLabelFn( + { + price: renewal.price || 0, + stars_price: renewal.stars_price, + currency: renewal.currency || plan?.currency, + }, + selectedMethod + ); + } + function showHwidRenewalBlock() { + return hwidRenewalAvailableForMethod(selectedPlan); + } + function showHwidRenewalUnavailableNote() { + return Boolean( + subscription?.active && + Number(subscription?.extra_hwid_devices || 0) > 0 && + isSubscriptionPlan(selectedPlan) && + !showHwidRenewalBlock() + ); + } + function hwidRenewalCount(plan = selectedPlan) { + return Number(hwidRenewalFor(plan)?.device_count || subscription?.extra_hwid_devices || 0); + } + function hwidRenewalHint(plan = selectedPlan) { + const renewal = hwidRenewalFor(plan); + if (renewal?.valid_from_text && renewal?.valid_until_text) { + return t("wa_hwid_devices_renewal_checkbox_hint", { + from: renewal.valid_from_text, + to: renewal.valid_until_text, + }); + } + return t("wa_hwid_devices_renewal_checkbox_hint_short"); + } + function showHwidDesyncNotice() { + return Boolean( + subscription?.device_topup_renewal_available && + subscription?.extra_hwid_devices_valid_until_text + ); + } function planKey(plan) { return planKeyFn(plan); } @@ -197,10 +274,34 @@

    {subscriptionPurchaseDescription}

    {/if} - {#if subscription?.active && Number(subscription?.extra_hwid_devices || 0) > 0} + {#if showHwidRenewalBlock()} + + {:else if showHwidRenewalUnavailableNote()}

    - {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 || "", })} @@ -243,7 +344,7 @@ disabled={!selectedPlan || !methods.length || payBusy} > {t("wa_pay")} - {selectedPlan ? priceLabel(selectedPlan) : ""} + {selectedPlan ? paymentPriceLabel(selectedPlan) : ""} {:else} @@ -261,10 +362,34 @@

    {subscriptionPurchaseDescription}

    {/if} - {#if subscription?.active && Number(subscription?.extra_hwid_devices || 0) > 0} + {#if showHwidRenewalBlock()} + + {:else if showHwidRenewalUnavailableNote()}

    - {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} -

    -

    - {t("wa_hwid_devices_renewal_offer", { - count: Number(deviceTopupOptions.renewal_recommended_count || 0), - date: deviceTopupOptions.extra_hwid_devices_valid_until_text || "", - })} -

    -
    - {: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 @@