From 8a6b2704d44509d8839edccaa6756922181f83d3 Mon Sep 17 00:00:00 2001 From: 3252a8 <3252a8@proton.me> Date: Thu, 30 Apr 2026 13:48:32 +0300 Subject: [PATCH] feat: new tariffs web app and bot interactions --- .env.example | 1 + bot/app/web/frontend/src/App.svelte | 747 +++++++++++++++++++++++-- bot/app/web/frontend/src/styles.css | 201 +++++++ bot/app/web/subscription_webapp.py | 305 +++++++++- bot/handlers/user/subscription/core.py | 59 +- bot/services/tariff_worker.py | 21 +- config/settings.py | 21 + docker-compose-caddy.yml | 4 + docker-compose-remote-server.yml | 4 + docs/tariffs.md | 2 +- locales/en.json | 40 ++ locales/ru.json | 40 ++ tests/test_settings.py | 11 + 13 files changed, 1409 insertions(+), 47 deletions(-) diff --git a/.env.example b/.env.example index cb1a2cd..3312c60 100644 --- a/.env.example +++ b/.env.example @@ -144,6 +144,7 @@ STARS_PRICE_12_MONTHS=0 TRAFFIC_PACKAGES=10:199,50:799 # Format: ":", comma-separated STARS_TRAFFIC_PACKAGES=10:2500 # Optional: traffic packages priced in Stars TARIFFS_CONFIG_PATH=config/tariffs.json # Optional Tariffs 2.0 JSON config. If missing, legacy .env pricing is used. +TARIFF_TRAFFIC_WARNING_LEVELS=85,90,95 # Tariffs 2.0 traffic warning levels, percent used # Subscription Notifications SUBSCRIPTION_NOTIFICATIONS_ENABLED=True # Enable subscription diff --git a/bot/app/web/frontend/src/App.svelte b/bot/app/web/frontend/src/App.svelte index d11debe..93e2a1d 100644 --- a/bot/app/web/frontend/src/App.svelte +++ b/bot/app/web/frontend/src/App.svelte @@ -210,6 +210,17 @@ let selectedPlan = null; let selectedMethod = ""; let paymentModalOpen = query.get("payment") === "1"; + let paymentStep = "tariff"; + let selectedTariffKey = ""; + let topupModalOpen = query.get("topup") === "1"; + let changeModalOpen = query.get("change") === "1"; + let topupOptions = null; + let changeOptions = null; + let selectedTopupPlan = null; + let selectedChangeTarget = null; + let selectedChangeAction = null; + let changeConfirmOpen = false; + let tariffActionBusy = false; let payBusy = false; let trialBusy = false; let linkEmailOpen = false; @@ -348,6 +359,50 @@ description: "Пакет без срока действия", }, ]; + DEV_MOCK.data.tariff_change_options = { + ok: true, + current: { + tariff_key: "standard", + title: "Стандарт", + description: "100 GB каждый месяц", + billing_model: "period", + }, + targets: [ + { + tariff_key: "business", + title: "Бизнес", + description: "300 GB и приоритетные серверы", + billing_model: "period", + monthly_gb: 300, + actions: [ + { mode: "recalc_days", kind: "free", title: "recalc_days", days_after: 10, remaining_days: 25 }, + { mode: "paid_diff", kind: "payment", title: "paid_diff", price: 190, currency: "RUB" }, + ], + }, + { + tariff_key: "traffic", + title: "Трафик", + description: "Пакеты без срока действия", + billing_model: "traffic", + actions: [ + { mode: "convert_days_to_gb", kind: "free", title: "convert_days_to_gb", converted_gb: 18, remaining_days: 25 }, + { mode: "buy_package", kind: "payment", title: "+50 GB", traffic_gb: 50, price: 799, currency: "RUB" }, + ], + }, + ], + }; + DEV_MOCK.data.topup_options = { + ok: true, + tariff_key: "standard", + tariff_name: "Стандарт", + traffic_percent: 86, + warning_levels: [85, 90, 95], + plans: [ + { id: "standard:topup:10", tariff_key: "standard", tariff_name: "Стандарт", sale_mode: "topup", traffic_gb: 10, months: 10, price: 99, currency: "RUB", title: "10 GB", subtitle: "Стандарт" }, + { id: "standard:topup:50", tariff_key: "standard", tariff_name: "Стандарт", sale_mode: "topup", traffic_gb: 50, months: 50, price: 399, currency: "RUB", title: "50 GB", subtitle: "Стандарт" }, + { id: "standard:topup:200", tariff_key: "standard", tariff_name: "Стандарт", sale_mode: "topup", traffic_gb: 200, months: 200, price: 1299, currency: "RUB", title: "200 GB", subtitle: "Стандарт" }, + ], + }; } else if (mode === "devices") { DEV_MOCK.data.settings.my_devices_enabled = true; DEV_MOCK.data.subscription = { @@ -385,8 +440,16 @@ $: appSettings = data?.settings || DEV_MOCK.data.settings; $: trafficMode = Boolean(appSettings?.traffic_mode); $: tariffMode = plans.some((plan) => plan?.tariff_key); + $: tariffCatalog = buildTariffCatalog(plans); + $: selectedTariff = tariffCatalog.find((tariff) => tariff.key === selectedTariffKey) || null; + $: selectedTariffPlans = tariffMode ? (selectedTariffKey ? plans.filter((plan) => plan?.tariff_key === selectedTariffKey) : []) : plans; $: devicesEnabled = Boolean(appSettings?.my_devices_enabled); $: subscription = data?.subscription || DEV_MOCK.data.subscription; + $: hasActiveTariffSubscription = Boolean(tariffMode && subscription?.active && subscription?.tariff_key); + $: currentTariffName = activeTariffName(subscription, plans); + $: canShowTopupButton = Boolean( + hasActiveTariffSubscription && Number(subscription?.traffic_limit_bytes || 0) > 0 && trafficPercent(subscription) >= 85, + ); $: user = data?.user || {}; $: referral = data?.referral || DEV_MOCK.data.referral; $: currentLang = normalizeLangCode(user?.language_code || CFG.language || "ru"); @@ -412,8 +475,16 @@ $: supportUrl = String(appSettings?.support_url || CFG.supportUrl || "").trim(); $: telegramLoginBotId = Number(CFG.telegramLoginBotId || 0); $: applyFavicon(CFG.logoUrl, brandEmoji); - $: syncBodyScrollLock(paymentModalOpen || linkEmailOpen); - $: if (!selectedPlan && plans.length) selectedPlan = plans[Math.min(1, plans.length - 1)]; + $: syncBodyScrollLock(paymentModalOpen || changeModalOpen || changeConfirmOpen || topupModalOpen || linkEmailOpen); + $: if (!tariffMode && !selectedPlan && plans.length) selectedPlan = plans[Math.min(1, plans.length - 1)]; + $: if (tariffMode && selectedTariffKey && !tariffCatalog.some((tariff) => tariff.key === selectedTariffKey)) { + selectedTariffKey = ""; + selectedPlan = null; + paymentStep = "tariff"; + } + $: if (tariffMode && selectedTariffKey && (!selectedPlan || selectedPlan.tariff_key !== selectedTariffKey)) { + selectedPlan = selectedTariffPlans[0] || null; + } $: if (!selectedMethod && methods.length) selectedMethod = methods[0].id; $: { const emailKey = normalizedEmail(user?.email); @@ -613,7 +684,9 @@ const payload = await api("/me"); if (!payload.ok) throw new Error(payload.error || "load_failed"); data = payload; - selectedPlan = payload.plans?.[Math.min(1, payload.plans.length - 1)] || payload.plans?.[0] || null; + selectedPlan = null; + selectedTariffKey = ""; + paymentStep = "tariff"; selectedMethod = payload.payment_methods?.[0]?.id || ""; let section = MOCK && query.get("screen") ? normalizeSection(query.get("screen")) : sectionFromPath(window.location.pathname); if (section === "devices" && !payload.settings?.my_devices_enabled) section = "home"; @@ -624,6 +697,8 @@ if (section === "devices" && payload.settings?.my_devices_enabled) { await loadDevices(); } + if (topupModalOpen) await loadTopupOptions(); + if (changeModalOpen) await loadTariffChangeOptions(); } function showLogin() { @@ -675,6 +750,8 @@ } if (path === "/promo/apply") return { ok: true, end_date_text: "31.05.2026" }; if (path === "/devices") return structuredCloneSafe(DEV_MOCK.data.devices); + if (path === "/tariffs/topup-options") return structuredCloneSafe(DEV_MOCK.data.topup_options || { ok: true, plans: [] }); + if (path === "/tariffs/change-options") return structuredCloneSafe(DEV_MOCK.data.tariff_change_options || { ok: true, targets: [] }); if (path === "/devices/disconnect" && String(options.method || "").toUpperCase() === "POST") { let payload = {}; try { @@ -727,6 +804,17 @@ payment_id: 10001, }; } + if (path === "/tariffs/change" && String(options.method || "").toUpperCase() === "POST") { + return { ok: true, tariff_key: "business" }; + } + if (path === "/tariffs/change-payment" && String(options.method || "").toUpperCase() === "POST") { + return { + ok: true, + action: "open_link", + payment_url: "https://example.com/tariff-change-payment-preview", + payment_id: 10002, + }; + } return { ok: false, error: "not_found" }; } @@ -1204,7 +1292,9 @@ const payload = await api("/me"); if (payload?.ok) { data = payload; - selectedPlan = payload.plans?.[Math.min(1, payload.plans.length - 1)] || payload.plans?.[0] || null; + selectedPlan = null; + selectedTariffKey = ""; + paymentStep = "tariff"; selectedMethod = payload.payment_methods?.[0]?.id || ""; mode = "app"; screen = previousScreen; @@ -1227,6 +1317,7 @@ months: selectedPlan.months, traffic_gb: selectedPlan.traffic_gb, tariff_key: selectedPlan.tariff_key, + sale_mode: selectedPlan.sale_mode, method: selectedMethod, }), }); @@ -1241,6 +1332,138 @@ } } + async function loadTopupOptions() { + if (topupOptions || tariffActionBusy) return; + tariffActionBusy = true; + try { + const response = await api("/tariffs/topup-options"); + if (!response?.ok) throw response; + topupOptions = response; + selectedTopupPlan = response.plans?.[0] || null; + } catch (error) { + showToast(error?.message || t("wa_tariff_options_failed")); + topupModalOpen = false; + } finally { + tariffActionBusy = false; + } + } + + async function loadTariffChangeOptions() { + if (changeOptions || tariffActionBusy) return; + tariffActionBusy = true; + try { + const response = await api("/tariffs/change-options"); + if (!response?.ok) throw response; + changeOptions = response; + selectedChangeTarget = response.targets?.[0] || null; + selectedChangeAction = selectedChangeTarget?.actions?.[0] || null; + } catch (error) { + showToast(error?.message || t("wa_tariff_options_failed")); + changeModalOpen = false; + } finally { + tariffActionBusy = false; + } + } + + async function createTopupPayment() { + if (!selectedTopupPlan || !selectedMethod || payBusy) return; + payBusy = true; + try { + const response = await api("/payments", { + method: "POST", + body: JSON.stringify({ + months: selectedTopupPlan.months, + traffic_gb: selectedTopupPlan.traffic_gb, + tariff_key: selectedTopupPlan.tariff_key || topupOptions?.tariff_key, + sale_mode: "topup", + method: selectedMethod, + }), + }); + if (!response.ok || !response.payment_url) throw response; + showToast(t("wa_payment_created")); + openExternalLink(response.payment_url); + topupModalOpen = false; + } catch (error) { + showToast(error?.message || t("wa_payment_create_failed")); + } finally { + payBusy = false; + } + } + + async function applyTariffChange() { + if (!selectedChangeTarget || !selectedChangeAction || tariffActionBusy) return; + if (selectedChangeAction.kind === "payment") { + await createTariffChangePayment(); + return; + } + tariffActionBusy = true; + try { + const response = await api("/tariffs/change", { + method: "POST", + body: JSON.stringify({ + tariff_key: selectedChangeTarget.tariff_key, + mode: selectedChangeAction.mode, + }), + }); + if (!response?.ok) throw response; + showToast(t("wa_tariff_change_applied")); + changeConfirmOpen = false; + changeModalOpen = false; + changeOptions = null; + await loadData(); + } catch (error) { + showToast(error?.message || t("wa_tariff_change_failed")); + } finally { + tariffActionBusy = false; + } + } + + async function createTariffChangePayment() { + if (!selectedChangeTarget || !selectedChangeAction || !selectedMethod || payBusy) return; + payBusy = true; + try { + let response; + if (selectedChangeAction.mode === "buy_package") { + response = await api("/payments", { + method: "POST", + body: JSON.stringify({ + tariff_key: selectedChangeTarget.tariff_key, + traffic_gb: selectedChangeAction.traffic_gb, + months: selectedChangeAction.traffic_gb, + sale_mode: "topup", + method: selectedMethod, + }), + }); + } else if (selectedChangeAction.mode === "buy_period") { + response = await api("/payments", { + method: "POST", + body: JSON.stringify({ + tariff_key: selectedChangeTarget.tariff_key, + months: selectedChangeAction.months, + method: selectedMethod, + }), + }); + } else { + response = await api("/tariffs/change-payment", { + method: "POST", + body: JSON.stringify({ + tariff_key: selectedChangeTarget.tariff_key, + method: selectedMethod, + }), + }); + } + if (!response.ok || !response.payment_url) throw response; + showToast(t("wa_payment_created")); + openExternalLink(response.payment_url); + changeConfirmOpen = false; + changeModalOpen = false; + } catch (error) { + showToast(error?.message || t("wa_payment_create_failed")); + } finally { + payBusy = false; + } + } + function openExternalLink(url) { if (!url) return; if (tg?.openLink) { @@ -1425,6 +1648,13 @@ } function openPaymentModal() { + if (tariffMode) { + paymentStep = "tariff"; + selectedTariffKey = ""; + selectedPlan = null; + } else { + paymentStep = "checkout"; + } paymentModalOpen = true; } @@ -1432,6 +1662,37 @@ paymentModalOpen = false; } + function openTopupModal() { + topupModalOpen = true; + loadTopupOptions(); + } + + function closeTopupModal() { + if (payBusy || tariffActionBusy) return; + topupModalOpen = false; + } + + function openTariffChangeModal() { + changeModalOpen = true; + loadTariffChangeOptions(); + } + + function closeTariffChangeModal() { + if (payBusy || tariffActionBusy) return; + changeModalOpen = false; + changeConfirmOpen = false; + } + + function openTariffChangeConfirm() { + if (!selectedChangeTarget || !selectedChangeAction || tariffActionBusy || payBusy) return; + changeConfirmOpen = true; + } + + function closeTariffChangeConfirm() { + if (payBusy || tariffActionBusy) return; + changeConfirmOpen = false; + } + function methodMeta(method) { const id = String(method?.id || "").toLowerCase(); if (id.includes("platega_sbp")) { @@ -1488,6 +1749,75 @@ return plan?.id || `${plan?.tariff_key || "legacy"}:${plan?.sale_mode || "subscription"}:${plan?.months || plan?.traffic_gb || ""}`; } + function buildTariffCatalog(planList) { + const byKey = new Map(); + for (const plan of planList || []) { + const key = String(plan?.tariff_key || planKey(plan) || "").trim(); + if (!key) continue; + const entry = byKey.get(key) || { + key, + title: plan?.tariff_name || plan?.title || key, + description: plan?.description || "", + billing_model: plan?.billing_model || (plan?.sale_mode === "traffic_package" || plan?.sale_mode === "traffic" ? "traffic" : "period"), + monthly_gb: Number(plan?.monthly_gb || 0), + traffic_packages: [], + plans_count: 0, + }; + if (!entry.description && plan?.description) entry.description = plan.description; + if (!entry.monthly_gb && Number(plan?.monthly_gb || 0) > 0) entry.monthly_gb = Number(plan.monthly_gb); + const trafficGb = Number(plan?.traffic_gb || 0); + if (trafficGb > 0) entry.traffic_packages.push(trafficGb); + entry.plans_count += 1; + byKey.set(key, entry); + } + return Array.from(byKey.values()); + } + + function activeTariffName(sub, planList) { + const direct = String(sub?.tariff_name || "").trim(); + if (direct) return direct; + const key = String(sub?.tariff_key || "").trim(); + if (!key) return ""; + const plan = (planList || []).find((item) => item?.tariff_key === key); + return String(plan?.tariff_name || plan?.title || key).trim(); + } + + function selectTariff(tariff) { + const key = String(tariff?.key || "").trim(); + if (!key) return; + selectedTariffKey = key; + selectedPlan = plans.find((plan) => plan?.tariff_key === key) || null; + } + + function continueWithSelectedTariff() { + if (!selectedTariffKey) return; + if (!selectedPlan) { + selectedPlan = selectedTariffPlans[0] || null; + } + paymentStep = "checkout"; + } + + function backToTariffList() { + paymentStep = "tariff"; + } + + function tariffLimitLabel(tariff) { + if (!tariff) return ""; + if (String(tariff.billing_model || "") === "traffic") { + const values = (tariff.traffic_packages || []).filter((value) => Number(value) > 0).sort((a, b) => a - b); + if (!values.length) return t("wa_tariff_model_traffic"); + const min = values[0]; + const max = values[values.length - 1]; + return min === max ? formatTrafficGb(min) : `${formatTrafficGb(min)} - ${formatTrafficGb(max)}`; + } + if (Number(tariff.monthly_gb || 0) > 0) return formatTrafficGb(tariff.monthly_gb); + return t("wa_unlimited_traffic"); + } + + function actionKey(action) { + return `${action?.mode || ""}:${action?.months || ""}:${action?.traffic_gb || ""}:${action?.price || ""}`; + } + function trafficPercent(sub) { const used = Number(sub?.traffic_used_bytes || 0); const limit = Number(sub?.traffic_limit_bytes || 0); @@ -1537,14 +1867,14 @@ function planSubtitle(plan) { if (!plan?.tariff_key) return ""; if (plan?.subtitle) return plan.subtitle; - if (plan?.sale_mode === "traffic_package" || plan?.billing_model === "traffic") { + if (plan?.sale_mode === "traffic_package" || plan?.sale_mode === "topup" || plan?.billing_model === "traffic") { return formatTrafficGb(plan?.traffic_gb || plan?.months); } return _formatMonthsForClient(plan?.months); } function planUnitHint(plan) { - if (trafficMode || plan?.sale_mode === "traffic" || plan?.sale_mode === "traffic_package") { + if (trafficMode || plan?.sale_mode === "traffic" || plan?.sale_mode === "traffic_package" || plan?.sale_mode === "topup") { const gb = Number(plan?.traffic_gb || plan?.months || 0); if (!gb) return ""; if (String(selectedMethod || "").toLowerCase().includes("stars") && Number(plan?.stars_price || 0) > 0) { @@ -1566,7 +1896,11 @@ } function paymentDescription() { - if (tariffMode) return t("wa_tariffs_choose"); + if (tariffMode) { + return paymentStep === "checkout" && selectedTariff + ? t("wa_tariff_choose_period_payment", { tariff: selectedTariff.title }) + : t("wa_tariffs_choose"); + } return trafficMode ? t("wa_traffic_packages_choose") : t("wa_subscription_choose_period"); } @@ -1575,6 +1909,57 @@ return subscription.active ? t("wa_renew") : t("wa_pay_subscription"); } + function changeActionTitle(action) { + const mode = String(action?.mode || ""); + if (mode === "recalc_days") { + return t("wa_tariff_change_recalc_days", { days: Number(action?.days_after || 0) }); + } + if (mode === "convert_days_to_gb") { + return t("wa_tariff_change_convert_gb", { gb: formatCompactNumber(action?.converted_gb || 0) }); + } + if (mode === "paid_diff") { + return t("wa_tariff_change_pay_diff", { price: priceLabel(action) }); + } + if (mode === "buy_package") { + return t("wa_tariff_change_buy_package", { gb: formatCompactNumber(action?.traffic_gb || 0), price: priceLabel(action) }); + } + if (mode === "buy_period") { + return `${action?.title || ""} · ${priceLabel(action)}`; + } + return action?.title || mode; + } + + function tariffChangeSummary() { + if (!selectedChangeTarget || !selectedChangeAction) return []; + const rows = [ + t("wa_tariff_change_confirm_target", { tariff: selectedChangeTarget.title }), + t("wa_tariff_change_confirm_action", { action: changeActionTitle(selectedChangeAction) }), + ]; + const mode = String(selectedChangeAction.mode || ""); + if (mode === "recalc_days") { + rows.push(t("wa_tariff_change_confirm_recalc", { days: Number(selectedChangeAction.days_after || 0) })); + } else if (mode === "convert_days_to_gb") { + rows.push(t("wa_tariff_change_confirm_convert", { gb: formatCompactNumber(selectedChangeAction.converted_gb || 0) })); + } else if (selectedChangeAction.kind === "payment") { + rows.push(t("wa_tariff_change_confirm_payment", { price: priceLabel(selectedChangeAction) })); + } + return rows; + } + + function formatCompactNumber(value) { + const numeric = Number(value || 0); + return Number.isInteger(numeric) ? String(numeric) : numeric.toFixed(2).replace(/0+$/, "").replace(/\.$/, ""); + } + + function topupWarningText() { + const percent = Number(topupOptions?.traffic_percent || trafficPercent(subscription)); + const levels = topupOptions?.warning_levels?.length ? topupOptions.warning_levels.join(" / ") : "85 / 90 / 95"; + if (percent >= 95) return t("wa_topup_warning_critical", { percent, levels }); + if (percent >= 90) return t("wa_topup_warning_high", { percent, levels }); + if (percent >= 85) return t("wa_topup_warning_medium", { percent, levels }); + return t("wa_topup_warning_levels", { levels }); + } + function _formatMonthsForClient(value) { const months = Number(value || 0); if (months === 1) return currentLang === "en" ? "1 month" : "1 месяц"; @@ -1937,6 +2322,9 @@

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

+ {#if hasActiveTariffSubscription && currentTariffName} +

{t("wa_current_tariff", { tariff: currentTariffName })}

+ {/if}

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

@@ -1989,6 +2377,18 @@ {t("wa_activate_trial")} {/if} + {#if hasActiveTariffSubscription} + + {/if} + {#if canShowTopupButton} + + {/if} @@ -2304,31 +2704,302 @@ class="payment-dialog-card" >
-
- {#each plans as plan} - + {/each} +
+ + {:else} + {t("wa_no_tariff_change_options")} + {/if} + {:else} + {#if tariffMode} + + {#if selectedTariff} +

{t("wa_selected_tariff", { tariff: selectedTariff.title })}

+ {/if} + {/if} + {#if selectedTariffPlans.length} +
+ {#each selectedTariffPlans as plan} + + {/each} +
+ +
+ {#if methods.length} + {#each methods as method} + {@const meta = methodMeta(method)} + + {/each} + {:else} + {t("wa_payment_methods_not_configured")} + {/if} +
+ + {:else} + {t("wa_no_tariff_change_options")} + {/if} + {/if} + {#if !tariffMode} +
+ {#each plans as plan} + + {/each} +
+ +
+ {#if methods.length} + {#each methods as method} + {@const meta = methodMeta(method)} + + {/each} + {:else} + {t("wa_payment_methods_not_configured")} + {/if} +
+ + {/if} +
+ + + +
+ {#if changeOptions?.targets?.length} +

{t("wa_tariff_change_targets_title")}

+
+ {#each changeOptions.targets as target} + + {/each} +
+ {#if selectedChangeTarget?.actions?.length} + +

{t("wa_tariff_change_strategy_title")}

+
+ {#each selectedChangeTarget.actions as action} + + {/each} +
+ {#if selectedChangeAction?.kind === "payment"} +
+ {#each methods as method} + {@const meta = methodMeta(method)} + + {/each} +
+ {/if} + + {:else} + {t("wa_no_tariff_change_options")} + {/if} + {:else} + {tariffActionBusy ? t("wa_tariff_options_loading") : t("wa_no_tariff_change_options")} + {/if} +
+
+ + +
+ + {#each tariffChangeSummary() as row} +

{row}

{/each} -
- -
- {#if methods.length} + + + +
+
+ + +
+ {#if topupOptions?.plans?.length} +
+ {#each topupOptions.plans as plan} + + {/each} +
+
{#each methods as method} {@const meta = methodMeta(method)} {/each} - {:else} - {t("wa_payment_methods_not_configured")} - {/if} -
- +
+ + {:else} + {tariffActionBusy ? t("wa_tariff_options_loading") : t("wa_no_topup_options")} + {/if}
diff --git a/bot/app/web/frontend/src/styles.css b/bot/app/web/frontend/src/styles.css index 0849b90..f4234a1 100644 --- a/bot/app/web/frontend/src/styles.css +++ b/bot/app/web/frontend/src/styles.css @@ -284,6 +284,11 @@ a { opacity: 0.82; } +.sub-status .current-tariff-line { + font-weight: 850; + opacity: 1; +} + .traffic-top, .total-card { display: flex; @@ -702,6 +707,202 @@ a { gap: 8px; } +.option-list { + display: grid; + gap: 8px; +} + +.option-row, +.tariff-selected-card { + display: flex; + width: 100%; + min-height: 66px; + align-items: center; + justify-content: space-between; + gap: 12px; + border: 1px solid var(--border); + border-radius: var(--radius); + background: linear-gradient(135deg, rgba(255, 255, 255, 0.05), rgba(255, 255, 255, 0.018)); + color: var(--text); + padding: 12px; + text-align: left; + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.045); +} + +.option-row-main, +.tariff-selected-card span { + display: grid; + gap: 4px; + min-width: 0; +} + +.option-row-main strong, +.tariff-selected-card strong { + font-size: 13px; + font-weight: 900; + line-height: 1.2; + overflow-wrap: anywhere; +} + +.option-row-main small, +.tariff-selected-card small { + color: var(--muted); + font-size: 11px; + line-height: 1.35; + overflow-wrap: anywhere; +} + +.option-row-meta { + display: grid; + justify-items: end; + gap: 3px; + flex: 0 0 auto; + max-width: 42%; + text-align: right; +} + +.option-row-meta em, +.tariff-selected-card em { + color: var(--accent); + font-size: 12px; + font-style: normal; + font-weight: 900; + line-height: 1.2; + overflow-wrap: anywhere; +} + +.option-row-meta small { + color: var(--muted); + font-size: 10px; + line-height: 1.2; +} + +.option-row.active { + border-color: var(--accent); + background: color-mix(in srgb, var(--accent) 9%, rgba(255, 255, 255, 0.035)); + box-shadow: + 0 0 0 1px color-mix(in srgb, var(--accent) 50%, transparent), + inset 0 1px 0 rgba(255, 255, 255, 0.06); +} + +.tariff-row { + min-height: 74px; +} + +.plan-row, +.change-action-row { + min-height: 62px; +} + +.change-action-row { + align-items: flex-start; +} + +.change-action-row > svg { + flex: 0 0 auto; + margin-top: 1px; + color: var(--accent); +} + +.back-inline { + display: inline-flex; + width: fit-content; + align-items: center; + gap: 6px; + border: 0; + background: transparent; + color: var(--muted); + padding: 0; + font-size: 12px; + font-weight: 850; +} + +.tariff-step-caption, +.section-kicker { + margin: 0; + color: var(--muted); + font-size: 12px; + font-weight: 800; + line-height: 1.3; +} + +.section-kicker { + color: var(--dim); + text-transform: uppercase; +} + +.tariff-selected-card { + min-height: 58px; + background: color-mix(in srgb, var(--accent) 7%, rgba(255, 255, 255, 0.035)); +} + +.tariff-action-list { + display: grid; + gap: 8px; +} + +.tariff-action-card, +.tariff-warning-card { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + border: 1px solid var(--border); + border-radius: var(--radius); + background: linear-gradient(135deg, rgba(255, 255, 255, 0.05), rgba(255, 255, 255, 0.018)); + color: var(--text); + padding: 12px; + text-align: left; +} + +.tariff-action-card span { + display: grid; + gap: 4px; + min-width: 0; +} + +.tariff-action-card strong, +.tariff-warning-card span { + font-size: 13px; + font-weight: 850; +} + +.tariff-action-card small { + color: var(--muted); + font-size: 11px; +} + +.tariff-action-card em { + color: var(--accent); + font-size: 10px; + font-style: normal; + font-weight: 850; + white-space: nowrap; +} + +.tariff-action-card.active { + border-color: var(--accent); + background: color-mix(in srgb, var(--accent) 9%, rgba(255, 255, 255, 0.035)); +} + +.tariff-warning-card { + justify-content: flex-start; + color: var(--warning, #ffd166); +} + +.confirm-summary-card { + display: grid; + gap: 8px; +} + +.confirm-summary-card p { + margin: 0; + color: var(--text); + font-size: 13px; + font-weight: 750; + line-height: 1.35; +} + .method-card { display: flex; width: 100%; diff --git a/bot/app/web/subscription_webapp.py b/bot/app/web/subscription_webapp.py index 066f129..6f400e8 100644 --- a/bot/app/web/subscription_webapp.py +++ b/bot/app/web/subscription_webapp.py @@ -102,11 +102,19 @@ class WebAppPaymentCreatePayload(BaseModel): months: Any = None traffic_gb: Any = None tariff_key: Optional[constr(max_length=128)] = None + sale_mode: Optional[constr(max_length=64)] = None description: Optional[constr(max_length=4096)] = None comment: Optional[constr(max_length=4096)] = None note: Optional[constr(max_length=4096)] = None +class WebAppTariffChangePayload(BaseModel): + model_config = ConfigDict(extra="ignore") + + tariff_key: constr(min_length=1, max_length=128) + mode: constr(min_length=1, max_length=64) + + class WebAppLanguagePayload(BaseModel): model_config = ConfigDict(extra="ignore") @@ -195,6 +203,10 @@ def setup_subscription_webapp_routes(app: web.Application) -> None: app.router.add_post("/api/trial/activate", activate_trial_route) app.router.add_get("/api/devices", devices_route) app.router.add_post("/api/devices/disconnect", disconnect_device_route) + app.router.add_get("/api/tariffs/topup-options", tariff_topup_options_route) + app.router.add_get("/api/tariffs/change-options", tariff_change_options_route) + app.router.add_post("/api/tariffs/change", tariff_change_route) + app.router.add_post("/api/tariffs/change-payment", tariff_change_payment_route) app.router.add_post("/api/payments", create_payment_route) app.router.add_get("/api/payments/{payment_id}", payment_status_route) @@ -1373,8 +1385,39 @@ async def create_payment_route(request: web.Request) -> web.Response: traffic_mode = bool(settings.traffic_sale_mode) sale_mode = "subscription" traffic_gb_for_payment: Optional[float] = None + requested_sale_mode = _sale_mode_base(str(payment_payload.sale_mode or "")) - if tariffs_config: + if tariffs_config and requested_sale_mode == "topup": + tariff_key = str(payment_payload.tariff_key or "").strip() + if not tariff_key: + return _json_error(400, "invalid_plan", "Tariff is not selected") + try: + tariff = tariffs_config.require(tariff_key) + except Exception: + return _json_error(400, "invalid_plan", "Tariff is not available") + 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") + packages = tariffs_config.topup_packages_for(tariff) + rub_packages = {float(package.gb): float(package.price) for package in (packages.rub if packages else [])} + stars_packages = {float(package.gb): int(float(package.price)) for package in (packages.stars if packages else [])} + package_key = _resolve_numeric_option_key(rub_packages, traffic_gb) + stars_package_key = _resolve_numeric_option_key(stars_packages, traffic_gb) + price = rub_packages.get(package_key) if package_key is not None else None + stars_price = stars_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 + traffic_gb_for_payment = float(payment_units) + sale_mode = f"topup@{tariff.key}" + elif tariffs_config: tariff_key = str(payment_payload.tariff_key or "").strip() if not tariff_key: return _json_error(400, "invalid_plan", "Tariff is not selected") @@ -1562,6 +1605,148 @@ async def activate_trial_route(request: web.Request) -> web.Response: ) +async def tariff_topup_options_route(request: web.Request) -> web.Response: + user_id = _require_user_id(request) + settings: Settings = request.app["settings"] + config = settings.tariffs_config + if not config: + return _json_error(404, "tariffs_unavailable", "Tariffs are not configured") + + async_session_factory: sessionmaker = request.app["async_session_factory"] + 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") + sub = await subscription_dal.get_active_subscription_by_user_id(session, user_id, db_user.panel_user_uuid) + if not sub or not sub.tariff_key: + return _json_error(400, "subscription_required", "Active tariff subscription is required") + lang = db_user.language_code or settings.DEFAULT_LANGUAGE + tariff = config.require(sub.tariff_key) + plans = _serialize_topup_packages(settings, tariff, config.topup_packages_for(tariff), lang) + return web.json_response( + { + "ok": True, + "tariff_key": tariff.key, + "tariff_name": tariff.name(lang), + "traffic_percent": _traffic_percent(sub.traffic_used_bytes, sub.traffic_limit_bytes), + "warning_levels": settings.tariff_traffic_warning_levels, + "plans": plans, + } + ) + + +async def tariff_change_options_route(request: web.Request) -> web.Response: + user_id = _require_user_id(request) + settings: Settings = request.app["settings"] + config = settings.tariffs_config + if not config: + return _json_error(404, "tariffs_unavailable", "Tariffs are not configured") + + 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") + sub = await subscription_dal.get_active_subscription_by_user_id(session, user_id, db_user.panel_user_uuid) + if not sub or not sub.tariff_key: + return _json_error(400, "subscription_required", "Active tariff subscription is required") + lang = db_user.language_code or settings.DEFAULT_LANGUAGE + current = config.require(sub.tariff_key) + targets = [] + for tariff in config.enabled_tariffs: + if tariff.key == current.key: + continue + options = subscription_service.calculate_tariff_switch_options(sub, tariff) + targets.append(_serialize_tariff_change_target(settings, config, tariff, options, lang)) + return web.json_response( + { + "ok": True, + "current": { + "tariff_key": current.key, + "title": current.name(lang), + "description": current.description(lang), + "billing_model": current.billing_model, + }, + "targets": targets, + } + ) + + +async def tariff_change_route(request: web.Request) -> web.Response: + user_id = _require_user_id(request) + payload = await _read_json(request) + change_payload, validation_error = _validate_model_payload(WebAppTariffChangePayload, payload) + if validation_error: + return validation_error + mode = str(change_payload.mode or "").strip() + if mode not in {"recalc_days", "convert_days_to_gb"}: + return _json_error(400, "invalid_change_mode", "This tariff change requires payment") + + settings: Settings = request.app["settings"] + if not settings.tariffs_config: + return _json_error(404, "tariffs_unavailable", "Tariffs are not configured") + 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") + result = await subscription_service.switch_tariff_without_payment( + session, + user_id, + str(change_payload.tariff_key), + mode, + ) + if not result: + await session.rollback() + return _json_error(400, "change_failed", "Tariff change failed") + await session.commit() + return web.json_response({"ok": True, **result}) + + +async def tariff_change_payment_route(request: web.Request) -> web.Response: + user_id = _require_user_id(request) + payload = await _read_json(request) + payment_payload, validation_error = _validate_model_payload(WebAppPaymentCreatePayload, payload) + if validation_error: + return validation_error + method = str(payment_payload.method or "").strip().lower() + tariff_key = str(payment_payload.tariff_key or "").strip() + settings: Settings = request.app["settings"] + config = settings.tariffs_config + if not config: + return _json_error(404, "tariffs_unavailable", "Tariffs are not configured") + if not tariff_key: + return _json_error(400, "invalid_plan", "Tariff is not selected") + + 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") + sub = await subscription_dal.get_active_subscription_by_user_id(session, user_id, db_user.panel_user_uuid) + if not sub: + return _json_error(400, "subscription_required", "Active tariff subscription is required") + target = config.require(tariff_key) + options = subscription_service.calculate_tariff_switch_options(sub, target) + price = float(options.get("paid_diff_rub") or 0) + if price <= 0: + return _json_error(400, "payment_not_required", "Payment is not required for this tariff change") + return await _create_subscription_payment( + request=request, + session=session, + user_id=user_id, + method=method, + months=1, + price=price, + stars_price=None, + lang=db_user.language_code or settings.DEFAULT_LANGUAGE, + sale_mode=f"tariff_upgrade@{target.key}", + ) + + async def devices_route(request: web.Request) -> web.Response: user_id = _require_user_id(request) settings: Settings = request.app["settings"] @@ -2533,6 +2718,124 @@ def _serialize_plans( return plans +def _traffic_percent(used: Optional[int], limit: Optional[int]) -> int: + used_val = int(used or 0) + limit_val = int(limit or 0) + if limit_val <= 0: + return 0 + return max(0, min(100, round((used_val / limit_val) * 100))) + + +def _serialize_topup_packages( + settings: Settings, + tariff: Any, + packages: Optional[Any], + lang: str, +) -> List[Dict[str, Any]]: + rub_packages = {float(package.gb): float(package.price) for package in (packages.rub if packages else [])} + stars_packages = {float(package.gb): int(float(package.price)) for package in (packages.stars if packages else [])} + plans: List[Dict[str, Any]] = [] + for traffic_gb in sorted(set(rub_packages) | set(stars_packages)): + price = rub_packages.get(traffic_gb) + stars_price = stars_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: Dict[str, Any] = { + "id": f"{tariff.key}:topup:{_format_number_for_payload(traffic_value)}", + "tariff_key": tariff.key, + "tariff_name": tariff.name(lang), + "billing_model": tariff.billing_model, + "sale_mode": "topup", + "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), + "subtitle": tariff.name(lang), + } + if stars_price is not None and int(stars_price) > 0: + plan["stars_price"] = int(stars_price) + plans.append(plan) + return plans + + +def _serialize_tariff_change_target( + settings: Settings, + config: Any, + tariff: Any, + options: Dict[str, Any], + lang: str, +) -> Dict[str, Any]: + actions: List[Dict[str, Any]] = [] + mode = str(options.get("mode") or "") + if mode == "period_to_period": + actions.append( + { + "mode": "recalc_days", + "kind": "free", + "title": "recalc_days", + "days_after": int(options.get("recalc_days") or 0), + "remaining_days": int(options.get("remaining_days") or 0), + } + ) + paid_diff = float(options.get("paid_diff_rub") or 0) + if paid_diff > 0: + actions.append( + { + "mode": "paid_diff", + "kind": "payment", + "title": "paid_diff", + "price": paid_diff, + "currency": settings.DEFAULT_CURRENCY_SYMBOL or "RUB", + } + ) + elif mode == "period_to_traffic": + actions.append( + { + "mode": "convert_days_to_gb", + "kind": "free", + "title": "convert_days_to_gb", + "converted_gb": float(options.get("converted_gb") or 0), + "remaining_days": int(options.get("remaining_days") or 0), + } + ) + actions.extend( + { + "mode": "buy_package", + "kind": "payment", + "title": f"+{package.gb:g} GB", + "traffic_gb": float(package.gb), + "price": float(package.price), + "currency": settings.DEFAULT_CURRENCY_SYMBOL or "RUB", + } + for package in (tariff.traffic_packages.rub if tariff.traffic_packages else []) + ) + else: + for months in tariff.enabled_periods: + price = tariff.period_price(int(months), "rub") + if price: + actions.append( + { + "mode": "buy_period", + "kind": "payment", + "months": int(months), + "title": _format_months_title(int(months), lang), + "price": float(price), + "currency": settings.DEFAULT_CURRENCY_SYMBOL or "RUB", + } + ) + return { + "tariff_key": tariff.key, + "title": tariff.name(lang), + "description": tariff.description(lang), + "billing_model": tariff.billing_model, + "monthly_gb": tariff.monthly_gb, + "options": options, + "actions": actions, + } + + def _serialize_payment_methods( settings: Settings, app: web.Application, diff --git a/bot/handlers/user/subscription/core.py b/bot/handlers/user/subscription/core.py index 85d580e..43b1b6e 100644 --- a/bot/handlers/user/subscription/core.py +++ b/bot/handlers/user/subscription/core.py @@ -271,11 +271,11 @@ async def tariff_change_select_callback(callback: types.CallbackQuery, i18n_data options = subscription_service.calculate_tariff_switch_options(db_sub, target) rows = [] if options["mode"] == "period_to_period": - rows.append([InlineKeyboardButton(text=f"Без доплаты, дней станет {options['recalc_days']}", callback_data=f"tariff_change:apply:{target.key}:recalc_days")]) + rows.append([InlineKeyboardButton(text=f"Без доплаты, дней станет {options['recalc_days']}", callback_data=f"tariff_change:confirm_apply:{target.key}:recalc_days")]) if options.get("paid_diff_rub", 0) > 0: - rows.append([InlineKeyboardButton(text=f"Доплатить {options['paid_diff_rub']} RUB", callback_data=f"tariff_change:pay:{target.key}:{options['paid_diff_rub']}")]) + rows.append([InlineKeyboardButton(text=f"Доплатить {options['paid_diff_rub']} RUB", callback_data=f"tariff_change:confirm_pay:{target.key}:{options['paid_diff_rub']}")]) elif options["mode"] == "period_to_traffic": - rows.append([InlineKeyboardButton(text=f"Перейти без доплаты, получить {options['converted_gb']} GB", callback_data=f"tariff_change:apply:{target.key}:convert_days_to_gb")]) + rows.append([InlineKeyboardButton(text=f"Перейти без доплаты, получить {options['converted_gb']} GB", callback_data=f"tariff_change:confirm_apply:{target.key}:convert_days_to_gb")]) for package in target.traffic_packages.rub: rows.append([InlineKeyboardButton(text=f"+ {package.gb:g} GB за {package.price:g} RUB", callback_data=f"tariff:package:{target.key}:{package.gb:g}")]) else: @@ -288,6 +288,59 @@ async def tariff_change_select_callback(callback: types.CallbackQuery, i18n_data await callback.answer() +@router.callback_query(F.data.startswith("tariff_change:confirm_apply:")) +async def tariff_change_confirm_apply_callback(callback: types.CallbackQuery, i18n_data: dict, settings: Settings, subscription_service: SubscriptionService, session: AsyncSession): + current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE) + i18n: JsonI18n = i18n_data.get("i18n_instance") + config = settings.tariffs_config + if not config or not callback.message: + await callback.answer("Error", show_alert=True) + return + _, _, tariff_key, mode = callback.data.split(":", 3) + target = config.require(tariff_key) + db_sub = await subscription_dal.get_active_subscription_by_user_id(session, callback.from_user.id) + if not db_sub: + await callback.answer("Error", show_alert=True) + return + options = subscription_service.calculate_tariff_switch_options(db_sub, target) + if mode == "recalc_days": + action_text = f"после перехода останется {options.get('recalc_days', 0)} дн." + elif mode == "convert_days_to_gb": + action_text = f"будет начислено {options.get('converted_gb', 0)} GB трафика" + else: + action_text = "тариф будет изменен без доплаты" + rows = [ + [InlineKeyboardButton(text="✅ Подтвердить", callback_data=f"tariff_change:apply:{target.key}:{mode}")], + [InlineKeyboardButton(text=i18n.gettext(current_lang, "back_to_main_menu_button"), callback_data=f"tariff_change:select:{target.key}")], + ] + await callback.message.edit_text( + f"Подтвердите смену тарифа\n\nНовый тариф: {target.name(current_lang)}\nИзменение: {action_text}", + reply_markup=InlineKeyboardMarkup(inline_keyboard=rows), + ) + await callback.answer() + + +@router.callback_query(F.data.startswith("tariff_change:confirm_pay:")) +async def tariff_change_confirm_pay_callback(callback: types.CallbackQuery, i18n_data: dict, settings: Settings): + current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE) + i18n: JsonI18n = i18n_data.get("i18n_instance") + config = settings.tariffs_config + if not config or not callback.message: + await callback.answer("Error", show_alert=True) + return + _, _, tariff_key, amount_raw = callback.data.split(":", 3) + target = config.require(tariff_key) + rows = [ + [InlineKeyboardButton(text="✅ Подтвердить и оплатить", callback_data=f"tariff_change:pay:{target.key}:{amount_raw}")], + [InlineKeyboardButton(text=i18n.gettext(current_lang, "back_to_main_menu_button"), callback_data=f"tariff_change:select:{target.key}")], + ] + await callback.message.edit_text( + f"Подтвердите смену тарифа\n\nНовый тариф: {target.name(current_lang)}\nБудет создана оплата на {amount_raw} RUB.", + reply_markup=InlineKeyboardMarkup(inline_keyboard=rows), + ) + await callback.answer() + + @router.callback_query(F.data.startswith("tariff_change:apply:")) async def tariff_change_apply_callback(callback: types.CallbackQuery, i18n_data: dict, settings: Settings, subscription_service: SubscriptionService, session: AsyncSession): _, _, tariff_key, mode = callback.data.split(":", 3) diff --git a/bot/services/tariff_worker.py b/bot/services/tariff_worker.py index dd9219b..022a6fa 100644 --- a/bot/services/tariff_worker.py +++ b/bot/services/tariff_worker.py @@ -4,6 +4,7 @@ from datetime import datetime, timezone from typing import Optional from aiogram import Bot +from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import sessionmaker @@ -124,7 +125,9 @@ class TariffTrafficWorker: if limit_val <= 0: return ratio = used_val / limit_val - for level, threshold in ((80, 0.8), (95, 0.95), (100, 1.0)): + levels = list(getattr(self.settings, "tariff_traffic_warning_levels", [85, 90, 95])) + for level in levels: + threshold = level / 100 if ratio < threshold: continue warning = await tariff_dal.get_warning( @@ -150,11 +153,21 @@ class TariffTrafficWorker: text = f"Трафик тарифа {tariff.name(self.settings.DEFAULT_LANGUAGE)} почти закончился. Осталось около {left_pct}%." else: text = "Трафик закончился. Доступ временно ограничен до сброса или докупки пакета." - await self.bot.send_message(sub.user_id, text) + markup = InlineKeyboardMarkup( + inline_keyboard=[ + [ + InlineKeyboardButton( + text="Докупить трафик", + callback_data="tariff_topup:list", + ) + ] + ] + ) + await self.bot.send_message(sub.user_id, text, reply_markup=markup) except Exception: logging.exception("Failed to send traffic warning to user %s", sub.user_id) - if level == 100: - await self._throttle(session, sub, tariff) + if ratio >= 1.0: + await self._throttle(session, sub, tariff) async def _throttle(self, session: AsyncSession, sub: Subscription, tariff) -> None: if sub.is_throttled: diff --git a/config/settings.py b/config/settings.py index 246048f..25bdb46 100644 --- a/config/settings.py +++ b/config/settings.py @@ -262,6 +262,10 @@ class Settings(BaseSettings): description="Comma-separated list of traffic packages priced in Stars, e.g. '5:500,20:1500'", ) TARIFFS_CONFIG_PATH: str = Field(default="config/tariffs.json") + TARIFF_TRAFFIC_WARNING_LEVELS: str = Field( + default="85,90,95", + description="Comma-separated traffic usage warning levels for tariff traffic limits, e.g. '85,90,95'", + ) SUBSCRIPTION_NOTIFICATIONS_ENABLED: bool = Field(default=True) SUBSCRIPTION_NOTIFY_ON_EXPIRE: bool = Field(default=True) @@ -741,6 +745,23 @@ class Settings(BaseSettings): return False return bool(self.traffic_packages or self.stars_traffic_packages) + @computed_field + @property + def tariff_traffic_warning_levels(self) -> List[int]: + levels: List[int] = [] + for part in (self.TARIFF_TRAFFIC_WARNING_LEVELS or "").split(","): + chunk = part.strip() + if not chunk: + continue + try: + level = int(float(chunk)) + except ValueError: + logging.warning("Invalid TARIFF_TRAFFIC_WARNING_LEVELS entry skipped: %s", chunk) + continue + if 0 < level < 100 and level not in levels: + levels.append(level) + return sorted(levels) or [85, 90, 95] + @computed_field @property def tariffs_config(self) -> Optional[TariffsConfig]: diff --git a/docker-compose-caddy.yml b/docker-compose-caddy.yml index a5faacd..b349743 100644 --- a/docker-compose-caddy.yml +++ b/docker-compose-caddy.yml @@ -9,6 +9,10 @@ services: - TZ=UTC volumes: - ./locales:/app/locales + # Optional Tariffs 2.0 config. Put tariffs.json near this compose file, + # then uncomment the mount below. If no JSON is mounted, legacy .env + # pricing is used. + # - ./tariffs.json:/app/config/tariffs.json:ro restart: unless-stopped depends_on: remnawave-minishop-db: diff --git a/docker-compose-remote-server.yml b/docker-compose-remote-server.yml index 6fe31c6..3302f6e 100644 --- a/docker-compose-remote-server.yml +++ b/docker-compose-remote-server.yml @@ -14,6 +14,10 @@ services: - TZ=UTC volumes: - ./locales:/app/locales + # Optional Tariffs 2.0 config. Put tariffs.json near this compose file, + # then uncomment the mount below. If no JSON is mounted, legacy .env + # pricing is used. + # - ./tariffs.json:/app/config/tariffs.json:ro restart: unless-stopped depends_on: - remnawave-minishop-db diff --git a/docs/tariffs.md b/docs/tariffs.md index 7da068f..a1ee9d8 100644 --- a/docs/tariffs.md +++ b/docs/tariffs.md @@ -64,6 +64,6 @@ Legacy поле `subscription_duration_months` остается для совм `TariffTrafficWorker` запускается, только если активен `tariffs.json`. - Раз в несколько минут синхронизирует `trafficLimitStrategy = MONTH` для period-тарифов, если панель ещё не переключена. -- Отправляет/дедуплицирует уровни предупреждений 80/95/100 через `traffic_warnings`. +- Отправляет/дедуплицирует уровни предупреждений из `TARIFF_TRAFFIC_WARNING_LEVELS` (по умолчанию `85,90,95`) через `traffic_warnings`. - При 100% удаляет пользователя из squad-ов тарифа и ставит `is_throttled`. - Возвращает пользователя в squad-ы, когда лимит снова больше использованного трафика. diff --git a/locales/en.json b/locales/en.json index 745f43f..eddea0e 100644 --- a/locales/en.json +++ b/locales/en.json @@ -579,6 +579,7 @@ "email_subscription_expiring_text_renew": "Renew: {url}", "wa_loading": "Loading...", "wa_back": "Back", + "wa_next": "Next", "wa_close": "Close", "wa_auth_checking_login": "Checking sign-in...", "wa_auth_login_confirm_failed": "Could not confirm sign-in", @@ -631,6 +632,45 @@ "wa_subscription_choose_period": "Choose subscription period", "wa_tariffs_title": "Tariffs", "wa_tariffs_choose": "Choose a tariff and payment option", + "wa_tariff_choose_period_payment": "{tariff}: choose period and payment method", + "wa_back_to_tariffs": "Back to tariffs", + "wa_selected_tariff": "Selected tariff: {tariff}", + "wa_tariff_no_description": "Tariff description is not configured", + "wa_change_tariff": "Change tariff", + "wa_topup_traffic": "Buy traffic", + "wa_current_tariff": "Current tariff: {tariff}", + "wa_tariff_options_loading": "Loading options", + "wa_tariff_options_failed": "Failed to load tariff options", + "wa_tariff_change_applied": "Tariff changed", + "wa_tariff_change_failed": "Failed to change tariff", + "wa_no_tariff_change_options": "No tariff change options available", + "wa_no_topup_options": "No top-up packages available", + "wa_tariff_model_period": "period", + "wa_tariff_model_traffic": "traffic", + "wa_tariff_change_recalc_days": "No payment: {days} days left", + "wa_tariff_change_convert_gb": "No payment: receive {gb} GB", + "wa_tariff_change_pay_diff": "Pay {price}", + "wa_tariff_change_buy_package": "Switch and add {gb} GB for {price}", + "wa_tariff_change_recalc_hint": "Remaining {days} days will be recalculated using the new tariff price.", + "wa_tariff_change_convert_hint": "Remaining {days} days will be converted into traffic.", + "wa_tariff_change_payment_hint": "The switch is applied after payment.", + "wa_tariff_change_targets_title": "New tariff", + "wa_tariff_change_strategy_title": "Switch option", + "wa_tariff_change_confirm_title": "Confirm tariff change", + "wa_tariff_change_confirm_desc": "Check what will happen after confirmation.", + "wa_tariff_change_confirm_target": "New tariff: {tariff}", + "wa_tariff_change_confirm_action": "Option: {action}", + "wa_tariff_change_confirm_recalc": "{days} days will remain after the switch.", + "wa_tariff_change_confirm_convert": "{gb} GB of traffic will be added.", + "wa_tariff_change_confirm_payment": "A payment for {price} will be created.", + "wa_confirm_and_apply": "Confirm and apply", + "wa_confirm_and_pay": "Confirm and pay", + "wa_topup_for_tariff": "Packages for {tariff}", + "wa_topup_warning_levels": "Traffic warnings are sent at {levels}%.", + "wa_topup_warning_medium": "{percent}% of traffic is used. Warnings are set at {levels}%.", + "wa_topup_warning_high": "{percent}% of traffic is used. Buying a package now is recommended.", + "wa_topup_warning_critical": "{percent}% of traffic is used. Access may be limited soon.", + "wa_apply": "Apply", "wa_per_month_short": "/mo", "wa_traffic_packages_title": "Traffic", "wa_traffic_packages_choose": "Choose traffic package", diff --git a/locales/ru.json b/locales/ru.json index afba3e7..895b8b6 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -579,6 +579,7 @@ "email_subscription_expiring_text_renew": "Продлить: {url}", "wa_loading": "Загрузка...", "wa_back": "Назад", + "wa_next": "Далее", "wa_close": "Закрыть", "wa_auth_checking_login": "Проверяем вход...", "wa_auth_login_confirm_failed": "Не удалось подтвердить вход", @@ -631,6 +632,45 @@ "wa_subscription_choose_period": "Выберите срок подписки", "wa_tariffs_title": "Тарифы", "wa_tariffs_choose": "Выберите тариф и вариант оплаты", + "wa_tariff_choose_period_payment": "{tariff}: выберите период и способ оплаты", + "wa_back_to_tariffs": "К списку тарифов", + "wa_selected_tariff": "Выбран тариф: {tariff}", + "wa_tariff_no_description": "Описание тарифа не задано", + "wa_change_tariff": "Сменить тариф", + "wa_topup_traffic": "Докупить трафик", + "wa_current_tariff": "Текущий тариф: {tariff}", + "wa_tariff_options_loading": "Загружаем варианты", + "wa_tariff_options_failed": "Не удалось загрузить варианты тарифов", + "wa_tariff_change_applied": "Тариф изменен", + "wa_tariff_change_failed": "Не удалось изменить тариф", + "wa_no_tariff_change_options": "Нет доступных вариантов смены тарифа", + "wa_no_topup_options": "Нет доступных пакетов докупки", + "wa_tariff_model_period": "период", + "wa_tariff_model_traffic": "трафик", + "wa_tariff_change_recalc_days": "Без доплаты: станет {days} дн.", + "wa_tariff_change_convert_gb": "Без доплаты: получить {gb} GB", + "wa_tariff_change_pay_diff": "Доплатить {price}", + "wa_tariff_change_buy_package": "Перейти и добавить {gb} GB за {price}", + "wa_tariff_change_recalc_hint": "Оставшиеся {days} дн. будут пересчитаны по цене нового тарифа.", + "wa_tariff_change_convert_hint": "Оставшиеся {days} дн. будут переведены в пакет трафика.", + "wa_tariff_change_payment_hint": "Переход применится после оплаты.", + "wa_tariff_change_targets_title": "Новый тариф", + "wa_tariff_change_strategy_title": "Вариант перехода", + "wa_tariff_change_confirm_title": "Подтвердите смену тарифа", + "wa_tariff_change_confirm_desc": "Проверьте, что произойдет после подтверждения.", + "wa_tariff_change_confirm_target": "Новый тариф: {tariff}", + "wa_tariff_change_confirm_action": "Вариант: {action}", + "wa_tariff_change_confirm_recalc": "После перехода останется {days} дн.", + "wa_tariff_change_confirm_convert": "Будет начислено {gb} GB трафика.", + "wa_tariff_change_confirm_payment": "Будет создана оплата на {price}.", + "wa_confirm_and_apply": "Подтвердить и применить", + "wa_confirm_and_pay": "Подтвердить и оплатить", + "wa_topup_for_tariff": "Пакеты для тарифа {tariff}", + "wa_topup_warning_levels": "Предупреждения о трафике приходят на {levels}%.", + "wa_topup_warning_medium": "Использовано {percent}% трафика. Предупреждения настроены на {levels}%.", + "wa_topup_warning_high": "Использовано {percent}% трафика. Лучше докупить пакет заранее.", + "wa_topup_warning_critical": "Использовано {percent}% трафика. Доступ скоро может быть ограничен.", + "wa_apply": "Применить", "wa_per_month_short": "/мес", "wa_traffic_packages_title": "Трафик", "wa_traffic_packages_choose": "Выберите пакет трафика", diff --git a/tests/test_settings.py b/tests/test_settings.py index a9e0d93..c4e86f7 100644 --- a/tests/test_settings.py +++ b/tests/test_settings.py @@ -91,3 +91,14 @@ class SettingsTests(unittest.TestCase): ) self.assertEqual(settings.TRIAL_TRAFFIC_STRATEGY, "WEEK") + + def test_tariff_warning_levels_are_parsed(self): + settings = Settings( + _env_file=None, + BOT_TOKEN="token", + POSTGRES_USER="app_user", + POSTGRES_PASSWORD="app_password", + TARIFF_TRAFFIC_WARNING_LEVELS="90,85,bad,95,90,100,0", + ) + + self.assertEqual(settings.tariff_traffic_warning_levels, [85, 90, 95])