From 3d5998b8fbed68357f6e716c763e4c981c2aee35 Mon Sep 17 00:00:00 2001 From: 3252a8 <3252a8@proton.me> Date: Thu, 30 Apr 2026 07:34:29 +0300 Subject: [PATCH] feat: support traffic and trial flow in web app --- bot/app/web/frontend/src/App.svelte | 184 +++++++++++++++++-- bot/app/web/frontend/src/styles.css | 32 ++++ bot/app/web/subscription_webapp.py | 263 +++++++++++++++++++++++----- locales/en.json | 10 ++ locales/ru.json | 10 ++ tests/test_webapp_assets.py | 18 ++ 6 files changed, 468 insertions(+), 49 deletions(-) diff --git a/bot/app/web/frontend/src/App.svelte b/bot/app/web/frontend/src/App.svelte index 5df0bc5..f37c9aa 100644 --- a/bot/app/web/frontend/src/App.svelte +++ b/bot/app/web/frontend/src/App.svelte @@ -9,6 +9,7 @@ CircleX, Copy, CreditCard, + Database, FileText, Gift, Globe2, @@ -131,12 +132,18 @@ settings: { support_url: "https://t.me/support", traffic_mode: false, + trial_enabled: true, + trial_available: true, + trial_duration_days: 5, + trial_traffic_limit_gb: 10, + trial_traffic_strategy: "NO_RESET", email_auth_enabled: true, }, }, }; const query = new URLSearchParams(window.location.search); + applyPreviewMock(query.get("mock")); const isPreviewBoard = query.get("preview") === "all"; const injectedConfig = readJsonScript("webapp-config"); const injectedI18n = readJsonScript("i18n"); @@ -160,6 +167,7 @@ let selectedMethod = ""; let paymentModalOpen = false; let payBusy = false; + let trialBusy = false; let linkEmailOpen = false; let linkEmailBusy = false; let linkTelegramBusy = false; @@ -196,11 +204,59 @@ let linkEmailResendTimer = null; let scrollLockApplied = false; + function applyPreviewMock(kind) { + const mode = String(kind || "").trim().toLowerCase(); + if (mode === "traffic") { + DEV_MOCK.data.settings.traffic_mode = true; + DEV_MOCK.data.settings.trial_available = false; + DEV_MOCK.data.subscription = { + ...DEV_MOCK.data.subscription, + active: true, + status: "ACTIVE", + remaining_text: "Навсегда", + end_date_text: "01.01.2099 00:00", + days_left: 26000, + traffic_used: "18.4 GB", + traffic_limit: "100 GB", + traffic_used_bytes: 19756849561, + traffic_limit_bytes: 107374182400, + traffic_limit_strategy: "NO_RESET", + }; + DEV_MOCK.data.plans = [ + { months: 10, traffic_gb: 10, price: 199, currency: "RUB", title: "10 GB", sale_mode: "traffic" }, + { months: 50, traffic_gb: 50, price: 799, currency: "RUB", title: "50 GB", sale_mode: "traffic" }, + { months: 100, traffic_gb: 100, price: 1390, currency: "RUB", title: "100 GB", sale_mode: "traffic" }, + { months: 300, traffic_gb: 300, price: 3490, currency: "RUB", title: "300 GB", sale_mode: "traffic" }, + ]; + } else if (mode === "trial") { + DEV_MOCK.data.settings.traffic_mode = false; + DEV_MOCK.data.settings.trial_enabled = true; + DEV_MOCK.data.settings.trial_available = true; + DEV_MOCK.data.settings.trial_duration_days = 5; + DEV_MOCK.data.settings.trial_traffic_limit_gb = 10; + DEV_MOCK.data.subscription = { + active: false, + status: "INACTIVE", + remaining_text: "Подписка не активна", + end_date_text: "", + days_left: 0, + config_link: null, + connect_url: null, + traffic_used: "0 B", + traffic_limit: "10 GB", + traffic_used_bytes: 0, + traffic_limit_bytes: 10737418240, + }; + } + } + $: brandTitle = CFG.title || "/minishop"; $: brandEmoji = CFG.logoEmoji || "🫥"; $: accent = CFG.primaryColor || "#00fe7a"; $: plans = data?.plans?.length ? data.plans : DEV_MOCK.data.plans; $: methods = data?.payment_methods?.length ? data.payment_methods : []; + $: appSettings = data?.settings || DEV_MOCK.data.settings; + $: trafficMode = Boolean(appSettings?.traffic_mode); $: subscription = data?.subscription || DEV_MOCK.data.subscription; $: user = data?.user || {}; $: referral = data?.referral || DEV_MOCK.data.referral; @@ -224,7 +280,7 @@ $: profileAvatarUrl = user?.telegram_photo_url || emailAvatarUrl || ""; $: privacyPolicyUrl = String(CFG.privacyPolicyUrl || "").trim(); $: userAgreementUrl = String(CFG.userAgreementUrl || "").trim(); - $: supportUrl = String(data?.settings?.support_url || CFG.supportUrl || "").trim(); + $: supportUrl = String(appSettings?.support_url || CFG.supportUrl || "").trim(); $: telegramLoginBotId = Number(CFG.telegramLoginBotId || 0); $: applyFavicon(CFG.logoUrl, brandEmoji); $: syncBodyScrollLock(paymentModalOpen || linkEmailOpen); @@ -371,6 +427,7 @@ } function syncSectionPath(section, replace = false) { + if (window.location.protocol === "file:") return; const normalized = normalizeSection(section); const targetPath = APP_SECTION_PATHS[normalized] || APP_SECTION_PATHS.home; if (window.location.pathname === targetPath) return; @@ -482,6 +539,22 @@ return { ok: true, token: "local-preview", csrf_token: "local-preview-csrf" }; } if (path === "/promo/apply") return { ok: true, end_date_text: "31.05.2026" }; + if (path === "/trial/activate" && String(options.method || "").toUpperCase() === "POST") { + DEV_MOCK.data.subscription = { + ...DEV_MOCK.data.subscription, + active: true, + status: "TRIAL", + remaining_text: "5 д. 0 ч.", + end_date_text: "05.05.2026 12:00", + days_left: 5, + traffic_limit: "10 GB", + traffic_limit_bytes: 10737418240, + traffic_used: "0 B", + traffic_used_bytes: 0, + }; + DEV_MOCK.data.settings.trial_available = false; + return { ok: true, activated: true, end_date_text: "05.05.2026 12:00" }; + } if (path === "/auth/logout") return { ok: true }; if (path === "/account/language" && String(options.method || "").toUpperCase() === "POST") { let payload = {}; @@ -1005,7 +1078,11 @@ try { const response = await api("/payments", { method: "POST", - body: JSON.stringify({ months: selectedPlan.months, method: selectedMethod }), + body: JSON.stringify({ + months: selectedPlan.months, + traffic_gb: selectedPlan.traffic_gb, + method: selectedMethod, + }), }); if (!response.ok || !response.payment_url) throw response; showToast(t("wa_payment_created")); @@ -1084,6 +1161,24 @@ } } + async function activateTrial() { + if (trialBusy) return; + trialBusy = true; + try { + const response = await api("/trial/activate", { + method: "POST", + body: JSON.stringify({}), + }); + if (!response.ok) throw response; + showToast(t("wa_trial_activated")); + await loadData(); + } catch (error) { + showToast(error?.message || t("wa_trial_activation_failed")); + } finally { + trialBusy = false; + } + } + async function logout() { markManualLogout(); clearToken(); @@ -1169,6 +1264,19 @@ return `${formatted} ${symbol}`; } + function priceLabel(plan, methodId = selectedMethod) { + if (String(methodId || "").toLowerCase().includes("stars") && Number(plan?.stars_price || 0) > 0) { + return `${Number(plan.stars_price)} ⭐`; + } + return formatMoney(plan?.price || 0, plan?.currency); + } + + function formatTrafficGb(value) { + const numeric = Number(value || 0); + const formatted = Number.isInteger(numeric) ? String(numeric) : numeric.toFixed(2).replace(/0+$/, "").replace(/\.$/, ""); + return `${formatted} GB`; + } + function trafficPercent(sub) { const used = Number(sub?.traffic_used_bytes || 0); const limit = Number(sub?.traffic_limit_bytes || 0); @@ -1202,6 +1310,9 @@ } function planDisplayTitle(plan) { + if (trafficMode || plan?.sale_mode === "traffic") { + return plan?.title || formatTrafficGb(plan?.traffic_gb || plan?.months); + } const months = Number(plan?.months || 0); if (months === 12) { return t("wa_plan_one_year"); @@ -1209,6 +1320,41 @@ return plan?.title || ""; } + function planUnitHint(plan) { + if (trafficMode || plan?.sale_mode === "traffic") { + const gb = Number(plan?.traffic_gb || plan?.months || 0); + if (!gb) return ""; + if (String(selectedMethod || "").toLowerCase().includes("stars") && Number(plan?.stars_price || 0) > 0) { + return `${Number(plan.stars_price / gb).toFixed(0)} ⭐${t("wa_per_gb_short")}`; + } + return `${formatMoney(Number(plan?.price || 0) / gb, plan?.currency)}${t("wa_per_gb_short")}`; + } + const months = Number(plan?.months || 0); + if (!months || months <= 1) return ""; + if (String(selectedMethod || "").toLowerCase().includes("stars") && Number(plan?.stars_price || 0) > 0) { + return `${Number(plan.stars_price / months).toFixed(0)} ⭐${t("wa_per_month_short")}`; + } + return `${formatMoney(Number(plan?.price || 0) / months, plan?.currency)}${t("wa_per_month_short")}`; + } + + function paymentTitle() { + return trafficMode ? t("wa_traffic_packages_title") : t("wa_subscription_title"); + } + + function paymentDescription() { + return trafficMode ? t("wa_traffic_packages_choose") : t("wa_subscription_choose_period"); + } + + function primaryPayActionLabel() { + if (trafficMode) return t("wa_buy_traffic"); + return subscription.active ? t("wa_renew") : t("wa_pay_subscription"); + } + + function trialTrafficLabel() { + const limit = Number(appSettings?.trial_traffic_limit_gb || 0); + return limit > 0 ? formatTrafficGb(limit) : t("wa_unlimited_traffic"); + } + function activeSubscriptionTermLabel(sub) { const forever = isForeverSubscription(sub); if (forever) return t("wa_sub_term_forever"); @@ -1540,7 +1686,7 @@
-

{t("wa_home_subscription_active")} | {activeSubscriptionTermLabel(subscription)}

+

{trafficMode ? t("wa_home_access_active") : t("wa_home_subscription_active")} | {activeSubscriptionTermLabel(subscription)}

{subscription.end_date_text ? t("wa_until_date", { date: subscription.end_date_text }) : subscription.remaining_text}

@@ -1566,15 +1712,33 @@ {trafficPercent(subscription)}% + {:else if appSettings?.trial_enabled && appSettings?.trial_available} + +
+ + + {t("wa_trial_title")} + {t("wa_trial_details", { days: Number(appSettings?.trial_duration_days || 0), traffic: trialTrafficLabel() })} + +
+
{/if}
+ {#if !subscription.active && appSettings?.trial_enabled && appSettings?.trial_available} + + {/if}
@@ -1805,8 +1969,8 @@ (selectedPlan = plan)} > {planDisplayTitle(plan)} - {formatMoney(plan.price, plan.currency)} - {#if plan.months > 1} - {formatMoney(plan.price / plan.months, plan.currency)}{t("wa_per_month_short")} + {priceLabel(plan)} + {#if planUnitHint(plan)} + {planUnitHint(plan)} {/if} {#if selectedPlan?.months === plan.months} @@ -1855,7 +2019,7 @@ {/if} diff --git a/bot/app/web/frontend/src/styles.css b/bot/app/web/frontend/src/styles.css index 4af17e3..d6e509d 100644 --- a/bot/app/web/frontend/src/styles.css +++ b/bot/app/web/frontend/src/styles.css @@ -359,6 +359,38 @@ a { font-size: 11px; } +.trial-card { + padding: 13px 14px; +} + +.trial-card-head { + display: flex; + align-items: center; + gap: 11px; +} + +.trial-card-head > svg { + flex: 0 0 auto; + color: var(--accent); +} + +.trial-card-head span { + display: grid; + min-width: 0; + gap: 3px; +} + +.trial-card-head strong { + font-size: 13px; + line-height: 1.18; +} + +.trial-card-head small { + color: var(--muted); + font-size: 11px; + line-height: 1.28; +} + .action-stack { display: grid; gap: 8px; diff --git a/bot/app/web/subscription_webapp.py b/bot/app/web/subscription_webapp.py index e579747..bb90775 100644 --- a/bot/app/web/subscription_webapp.py +++ b/bot/app/web/subscription_webapp.py @@ -37,6 +37,7 @@ from bot.services.referral_service import ReferralService from bot.services.severpay_service import SeverPayService from bot.services.subscription_service import SubscriptionService from bot.services.yookassa_service import YooKassaService +from bot.utils.config_link import prepare_config_links from bot.utils.text_sanitizer import sanitize_display_name, sanitize_username from bot.utils.request_security import request_client_ip from config.settings import Settings @@ -99,6 +100,7 @@ class WebAppPaymentCreatePayload(BaseModel): method: str = "" months: Any = None + traffic_gb: Any = None description: Optional[constr(max_length=4096)] = None comment: Optional[constr(max_length=4096)] = None note: Optional[constr(max_length=4096)] = None @@ -182,6 +184,7 @@ def setup_subscription_webapp_routes(app: web.Application) -> None: app.router.add_post("/api/account/email/verify", account_email_verify_route) app.router.add_post("/api/account/telegram/link", account_telegram_link_route) app.router.add_post("/api/promo/apply", apply_promo_route) + app.router.add_post("/api/trial/activate", activate_trial_route) app.router.add_post("/api/payments", create_payment_route) app.router.add_get("/api/payments/{payment_id}", payment_status_route) @@ -430,6 +433,8 @@ def _get_cached_webapp_settings(request: web.Request) -> Dict[str, Any]: "logo_url": _resolve_webapp_logo_url(settings), "subscription_options": settings.subscription_options, "stars_subscription_options": settings.stars_subscription_options, + "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 "", @@ -1352,19 +1357,46 @@ async def create_payment_route(request: web.Request) -> web.Response: if validation_error: return validation_error method = str(payment_payload.method or "").strip().lower() - try: - months = int(float(payment_payload.months)) - except (TypeError, ValueError): - return _json_error(400, "invalid_plan", "Invalid subscription period") - settings: Settings = request.app["settings"] cached = _get_cached_webapp_settings(request) - price = cached["subscription_options"].get(months) - stars_price = cached["stars_subscription_options"].get(months) - if price is None and method != "stars": - return _json_error(400, "invalid_plan", "Subscription period is not available") - if method == "stars" and (stars_price is None or int(stars_price) <= 0): - return _json_error(400, "invalid_plan", "Stars price is not configured") + traffic_mode = bool(settings.traffic_sale_mode) + + if traffic_mode: + try: + traffic_gb = float( + payment_payload.traffic_gb + if payment_payload.traffic_gb is not None + else payment_payload.months + ) + except (TypeError, ValueError): + return _json_error(400, "invalid_plan", "Invalid traffic package") + if traffic_gb <= 0: + return _json_error(400, "invalid_plan", "Invalid traffic package") + package_key = _resolve_numeric_option_key(cached["traffic_packages"], traffic_gb) + stars_package_key = _resolve_numeric_option_key(cached["stars_traffic_packages"], traffic_gb) + price = cached["traffic_packages"].get(package_key) if package_key is not None else None + stars_price = ( + cached["stars_traffic_packages"].get(stars_package_key) + if stars_package_key is not None + else None + ) + if price is None and method != "stars": + return _json_error(400, "invalid_plan", "Traffic package is not available") + if method == "stars" and (stars_price is None or int(stars_price) <= 0): + return _json_error(400, "invalid_plan", "Stars price is not configured") + payment_units = int(traffic_gb) if float(traffic_gb).is_integer() else traffic_gb + else: + try: + months = int(float(payment_payload.months)) + except (TypeError, ValueError): + return _json_error(400, "invalid_plan", "Invalid subscription period") + price = cached["subscription_options"].get(months) + stars_price = cached["stars_subscription_options"].get(months) + if price is None and method != "stars": + return _json_error(400, "invalid_plan", "Subscription period is not available") + if method == "stars" and (stars_price is None or int(stars_price) <= 0): + return _json_error(400, "invalid_plan", "Stars price is not configured") + payment_units = months async_session_factory: sessionmaker = request.app["async_session_factory"] async with async_session_factory() as session: @@ -1377,10 +1409,81 @@ async def create_payment_route(request: web.Request) -> web.Response: session=session, user_id=user_id, method=method, - months=months, + months=payment_units, price=float(price or 0), stars_price=stars_price, lang=lang, + sale_mode="traffic" if traffic_mode else "subscription", + traffic_gb=float(payment_units) if traffic_mode else None, + ) + + +async def activate_trial_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="trial_activate", + ) + if rate_limit_response: + return rate_limit_response + + settings: Settings = request.app["settings"] + if not settings.TRIAL_ENABLED or settings.TRIAL_DURATION_DAYS <= 0: + return _json_error(400, "trial_unavailable", "Trial is not available") + + async_session_factory: sessionmaker = request.app["async_session_factory"] + subscription_service: SubscriptionService = request.app["subscription_service"] + async with async_session_factory() as session: + 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") + + activation_result = await subscription_service.activate_trial_subscription(session, user_id) + if not activation_result or not activation_result.get("activated"): + await session.rollback() + message_key = ( + activation_result.get("message_key", "trial_activation_failed") + if activation_result + else "trial_activation_failed" + ) + status = 400 if message_key != "trial_activation_failed_panel_update" else 502 + return _json_error(status, message_key, message_key) + + end_date = activation_result.get("end_date") + config_link, connect_url = await prepare_config_links( + settings, + activation_result.get("subscription_url"), + ) + + i18n_instance = request.app.get("i18n") + if settings.LOG_TRIAL_ACTIVATIONS and i18n_instance: + try: + notification_service = NotificationService(request.app["bot"], settings, i18n_instance) + await notification_service.notify_trial_activation(user_id, end_date) + except Exception: + logger.exception("Failed to send WebApp trial activation notification") + + try: + from db.dal import ad_dal as _ad_dal + + await _ad_dal.mark_trial_activated(session, user_id) + await session.commit() + except Exception: + await session.rollback() + logger.exception("Failed to mark WebApp trial activation for ad attribution") + + return web.json_response( + { + "ok": True, + "activated": True, + "days": activation_result.get("days", settings.TRIAL_DURATION_DAYS), + "end_date": end_date.isoformat() if isinstance(end_date, datetime) else None, + "end_date_text": _format_webapp_datetime(end_date) if isinstance(end_date, datetime) else None, + "traffic_gb": activation_result.get("traffic_gb", settings.TRIAL_TRAFFIC_LIMIT_GB), + "config_link": config_link, + "connect_url": connect_url or config_link, + } ) @@ -1984,6 +2087,11 @@ async def _build_user_payload(request: web.Request, user_id: int) -> Dict[str, A user_id, db_user.panel_user_uuid, ) if db_user.panel_user_uuid else None + trial_available = bool( + settings.TRIAL_ENABLED + and settings.TRIAL_DURATION_DAYS > 0 + and not await subscription_service.has_had_any_subscription(session, user_id) + ) try: await session.commit() except Exception: @@ -2018,11 +2126,18 @@ async def _build_user_payload(request: web.Request, user_id: int) -> Dict[str, A 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"], ), "payment_methods": _serialize_payment_methods(settings, request.app), "settings": { "support_url": settings.SUPPORT_LINK, "traffic_mode": bool(settings.traffic_sale_mode), + "trial_enabled": bool(settings.TRIAL_ENABLED), + "trial_available": trial_available, + "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": settings.TRIAL_TRAFFIC_STRATEGY, "email_auth_enabled": settings.email_auth_configured, }, } @@ -2120,7 +2235,33 @@ def _serialize_plans( *, subscription_options: Optional[Dict[int, float]] = None, stars_subscription_options: Optional[Dict[int, int]] = None, + traffic_packages: Optional[Dict[float, float]] = None, + stars_traffic_packages: Optional[Dict[float, int]] = None, ) -> List[Dict[str, Any]]: + if getattr(settings, "traffic_sale_mode", False): + active_traffic_packages = traffic_packages or settings.traffic_packages + active_stars_traffic_packages = stars_traffic_packages or settings.stars_traffic_packages + traffic_units = sorted(set(active_traffic_packages) | set(active_stars_traffic_packages)) + plans: List[Dict[str, Any]] = [] + for traffic_gb in traffic_units: + price = active_traffic_packages.get(traffic_gb) + stars_price = active_stars_traffic_packages.get(traffic_gb) + if price is None and (stars_price is None or int(stars_price) <= 0): + continue + traffic_value = float(traffic_gb) + plan = { + "months": int(traffic_value) if traffic_value.is_integer() else traffic_value, + "traffic_gb": traffic_value, + "price": float(price or 0), + "currency": settings.DEFAULT_CURRENCY_SYMBOL or "RUB", + "title": _format_traffic_title(traffic_value, lang), + "sale_mode": "traffic", + } + if stars_price is not None and int(stars_price) > 0: + plan["stars_price"] = int(stars_price) + plans.append(plan) + return plans + active_subscription_options = subscription_options or settings.subscription_options active_stars_subscription_options = stars_subscription_options or settings.stars_subscription_options plans: List[Dict[str, Any]] = [] @@ -2130,6 +2271,7 @@ def _serialize_plans( "price": float(price), "currency": settings.DEFAULT_CURRENCY_SYMBOL or "RUB", "title": _format_months_title(int(months), lang), + "sale_mode": "subscription", } stars_price = active_stars_subscription_options.get(months) if stars_price is not None and int(stars_price) > 0: @@ -2182,17 +2324,24 @@ async def _create_subscription_payment( session: AsyncSession, user_id: int, method: str, - months: int, + months: Any, price: float, stars_price: Optional[int], lang: str, + sale_mode: str = "subscription", + traffic_gb: Optional[float] = None, ) -> web.Response: settings: Settings = request.app["settings"] - description = _payment_description(months, lang) + sale_mode = "traffic" if sale_mode == "traffic" else "subscription" + description = ( + _traffic_payment_description(float(traffic_gb if traffic_gb is not None else months), lang) + if sale_mode == "traffic" + else _payment_description(int(months), lang) + ) if method == "yookassa": return await _create_yookassa_payment( - request, session, user_id, months, price, description + request, session, user_id, months, price, description, sale_mode=sale_mode, traffic_gb=traffic_gb ) if method == "freekassa": return await _create_freekassa_payment( @@ -2200,7 +2349,7 @@ async def _create_subscription_payment( ) if method in ("platega", "platega_sbp", "platega_crypto"): return await _create_platega_payment( - request, session, user_id, months, price, description, variant=method + request, session, user_id, months, price, description, variant=method, sale_mode=sale_mode, traffic_gb=traffic_gb ) if method == "severpay": return await _create_severpay_payment( @@ -2216,7 +2365,7 @@ async def _create_subscription_payment( months=months, amount=price, description=description, - sale_mode="subscription", + sale_mode=sale_mode, url_kind="web", ) if not url: @@ -2228,7 +2377,7 @@ async def _create_subscription_payment( if not settings.STARS_ENABLED or stars_price is None: return _json_error(400, "payment_unavailable", "Payment method unavailable") return await _create_stars_payment( - request, session, user_id, months, int(stars_price), description + request, session, user_id, months, int(stars_price), description, sale_mode=sale_mode ) return _json_error(400, "payment_unavailable", "Payment method unavailable") @@ -2265,9 +2414,12 @@ async def _create_yookassa_payment( request: web.Request, session: AsyncSession, user_id: int, - months: int, + months: Any, price: float, description: str, + *, + sale_mode: str = "subscription", + traffic_gb: Optional[float] = None, ) -> web.Response: settings: Settings = request.app["settings"] service: YooKassaService = request.app["yookassa_service"] @@ -2282,20 +2434,23 @@ async def _create_yookassa_payment( currency="RUB", status="pending_yookassa", description=description, - months=months, + months=int(float(months)) if sale_mode != "traffic" else int(float(traffic_gb or months)), provider="yookassa", ) + metadata = { + "user_id": str(user_id), + "subscription_months": str(int(float(months)) if sale_mode != "traffic" else 0), + "payment_db_id": str(payment.payment_id), + "sale_mode": sale_mode, + "source": "webapp", + } + if sale_mode == "traffic": + metadata["traffic_gb"] = _format_number_for_payload(traffic_gb or months) response = await service.create_payment( amount=price, currency="RUB", description=description, - metadata={ - "user_id": str(user_id), - "subscription_months": str(months), - "payment_db_id": str(payment.payment_id), - "sale_mode": "subscription", - "source": "webapp", - }, + metadata=metadata, receipt_email=settings.YOOKASSA_DEFAULT_RECEIPT_EMAIL, save_payment_method=bool( settings.yookassa_autopayments_active @@ -2335,7 +2490,7 @@ async def _create_freekassa_payment( request: web.Request, session: AsyncSession, user_id: int, - months: int, + months: Any, price: float, description: str, ) -> web.Response: @@ -2352,7 +2507,7 @@ async def _create_freekassa_payment( currency=service.default_currency, status="pending_freekassa", description=description, - months=months, + months=int(float(months)), provider="freekassa", ) success, response_data = await service.create_order( @@ -2396,10 +2551,12 @@ async def _create_platega_payment( request: web.Request, session: AsyncSession, user_id: int, - months: int, + months: Any, price: float, description: str, variant: str = "platega_sbp", + sale_mode: str = "subscription", + traffic_gb: Optional[float] = None, ) -> web.Response: settings: Settings = request.app["settings"] service: PlategaService = request.app["platega_service"] @@ -2422,15 +2579,17 @@ async def _create_platega_payment( currency=settings.DEFAULT_CURRENCY_SYMBOL or "RUB", status="pending_platega", description=description, - months=months, + months=int(float(months)) if sale_mode != "traffic" else int(float(traffic_gb or months)), provider="platega", ) + months_for_provider = int(float(months)) if sale_mode != "traffic" else int(float(traffic_gb or months)) payload = json.dumps( { "payment_db_id": payment.payment_id, "user_id": user_id, - "months": months, - "sale_mode": "subscription", + "months": months_for_provider if sale_mode != "traffic" else 0, + "sale_mode": sale_mode, + "traffic_gb": _format_number_for_payload(traffic_gb or months) if sale_mode == "traffic" else None, "source": "webapp", "platega_variant": "crypto" if variant == "platega_crypto" else "sbp", } @@ -2438,7 +2597,7 @@ async def _create_platega_payment( success, response_data = await service.create_transaction( payment_db_id=payment.payment_id, user_id=user_id, - months=months, + months=months_for_provider, amount=price, currency=settings.DEFAULT_CURRENCY_SYMBOL or "RUB", description=description, @@ -2483,7 +2642,7 @@ async def _create_severpay_payment( request: web.Request, session: AsyncSession, user_id: int, - months: int, + months: Any, price: float, description: str, ) -> web.Response: @@ -2500,7 +2659,7 @@ async def _create_severpay_payment( currency=settings.DEFAULT_CURRENCY_SYMBOL or "RUB", status="pending_severpay", description=description, - months=months, + months=int(float(months)), provider="severpay", ) success, response_data = await service.create_payment( @@ -2546,9 +2705,10 @@ async def _create_stars_payment( request: web.Request, session: AsyncSession, user_id: int, - months: int, + months: Any, stars_price: int, description: str, + sale_mode: str = "subscription", ) -> web.Response: bot: Bot = request.app["bot"] try: @@ -2559,10 +2719,10 @@ async def _create_stars_payment( currency="XTR", status="pending_stars", description=description, - months=months, + months=int(float(months)), provider="telegram_stars", ) - payload = f"{payment.payment_id}:{months}:subscription" + payload = f"{payment.payment_id}:{_format_number_for_payload(months)}:{sale_mode}" prices = [LabeledPrice(label=description, amount=stars_price)] create_invoice_link = getattr(bot, "create_invoice_link", None) if callable(create_invoice_link): @@ -2669,6 +2829,31 @@ def _format_months_title(months: int, lang: str) -> str: return f"{months} месяцев" +def _format_number_for_payload(value: Any) -> str: + numeric = float(value or 0) + return str(int(numeric)) if numeric.is_integer() else f"{numeric:g}" + + +def _format_traffic_title(traffic_gb: float, lang: str) -> str: + return f"{_format_number_for_payload(traffic_gb)} GB" + + +def _traffic_payment_description(traffic_gb: float, lang: str) -> str: + if lang == "en": + return f"Traffic package {_format_traffic_title(traffic_gb, lang)}" + return f"Пакет трафика {_format_traffic_title(traffic_gb, lang)}" + + +def _resolve_numeric_option_key(options: Dict[Any, Any], target: float) -> Optional[Any]: + for key in options: + try: + if abs(float(key) - float(target)) < 0.000001: + return key + except (TypeError, ValueError): + continue + return None + + def _payment_description(months: int, lang: str) -> str: if lang == "en": return f"Subscription for {_format_months_title(months, lang)}" diff --git a/locales/en.json b/locales/en.json index e016f94..897932a 100644 --- a/locales/en.json +++ b/locales/en.json @@ -610,6 +610,7 @@ "wa_auth_legal_and": "and", "wa_auth_legal_agreement": "user agreement", "wa_home_subscription_active": "Subscription active", + "wa_home_access_active": "Access active", "wa_sub_term_forever": "Forever", "wa_sub_term_value_unit": "{value} {unit}", "wa_sub_term_day_one": "day", @@ -629,6 +630,15 @@ "wa_subscription_title": "Subscription", "wa_subscription_choose_period": "Choose subscription period", "wa_per_month_short": "/mo", + "wa_traffic_packages_title": "Traffic", + "wa_traffic_packages_choose": "Choose traffic package", + "wa_per_gb_short": "/GB", + "wa_buy_traffic": "Buy traffic", + "wa_activate_trial": "Activate trial", + "wa_trial_title": "Trial period", + "wa_trial_details": "{days} days, {traffic}", + "wa_trial_activated": "Trial activated", + "wa_trial_activation_failed": "Failed to activate trial", "wa_payment_methods_not_configured": "Payment methods are not configured yet", "wa_pay": "Pay", "wa_referral_link_title": "Your referral link", diff --git a/locales/ru.json b/locales/ru.json index 805aef0..08146e7 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -610,6 +610,7 @@ "wa_auth_legal_and": "и", "wa_auth_legal_agreement": "пользовательским соглашением", "wa_home_subscription_active": "Подписка активна", + "wa_home_access_active": "Доступ активен", "wa_sub_term_forever": "Навсегда", "wa_sub_term_value_unit": "{value} {unit}", "wa_sub_term_day_one": "день", @@ -629,6 +630,15 @@ "wa_subscription_title": "Подписка", "wa_subscription_choose_period": "Выберите срок подписки", "wa_per_month_short": "/мес", + "wa_traffic_packages_title": "Трафик", + "wa_traffic_packages_choose": "Выберите пакет трафика", + "wa_per_gb_short": "/ГБ", + "wa_buy_traffic": "Купить трафик", + "wa_activate_trial": "Активировать триал", + "wa_trial_title": "Пробный период", + "wa_trial_details": "{days} дн., {traffic}", + "wa_trial_activated": "Пробный период активирован", + "wa_trial_activation_failed": "Не удалось активировать пробный период", "wa_payment_methods_not_configured": "Способы оплаты пока не настроены", "wa_pay": "Оплатить", "wa_referral_link_title": "Ваша реферальная ссылка", diff --git a/tests/test_webapp_assets.py b/tests/test_webapp_assets.py index db24f97..ee9fb16 100644 --- a/tests/test_webapp_assets.py +++ b/tests/test_webapp_assets.py @@ -6,9 +6,27 @@ from types import SimpleNamespace from unittest.mock import patch from bot.app.web import subscription_webapp +from config.settings import Settings class WebAppAssetTests(unittest.IsolatedAsyncioTestCase): + def test_serialize_plans_uses_traffic_packages_in_traffic_mode(self): + settings = Settings( + _env_file=None, + BOT_TOKEN="token", + POSTGRES_USER="app_user", + POSTGRES_PASSWORD="app_password", + TRAFFIC_PACKAGES="10:199,50:799", + STARS_TRAFFIC_PACKAGES="50:2500", + ) + + plans = subscription_webapp._serialize_plans(settings, "en") + + self.assertEqual([plan["traffic_gb"] for plan in plans], [10.0, 50.0]) + self.assertEqual(plans[0]["sale_mode"], "traffic") + self.assertEqual(plans[0]["price"], 199.0) + self.assertEqual(plans[1]["stars_price"], 2500) + def test_resolve_webapp_js_asset_name_prefers_latest_minified_build(self): with tempfile.TemporaryDirectory() as tmpdir: asset_dir = Path(tmpdir)