diff --git a/bot/app/web/admin_api.py b/bot/app/web/admin_api.py index a2501f6..f1dd698 100644 --- a/bot/app/web/admin_api.py +++ b/bot/app/web/admin_api.py @@ -253,11 +253,20 @@ def _write_tariffs_config_file(path: Path, config: TariffsConfig) -> None: data = _tariffs_config_payload(config) path.parent.mkdir(parents=True, exist_ok=True) tmp_path = path.with_suffix(f"{path.suffix}.tmp") - tmp_path.write_text( - json.dumps(data, ensure_ascii=False, indent=2) + "\n", - encoding="utf-8", - ) - tmp_path.replace(path) + payload = json.dumps(data, ensure_ascii=False, indent=2) + "\n" + try: + tmp_path.write_text(payload, encoding="utf-8") + tmp_path.replace(path) + except PermissionError: + # A docker-compose single-file bind mount can make /app/config + # unwritable while the mounted tariffs.json itself is writable. + # Fall back to updating the existing file in-place. + if tmp_path.exists(): + try: + tmp_path.unlink() + except OSError: + pass + path.write_text(payload, encoding="utf-8") # ─── Routes ──────────────────────────────────────────────────────── @@ -1225,6 +1234,36 @@ async def admin_tariffs_save_route(request: web.Request) -> web.Response: return _ok({"exists": True, "path": str(path), "catalog": _tariffs_config_payload(config)}) +async def admin_panel_internal_squads_route(request: web.Request) -> web.Response: + _require_admin_user_id(request) + panel_service = request.app.get("panel_service") + if panel_service is None: + return _error(503, "panel_unavailable", "Panel service unavailable") + try: + squads = await panel_service.get_internal_squads() + except Exception as exc: + logger.exception("Failed to load internal squads from panel") + return _error(502, "panel_request_failed", str(exc)) + if squads is None: + return _error(502, "panel_request_failed", "Unable to load internal squads") + items = [] + for squad in squads: + if not isinstance(squad, dict): + continue + uuid = squad.get("uuid") or squad.get("id") + if not uuid: + continue + items.append( + { + "uuid": str(uuid), + "name": squad.get("name") or squad.get("title") or str(uuid), + "members_count": squad.get("membersCount") or squad.get("usersCount") or squad.get("members_count"), + "active_inbounds_count": squad.get("activeInboundsCount") or squad.get("active_inbounds_count"), + } + ) + return _ok({"squads": items}) + + # ─── Router setup ────────────────────────────────────────────────── @@ -1265,3 +1304,4 @@ def setup_admin_routes(app: web.Application) -> None: router.add_get("/api/admin/tariffs", admin_tariffs_get_route) router.add_put("/api/admin/tariffs", admin_tariffs_save_route) + router.add_get("/api/admin/panel/internal-squads", admin_panel_internal_squads_route) diff --git a/bot/app/web/frontend/src/App.svelte b/bot/app/web/frontend/src/App.svelte index 699ad9d..8de8824 100644 --- a/bot/app/web/frontend/src/App.svelte +++ b/bot/app/web/frontend/src/App.svelte @@ -127,6 +127,14 @@ traffic_limit: "100 GB", traffic_used_bytes: 19756849561, traffic_limit_bytes: 107374182400, + premium_used: "32.0 GB", + premium_limit: "50.0 GB", + premium_used_bytes: 34359738368, + premium_limit_bytes: 53687091200, + premium_baseline_bytes: 53687091200, + premium_topup_balance_bytes: 0, + premium_is_limited: false, + premium_node_labels: ["Premium NL-1", "Premium DE-1"], max_devices: 5, }, devices: { @@ -496,9 +504,16 @@ $: devicesEnabled = Boolean(appSettings?.my_devices_enabled); $: subscription = data?.subscription || DEV_MOCK.data.subscription; $: hasActiveTariffSubscription = Boolean(tariffMode && subscription?.active && subscription?.tariff_key); + $: canChangeTariff = Boolean(hasActiveTariffSubscription && hasMultipleTariffs); $: currentTariffName = activeTariffName(subscription, plans); + $: canOpenTopupModal = Boolean( + hasActiveTariffSubscription && + subscription?.can_topup_traffic && + (Number(subscription?.traffic_limit_bytes || 0) > 0 || Number(subscription?.premium_limit_bytes || 0) > 0), + ); $: canShowTopupButton = Boolean( - hasActiveTariffSubscription && Number(subscription?.traffic_limit_bytes || 0) > 0 && trafficPercent(subscription) >= 85, + canOpenTopupModal && + (trafficPercent(subscription) >= 85 || premiumTrafficPercent(subscription) >= 85), ); $: user = data?.user || {}; $: isAdmin = Boolean(user?.is_admin); @@ -1845,7 +1860,7 @@ months: selectedTopupPlan.months, traffic_gb: selectedTopupPlan.traffic_gb, tariff_key: selectedTopupPlan.tariff_key || topupOptions?.tariff_key, - sale_mode: "topup", + sale_mode: selectedTopupPlan.sale_mode || "topup", method: selectedMethod, }), }); @@ -2231,6 +2246,7 @@ } function openTopupModal() { + if (!canOpenTopupModal) return; topupModalOpen = true; loadTopupOptions(); } @@ -2313,6 +2329,11 @@ return `${formatted} GB`; } + function formatTrafficBytes(value) { + const gb = Number(value || 0) / 1073741824; + return formatTrafficGb(gb); + } + function planKey(plan) { return plan?.id || `${plan?.tariff_key || "legacy"}:${plan?.sale_mode || "subscription"}:${plan?.months || plan?.traffic_gb || ""}`; } @@ -2421,6 +2442,33 @@ return t("wa_traffic_reset_policy"); } + function premiumTrafficPercent(sub) { + const used = Number(sub?.premium_used_bytes || 0); + const limit = Number(sub?.premium_limit_bytes || 0); + if (!limit || limit <= 0) return 0; + return Math.max(0, Math.min(100, Math.round((used / limit) * 100))); + } + + function premiumTrafficLabel(sub) { + return t("wa_traffic_of", { used: sub?.premium_used || "0 GB", limit: sub?.premium_limit || "0 GB" }); + } + + function premiumTrafficLeftLabel(sub) { + const left = Math.max(0, Number(sub?.premium_limit_bytes || 0) - Number(sub?.premium_used_bytes || 0)); + return formatTrafficBytes(left); + } + + function premiumTopupBalanceLabel(sub) { + return formatTrafficBytes(Number(sub?.premium_topup_balance_bytes || 0)); + } + + function premiumServerLabels(sub) { + const labels = Array.isArray(sub?.premium_node_labels) && sub.premium_node_labels.length + ? sub.premium_node_labels + : sub?.premium_squad_labels || []; + return labels.map((label) => String(label || "").trim()).filter(Boolean); + } + function planDisplayTitle(plan) { if (plan?.tariff_key) { return plan?.tariff_name || plan?.title || plan?.tariff_key; @@ -2438,14 +2486,14 @@ function planSubtitle(plan) { if (!plan?.tariff_key) return ""; if (plan?.subtitle) return plan.subtitle; - if (plan?.sale_mode === "traffic_package" || plan?.sale_mode === "topup" || plan?.billing_model === "traffic") { + if (plan?.sale_mode === "traffic_package" || plan?.sale_mode === "topup" || plan?.sale_mode === "premium_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" || plan?.sale_mode === "topup") { + if (trafficMode || plan?.sale_mode === "traffic" || plan?.sale_mode === "traffic_package" || plan?.sale_mode === "topup" || plan?.sale_mode === "premium_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) { @@ -2537,6 +2585,34 @@ return t("wa_topup_warning_levels", { levels }); } + function topupModalDescription() { + if (!topupOptions) return ""; + if (singleTariffMode) return ""; + return topupOptions?.tariff_name ? t("wa_topup_for_tariff", { tariff: topupOptions.tariff_name }) : ""; + } + + function topupCarryoverNotes() { + const plans = topupOptions?.plans || []; + if (!plans.length) return []; + return [ + t( + "wa_topup_carryover", + {}, + "Докупленный трафик не сгорает: сначала расходуется месячный лимит, затем докупленный остаток." + ), + ]; + } + + function deviceTopupModalDescription() { + if (!deviceTopupOptions) return ""; + return deviceTopupOptions?.tariff_name ? t("wa_device_topup_for_tariff", { tariff: deviceTopupOptions.tariff_name }) : ""; + } + + function tariffChangeModalDescription() { + if (!changeOptions) return ""; + return changeOptions?.current ? t("wa_current_tariff", { tariff: changeOptions.current.title }) : ""; + } + function _formatMonthsForClient(value) { const months = Number(value || 0); if (months === 1) return currentLang === "en" ? "1 month" : "1 месяц"; @@ -2913,7 +2989,10 @@ {#if subscription.active} - + + {#if canOpenTopupModal} + + {/if}
{t("wa_home_traffic_used")} {trafficLabel(subscription)} @@ -2926,6 +3005,44 @@ {trafficPercent(subscription)}%
+ {#if Number(subscription?.premium_limit_bytes || 0) > 0} + + {#if canOpenTopupModal} + + {/if} +
+ {t("wa_premium_traffic_title", {}, "Premium-серверы")} + {premiumTrafficLabel(subscription)} +
+
+ +
+
+ {subscription?.premium_is_limited ? t("wa_premium_access_limited", {}, "Доступ к premium временно ограничен") : t("wa_premium_reset_monthly", {}, "Отдельный лимит на месяц")} + {premiumTrafficPercent(subscription)}% +
+
+ + {t("wa_premium_left", {}, "Осталось")} + {premiumTrafficLeftLabel(subscription)} + + + {t("wa_premium_topup_balance", {}, "Докупленный остаток")} + {premiumTopupBalanceLabel(subscription)} + +
+ {#if premiumServerLabels(subscription).length} +
+ {t("wa_premium_servers_limited", {}, "Отдельный лимит действует на")} +
+ {#each premiumServerLabels(subscription).slice(0, 8) as label} + {label} + {/each} +
+
+ {/if} +
+ {/if} {:else if appSettings?.trial_enabled && appSettings?.trial_available}
@@ -2959,7 +3076,7 @@ {t("wa_activate_trial")} {/if} - {#if hasActiveTariffSubscription} + {#if canChangeTariff} -
{/if}
@@ -1921,6 +1985,7 @@ Основное Цены Докупки + Premium Устройства @@ -1992,11 +2057,40 @@
- - Internal Squads UUID - Один UUID на строку или через запятую - - +
+ Основные Internal Squads + {panelSquadsLoading ? "Загружаю список из панели…" : "Выберите сквады из Remnawave"} + { + addSquadToDraft("squadUuids", value); + selectedBaseSquad = ""; + }} + > + + Добавить сквад + + + + + {#each panelSquads as squad} + + {squad.name} + + + {/each} + + + +
+ {#each normalizeUuidList(tariffDraft.squadUuids) as uuid} + + {/each} +
+
@@ -2020,6 +2114,87 @@
+ +
+
+ Premium-сквад и отдельный лимит +
+
+
+ Premium Internal Squads + Ноды для учета трафика будут взяты из accessible nodes этих сквадов + { + addSquadToDraft("premiumSquadUuids", value); + selectedPremiumSquad = ""; + }} + > + + Добавить premium-сквад + + + + + {#each panelSquads as squad} + + {squad.name} + + + {/each} + + + +
+ {#each normalizeUuidList(tariffDraft.premiumSquadUuids) as uuid} + + {/each} +
+
+ + Premium лимит, GB/мес. + 0 или пусто — нет отдельного premium-лимита + + +
+
+ +
+
+ Докупка premium-трафика +
+ + +
+
+
+
+ RUB + {#each tariffDraft.premiumTopupRubRows as row, index} +
+ + + +
+ {/each} +
+
+ Stars + {#each tariffDraft.premiumTopupStarsRows as row, index} +
+ + + +
+ {/each} +
+
+
+
+ {#if tariffDraft.billing_model === "period"}
diff --git a/bot/app/web/frontend/src/styles.css b/bot/app/web/frontend/src/styles.css index 1fe81dc..d76f789 100644 --- a/bot/app/web/frontend/src/styles.css +++ b/bot/app/web/frontend/src/styles.css @@ -388,6 +388,107 @@ a { font-size: 11px; } +.traffic-card-clickable { + cursor: pointer; +} + +.card-click-target { + position: absolute; + z-index: 2; + inset: 0; + width: 100%; + border: 0; + background: transparent; + cursor: pointer; + -webkit-tap-highlight-color: transparent; +} + +.card-click-target:focus-visible { + outline: 2px solid var(--accent); + outline-offset: -3px; +} + +.premium-traffic-card { + display: grid; + gap: 10px; +} + +.premium-traffic-card-limited { + border-color: rgba(255, 193, 7, 0.34); +} + +.premium-progress span { + background: linear-gradient(90deg, #38bdf8, var(--accent)); +} + +.premium-detail-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 8px; +} + +.premium-detail-grid span { + display: grid; + gap: 3px; + min-width: 0; +} + +.premium-detail-grid small, +.premium-server-list small, +.topup-summary-card small { + color: var(--muted); + font-size: 11px; + line-height: 1.3; +} + +.premium-detail-grid strong { + font-size: 12px; + line-height: 1.2; +} + +.premium-server-list { + display: grid; + gap: 6px; +} + +.premium-server-list > div { + display: flex; + flex-wrap: wrap; + gap: 6px; +} + +.premium-server-list span { + max-width: 100%; + padding: 4px 8px; + overflow: hidden; + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 999px; + color: var(--text); + font-size: 11px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.topup-summary-card { + display: grid; + gap: 10px; + padding: 12px; +} + +.topup-summary-card > div:not(.premium-server-list) { + display: grid; + gap: 3px; +} + +.topup-summary-card strong { + font-size: 12px; + line-height: 1.2; +} + +.premium-server-list-modal { + margin-top: 0; +} + .trial-card { padding: 13px 14px; } @@ -828,6 +929,102 @@ a { color: var(--accent); } +.dialog-skeleton { + display: grid; + gap: 12px; +} + +.topup-carryover-note { + display: grid; + gap: 6px; + padding: 10px 12px; + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: var(--radius); + background: rgba(255, 255, 255, 0.035); +} + +.topup-carryover-note p { + margin: 0; + color: var(--muted); + font-size: 11px; + line-height: 1.35; +} + +.skeleton-row, +.skeleton-method, +.skeleton-pay-button { + pointer-events: none; +} + +.skeleton-line, +.skeleton-dot, +.skeleton-pay-button { + display: block; + overflow: hidden; + border-radius: 999px; + background: + linear-gradient( + 90deg, + rgba(255, 255, 255, 0.07) 0%, + rgba(255, 255, 255, 0.14) 42%, + rgba(255, 255, 255, 0.07) 84% + ); + background-size: 220% 100%; + animation: skeleton-shimmer 1.15s ease-in-out infinite; +} + +.skeleton-line-title { + width: 118px; + height: 13px; +} + +.skeleton-line-short { + width: 76px; + height: 10px; + opacity: 0.75; +} + +.skeleton-line-price { + width: 58px; + height: 12px; +} + +.skeleton-line-tiny { + width: 34px; + height: 9px; + opacity: 0.7; +} + +.skeleton-line-method { + width: 72px; + height: 12px; +} + +.skeleton-dot { + width: 18px; + height: 18px; + flex: 0 0 auto; +} + +.skeleton-method { + gap: 8px; +} + +.skeleton-pay-button { + width: 100%; + height: 46px; + border-radius: var(--radius); +} + +@keyframes skeleton-shimmer { + 0% { + background-position: 120% 0; + } + 100% { + background-position: -120% 0; + } +} + .back-inline { display: inline-flex; width: fit-content; @@ -3960,12 +4157,12 @@ a { .settings-admin-block { display: grid; gap: 10px; - margin: 6px 0 10px; + margin: 6px 0 0; } .settings-row.settings-row-admin { - border: 1px solid color-mix(in srgb, var(--accent) 36%, transparent); - background: color-mix(in srgb, var(--accent) 9%, transparent); + border: 1px solid color-mix(in srgb, #f59e0b 42%, transparent); + background: color-mix(in srgb, #f59e0b 12%, transparent); border-radius: var(--radius); padding: 10px 12px; width: 100%; @@ -3974,12 +4171,12 @@ a { } .settings-row.settings-row-admin:hover { - background: color-mix(in srgb, var(--accent) 16%, transparent); - border-color: color-mix(in srgb, var(--accent) 50%, transparent); + background: color-mix(in srgb, #f59e0b 18%, transparent); + border-color: color-mix(in srgb, #f59e0b 58%, transparent); } .settings-row.settings-row-admin > svg:first-child { - color: var(--accent); + color: #fbbf24; opacity: 1; } @@ -4190,6 +4387,33 @@ a { opacity: 1; } +.admin-chip-list { + display: flex; + flex-wrap: wrap; + gap: 6px; + min-height: 0; +} + +.admin-chip { + display: inline-flex; + align-items: center; + gap: 6px; + min-height: 28px; + max-width: 100%; + padding: 5px 8px; + border: 1px solid var(--admin-border); + border-radius: 8px; + background: var(--admin-surface-2); + color: var(--admin-text); + font: inherit; + font-size: 12px; + cursor: pointer; +} + +.admin-chip:hover { + border-color: var(--admin-border-strong); +} + /* Label primitive (bits-ui Label.Root) */ .admin-field-label { display: flex; diff --git a/bot/app/web/subscription_webapp.py b/bot/app/web/subscription_webapp.py index 0de71f4..96706dd 100644 --- a/bot/app/web/subscription_webapp.py +++ b/bot/app/web/subscription_webapp.py @@ -1744,7 +1744,7 @@ async def create_payment_route(request: web.Request) -> web.Response: return _json_error(400, "invalid_plan", "Stars price is not configured") payment_units = device_count sale_mode = f"hwid_devices@{tariff.key}" - elif tariffs_config and requested_sale_mode == "topup": + elif tariffs_config and requested_sale_mode in {"topup", "premium_topup"}: tariff_key = str(payment_payload.tariff_key or "").strip() if not tariff_key: return _json_error(400, "invalid_plan", "Tariff is not selected") @@ -1760,7 +1760,11 @@ async def create_payment_route(request: web.Request) -> web.Response: ) except (TypeError, ValueError): return _json_error(400, "invalid_plan", "Invalid traffic package") - packages = tariffs_config.topup_packages_for(tariff) + packages = ( + tariff.premium_topup_packages + if requested_sale_mode == "premium_topup" + else 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) @@ -1773,7 +1777,7 @@ async def create_payment_route(request: web.Request) -> web.Response: 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}" + sale_mode = f"{requested_sale_mode}@{tariff.key}" elif tariffs_config: tariff_key = str(payment_payload.tariff_key or "").strip() if not tariff_key: @@ -1980,14 +1984,40 @@ async def tariff_topup_options_route(request: web.Request) -> web.Response: 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) + premium_plans = _serialize_topup_packages( + settings, + tariff, + tariff.premium_topup_packages, + lang, + sale_mode="premium_topup", + title_prefix="Premium ", + ) if tariff.premium_squad_uuids else [] + premium_limit_bytes = ( + int(sub.premium_baseline_bytes or 0) + + int(sub.premium_topup_balance_bytes or 0) + + int(getattr(sub, "premium_topup_used_bytes", 0) or 0) + ) + premium_access = await request.app["subscription_service"].premium_access_for_tariff(tariff) 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), + "premium_traffic_percent": _traffic_percent( + sub.premium_used_bytes, + premium_limit_bytes, + ), + "premium_limit_bytes": premium_limit_bytes, + "premium_used_bytes": int(sub.premium_used_bytes or 0), + "premium_baseline_bytes": int(sub.premium_baseline_bytes or 0), + "premium_topup_balance_bytes": int(sub.premium_topup_balance_bytes or 0), + "premium_topup_used_bytes": int(getattr(sub, "premium_topup_used_bytes", 0) or 0), + "premium_is_limited": bool(sub.premium_is_limited), + "premium_squad_labels": premium_access.get("squad_labels") or [], + "premium_node_labels": premium_access.get("node_labels") or [], "warning_levels": settings.tariff_traffic_warning_levels, - "plans": plans, + "plans": plans + premium_plans, } ) @@ -2971,7 +3001,7 @@ async def _build_user_payload(request: web.Request, user_id: int) -> Dict[str, A "language_code": lang, "is_admin": is_admin, }, - "subscription": _serialize_subscription(active, local_sub, lang), + "subscription": _serialize_subscription(settings, active, local_sub, lang), "referral": { "code": referral_code, "bot_link": referral_link, @@ -3052,6 +3082,7 @@ def _build_webapp_referral_link( def _serialize_subscription( + settings: Settings, active: Optional[Dict[str, Any]], local_sub: Optional[Any], lang: str, @@ -3077,6 +3108,18 @@ def _serialize_subscription( int((end_date - datetime.now(timezone.utc)).total_seconds()), ) + can_topup_traffic = False + if settings.tariffs_config and active.get("tariff_key"): + try: + tariff = settings.tariffs_config.require(str(active.get("tariff_key"))) + packages = settings.tariffs_config.topup_packages_for(tariff) + can_topup_traffic = bool( + (packages and packages.has_any()) + or (tariff.premium_topup_packages and tariff.premium_topup_packages.has_any()) + ) + except Exception: + can_topup_traffic = False + return { "active": seconds_left > 0, "status": active.get("status_from_panel") or "UNKNOWN", @@ -3097,6 +3140,17 @@ def _serialize_subscription( "traffic_limit_strategy": str(active.get("traffic_limit_strategy") or ""), "tier_baseline_bytes": _coerce_int_or_none(active.get("tier_baseline_bytes")), "topup_balance_bytes": _coerce_int_or_none(active.get("topup_balance_bytes")), + "premium_limit": _format_bytes(active.get("premium_limit_bytes")), + "premium_used": _format_bytes(active.get("premium_used_bytes")), + "premium_limit_bytes": _coerce_int_or_none(active.get("premium_limit_bytes")), + "premium_used_bytes": _coerce_int_or_none(active.get("premium_used_bytes")), + "premium_baseline_bytes": _coerce_int_or_none(active.get("premium_baseline_bytes")), + "premium_topup_balance_bytes": _coerce_int_or_none(active.get("premium_topup_balance_bytes")), + "premium_topup_used_bytes": _coerce_int_or_none(active.get("premium_topup_used_bytes")), + "premium_is_limited": bool(active.get("premium_is_limited")), + "premium_squad_labels": list(active.get("premium_squad_labels") or []), + "premium_node_labels": list(active.get("premium_node_labels") or []), + "can_topup_traffic": can_topup_traffic, "period_start_at": active.get("period_start_at").isoformat() if active.get("period_start_at") else None, "is_throttled": bool(active.get("is_throttled")), "max_devices": _coerce_int_or_none(active.get("max_devices")), @@ -3242,6 +3296,9 @@ def _serialize_topup_packages( tariff: Any, packages: Optional[Any], lang: str, + *, + sale_mode: str = "topup", + title_prefix: 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 [])} @@ -3253,17 +3310,17 @@ def _serialize_topup_packages( continue traffic_value = float(traffic_gb) plan: Dict[str, Any] = { - "id": f"{tariff.key}:topup:{_format_number_for_payload(traffic_value)}", + "id": f"{tariff.key}:{sale_mode}:{_format_number_for_payload(traffic_value)}", "tariff_key": tariff.key, "tariff_name": tariff.name(lang), "billing_model": tariff.billing_model, - "sale_mode": "topup", + "sale_mode": sale_mode, "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), + "title": f"{title_prefix}{_format_traffic_title(traffic_value, lang)}", + "subtitle": ("Premium-серверы" if lang == "ru" else "Premium servers") if sale_mode == "premium_topup" else tariff.name(lang), } if stars_price is not None and int(stars_price) > 0: plan["stars_price"] = int(stars_price) @@ -3396,19 +3453,19 @@ def _serialize_payment_methods( methods: List[Dict[str, Any]] = [] for method in settings.payment_methods_order: method = method.lower() - if method == "severpay" and _service_configured(app, "severpay_service"): + if method == "severpay" and settings.SEVERPAY_ENABLED and _service_configured(app, "severpay_service"): methods.append({"id": method, "name": labels[method]}) - elif method == "freekassa" and _service_configured(app, "freekassa_service"): + elif method == "freekassa" and settings.FREEKASSA_ENABLED and _service_configured(app, "freekassa_service"): methods.append({"id": method, "name": labels[method]}) - elif method == "platega_sbp" and settings.PLATEGA_SBP_ENABLED and _service_configured(app, "platega_service"): + elif method == "platega_sbp" and settings.PLATEGA_ENABLED and settings.PLATEGA_SBP_ENABLED and _service_configured(app, "platega_service"): methods.append({"id": method, "name": labels[method]}) - elif method == "platega_crypto" and settings.PLATEGA_CRYPTO_ENABLED and _service_configured(app, "platega_service"): + elif method == "platega_crypto" and settings.PLATEGA_ENABLED and settings.PLATEGA_CRYPTO_ENABLED and _service_configured(app, "platega_service"): methods.append({"id": method, "name": labels[method]}) - elif method == "yookassa" and _service_configured(app, "yookassa_service"): + elif method == "yookassa" and settings.YOOKASSA_ENABLED and _service_configured(app, "yookassa_service"): methods.append({"id": method, "name": labels[method]}) elif method == "stars" and settings.STARS_ENABLED: methods.append({"id": method, "name": labels[method]}) - elif method == "cryptopay" and _service_configured(app, "cryptopay_service"): + elif method == "cryptopay" and settings.CRYPTOPAY_ENABLED and _service_configured(app, "cryptopay_service"): methods.append({"id": method, "name": labels[method]}) return methods @@ -3429,7 +3486,7 @@ def _sale_mode_tariff_key(sale_mode: str) -> Optional[str]: def _sale_mode_is_traffic(sale_mode: str) -> bool: - return _sale_mode_base(sale_mode) in {"traffic", "traffic_package", "topup"} + return _sale_mode_base(sale_mode) in {"traffic", "traffic_package", "topup", "premium_topup"} def _sale_mode_is_hwid_devices(sale_mode: str) -> bool: @@ -3462,24 +3519,36 @@ async def _create_subscription_payment( ) if method == "yookassa": + if not settings.YOOKASSA_ENABLED: + return _json_error(400, "payment_unavailable", "Payment method unavailable") return await _create_yookassa_payment( request, session, user_id, months, price, description, sale_mode=sale_mode, traffic_gb=traffic_gb ) if method == "freekassa": + if not settings.FREEKASSA_ENABLED: + return _json_error(400, "payment_unavailable", "Payment method unavailable") return await _create_freekassa_payment( request, session, user_id, months, price, description, sale_mode=sale_mode, traffic_gb=traffic_gb ) if method in ("platega", "platega_sbp", "platega_crypto"): + if not settings.PLATEGA_ENABLED: + return _json_error(400, "payment_unavailable", "Payment method unavailable") + if method == "platega_sbp" and not settings.PLATEGA_SBP_ENABLED: + return _json_error(400, "payment_unavailable", "Payment method unavailable") + if method == "platega_crypto" and not settings.PLATEGA_CRYPTO_ENABLED: + return _json_error(400, "payment_unavailable", "Payment method unavailable") return await _create_platega_payment( request, session, user_id, months, price, description, variant=method, sale_mode=sale_mode, traffic_gb=traffic_gb ) if method == "severpay": + if not settings.SEVERPAY_ENABLED: + return _json_error(400, "payment_unavailable", "Payment method unavailable") return await _create_severpay_payment( request, session, user_id, months, price, description, sale_mode=sale_mode, traffic_gb=traffic_gb ) if method == "cryptopay": service: CryptoPayService = request.app["cryptopay_service"] - if not service or not service.configured: + if not settings.CRYPTOPAY_ENABLED or not service or not service.configured: return _json_error(400, "payment_unavailable", "Payment method unavailable") url = await service.create_invoice( session=session, diff --git a/bot/handlers/admin/payments.py b/bot/handlers/admin/payments.py index 7f25e8a..de1c647 100644 --- a/bot/handlers/admin/payments.py +++ b/bot/handlers/admin/payments.py @@ -1,4 +1,4 @@ -import logging +import logging import csv import io from aiogram import Router, F, types @@ -68,7 +68,7 @@ def format_payment_text(payment: Payment, i18n: JsonI18n, lang: str, settings: S }.get(payment.provider, payment.provider or 'Unknown') sale_base = (payment.sale_mode or "").split("@", 1)[0].split("|", 1)[0] - traffic_like = sale_base in {"traffic", "traffic_package", "topup"} + traffic_like = sale_base in {"traffic", "traffic_package", "topup", "premium_topup"} if traffic_like: traffic_val = payment.purchased_gb or payment.subscription_duration_months or 0 traffic_display = str(int(traffic_val)) if float(traffic_val).is_integer() else f"{traffic_val:g}" diff --git a/bot/handlers/user/payment.py b/bot/handlers/user/payment.py index cf73021..b3ad551 100644 --- a/bot/handlers/user/payment.py +++ b/bot/handlers/user/payment.py @@ -1,4 +1,4 @@ -import logging +import logging import json import asyncio from datetime import datetime, timezone, timedelta @@ -238,7 +238,7 @@ async def process_successful_payment(session: AsyncSession, bot: Bot, promo_code_id_from_payment=promo_code_id, provider="yookassa", sale_mode=sale_mode, - traffic_gb=traffic_amount_gb if sale_mode_base in {"traffic", "traffic_package", "topup"} else None, + traffic_gb=traffic_amount_gb if sale_mode_base in {"traffic", "traffic_package", "topup", "premium_topup"} else None, ) if not activation_details or not activation_details.get('end_date'): diff --git a/bot/handlers/user/subscription/core.py b/bot/handlers/user/subscription/core.py index e9888db..a18d51c 100644 --- a/bot/handlers/user/subscription/core.py +++ b/bot/handlers/user/subscription/core.py @@ -1,8 +1,10 @@ import hashlib +import html import logging from aiogram import Router, F, types, Bot from aiogram.filters import Command from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup, WebAppInfo +from aiogram.utils.keyboard import InlineKeyboardBuilder from typing import Optional, Union from datetime import datetime from sqlalchemy.ext.asyncio import AsyncSession @@ -269,10 +271,80 @@ async def tariff_topup_list_callback(callback: types.CallbackQuery, i18n_data: d tariff = config.require(active["tariff_key"]) packages = config.topup_packages_for(tariff) rub_packages = packages.rub if packages else [] - if not rub_packages: + premium_packages = tariff.premium_topup_packages.rub if tariff.premium_topup_packages else [] + if not rub_packages and not premium_packages: await callback.answer(get_text("no_subscription_options_available"), show_alert=True) return - markup = get_tariff_packages_keyboard(tariff, rub_packages, current_lang, i18n, back_callback="main_action:my_subscription") + builder = InlineKeyboardBuilder() + currency = settings.DEFAULT_CURRENCY_SYMBOL + for package in rub_packages: + builder.row( + InlineKeyboardButton( + text=f"Обычный трафик +{package.gb:g} GB — {package.price:g} {currency}", + callback_data=f"tariff:package:{tariff.key}:{package.gb:g}", + ) + ) + for package in premium_packages: + builder.row( + InlineKeyboardButton( + text=f"Premium-серверы +{package.gb:g} GB — {package.price:g} {currency}", + callback_data=f"tariff:premium_package:{tariff.key}:{package.gb:g}", + ) + ) + builder.row(InlineKeyboardButton(text=get_text("back_to_main_menu_button"), callback_data="main_action:my_subscription")) + + premium_lines = [] + carryover_lines = [] + if rub_packages or premium_packages: + carryover_lines.append("Докупленный трафик не сгорает: сначала расходуется месячный лимит, затем докупленный остаток.") + if int(active.get("premium_limit_bytes") or 0) > 0: + premium_left = max(0, int(active.get("premium_limit_bytes") or 0) - int(active.get("premium_used_bytes") or 0)) + labels = active.get("premium_node_labels") or active.get("premium_squad_labels") or [] + if labels: + visible = [str(label) for label in labels[:8]] + premium_lines.append("Premium-лимит действует на:") + premium_lines.extend(f"• {label}" for label in visible) + if len(labels) > len(visible): + premium_lines.append(f"• ... еще {len(labels) - len(visible)}") + premium_lines.append( + f"Premium использовано: {active.get('premium_used')} из {active.get('premium_limit')}. Осталось: {premium_left / 2**30:.2f} GB." + ) + text = get_text("choose_payment_method_traffic") + if carryover_lines: + text = text + "\n\n" + "\n".join(carryover_lines) + if premium_lines: + text = text + "\n\n" + "\n".join(premium_lines) + await callback.message.edit_text(text, reply_markup=builder.as_markup()) + await callback.answer() + + +@router.callback_query(F.data.startswith("tariff:premium_package:")) +async def select_tariff_premium_package_callback(callback: types.CallbackQuery, i18n_data: dict, settings: Settings, session: AsyncSession): + current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE) + i18n: JsonI18n = i18n_data.get("i18n_instance") + get_text = lambda key, **kw: i18n.gettext(current_lang, key, **kw) + config = settings.tariffs_config + if not config or not callback.message: + await callback.answer(get_text("error_occurred_try_again"), show_alert=True) + return + _, _, tariff_key, gb_raw = callback.data.split(":", 3) + tariff = config.require(tariff_key) + gb = float(gb_raw) + packages = tariff.premium_topup_packages.rub if tariff.premium_topup_packages else [] + package = next((pkg for pkg in packages if float(pkg.gb) == gb), None) + if not package: + await callback.answer(get_text("error_try_again"), show_alert=True) + return + markup = get_payment_method_keyboard( + gb, + package.price, + None, + settings.DEFAULT_CURRENCY_SYMBOL, + current_lang, + i18n, + settings, + sale_mode=f"premium_topup@{tariff.key}", + ) await callback.message.edit_text(get_text("choose_payment_method_traffic"), reply_markup=markup) await callback.answer() @@ -350,6 +422,9 @@ async def tariff_change_list_callback(callback: types.CallbackQuery, i18n_data: if not config or not active or not callback.message: await callback.answer("Error", show_alert=True) return + if len(config.enabled_tariffs) <= 1: + await callback.answer("Смена тарифа недоступна: сейчас включен только один тариф.", show_alert=True) + return rows = [] for tariff in config.enabled_tariffs: if tariff.key == active.get("tariff_key"): @@ -614,6 +689,31 @@ async def my_subscription_command_handler( if tariff_prefix: text = tariff_prefix + "\n" + text + if int(active.get("premium_limit_bytes") or 0) > 0: + premium_limit = int(active.get("premium_limit_bytes") or 0) + premium_used = int(active.get("premium_used_bytes") or 0) + premium_left = max(0, premium_limit - premium_used) + premium_balance = int(active.get("premium_topup_balance_bytes") or 0) + premium_status = "ограничен" if active.get("premium_is_limited") else "активен" + labels = active.get("premium_node_labels") or active.get("premium_squad_labels") or [] + if labels: + visible = [html.escape(str(label)) for label in labels[:8]] + label_block = "\n".join(f"• {label}" for label in visible) + if len(labels) > len(visible): + label_block += f"\n• ... еще {len(labels) - len(visible)}" + else: + label_block = "• premium-серверы тарифа" + text += ( + "\n\n🚀 Premium-серверы\n" + f"Статус: {premium_status}\n" + f"Лимит: {active.get('premium_used')} из {active.get('premium_limit')}\n" + f"Осталось: {premium_left / 2**30:.2f} GB\n" + f"Докупленный остаток: {premium_balance / 2**30:.2f} GB\n" + "Отдельный лимит действует на:\n" + f"{label_block}\n\n" + "Premium-докупка не сгорает: сначала расходуется месячный лимит premium-серверов, затем докупленный premium-трафик." + ) + base_markup = get_back_to_main_menu_markup( current_lang, i18n, @@ -727,8 +827,19 @@ async def my_subscription_command_handler( tariff_actions = [] if _has_multiple_enabled_tariffs(settings): tariff_actions.append(InlineKeyboardButton(text="Сменить тариф", callback_data="tariff_change:list")) - tariff_actions.append(InlineKeyboardButton(text="Докупить трафик", callback_data="tariff_topup:list")) - prepend_rows.append(tariff_actions) + try: + tariff = settings.tariffs_config.require(local_sub.tariff_key) + topup_packages = settings.tariffs_config.topup_packages_for(tariff) + has_topup_packages = bool( + (topup_packages and topup_packages.has_any()) + or (tariff.premium_topup_packages and tariff.premium_topup_packages.has_any()) + ) + except Exception: + has_topup_packages = False + if has_topup_packages: + tariff_actions.append(InlineKeyboardButton(text="Докупить трафик", callback_data="tariff_topup:list")) + if tariff_actions: + prepend_rows.append(tariff_actions) if prepend_rows: kb = prepend_rows + kb diff --git a/bot/handlers/user/subscription/payments_crypto.py b/bot/handlers/user/subscription/payments_crypto.py index 2449af7..62412b4 100644 --- a/bot/handlers/user/subscription/payments_crypto.py +++ b/bot/handlers/user/subscription/payments_crypto.py @@ -1,4 +1,4 @@ -from typing import Optional +from typing import Optional from aiogram import F, Router, types from sqlalchemy.ext.asyncio import AsyncSession @@ -30,7 +30,7 @@ async def pay_crypto_callback_handler( pass return - if not cryptopay_service or not getattr(cryptopay_service, "configured", False): + if not settings.CRYPTOPAY_ENABLED or not cryptopay_service or not getattr(cryptopay_service, "configured", False): try: await callback.answer(get_text("payment_service_unavailable_alert"), show_alert=True) except Exception: @@ -55,7 +55,7 @@ async def pay_crypto_callback_handler( sale_base = sale_mode.split("@", 1)[0].split("|", 1)[0] payment_description = ( get_text("payment_description_traffic", traffic_gb=human_value) - if sale_base in {"traffic", "traffic_package", "topup"} + if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"} else (get_text("payment_description_hwid_devices", count=int(months)) if sale_base in {"hwid_device", "hwid_devices"} else get_text("payment_description_subscription", months=int(months))) ) @@ -72,7 +72,7 @@ async def pay_crypto_callback_handler( try: await callback.message.edit_text( get_text( - key="payment_link_message_traffic" if sale_base in {"traffic", "traffic_package", "topup"} else "payment_link_message", + key="payment_link_message_traffic" if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"} else "payment_link_message", months=int(months), traffic_gb=human_value, ), @@ -89,7 +89,7 @@ async def pay_crypto_callback_handler( try: await callback.message.answer( get_text( - key="payment_link_message_traffic" if sale_base in {"traffic", "traffic_package", "topup"} else "payment_link_message", + key="payment_link_message_traffic" if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"} else "payment_link_message", months=int(months), traffic_gb=human_value, ), diff --git a/bot/handlers/user/subscription/payments_freekassa.py b/bot/handlers/user/subscription/payments_freekassa.py index 34766cf..5d349b3 100644 --- a/bot/handlers/user/subscription/payments_freekassa.py +++ b/bot/handlers/user/subscription/payments_freekassa.py @@ -1,4 +1,4 @@ -import logging +import logging from datetime import datetime from typing import Optional @@ -64,7 +64,7 @@ async def pay_fk_callback_handler( sale_base = sale_mode.split("@", 1)[0].split("|", 1)[0] payment_description = ( get_text("payment_description_traffic", traffic_gb=human_value) - if sale_base in {"traffic", "traffic_package", "topup"} + if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"} else (get_text("payment_description_hwid_devices", count=int(months)) if sale_base in {"hwid_device", "hwid_devices"} else get_text("payment_description_subscription", months=int(months))) ) currency_code = getattr(freekassa_service, "default_currency", None) or settings.DEFAULT_CURRENCY_SYMBOL or "RUB" @@ -79,7 +79,7 @@ async def pay_fk_callback_handler( "provider": "freekassa", "sale_mode": sale_mode, "tariff_key": sale_mode.split("@", 1)[1] if "@" in sale_mode else None, - "purchased_gb": float(months) if sale_base in {"traffic", "traffic_package", "topup"} 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", "hwid_devices"} else None, } diff --git a/bot/handlers/user/subscription/payments_platega.py b/bot/handlers/user/subscription/payments_platega.py index d39f720..3e134a3 100644 --- a/bot/handlers/user/subscription/payments_platega.py +++ b/bot/handlers/user/subscription/payments_platega.py @@ -1,4 +1,4 @@ -import json +import json import logging from typing import Optional @@ -91,7 +91,7 @@ async def pay_platega_callback_handler( sale_base = sale_mode.split("@", 1)[0].split("|", 1)[0] payment_description = ( get_text("payment_description_traffic", traffic_gb=human_value) - if sale_base in {"traffic", "traffic_package", "topup"} + if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"} else (get_text("payment_description_hwid_devices", count=int(months)) if sale_base in {"hwid_device", "hwid_devices"} else get_text("payment_description_subscription", months=int(months))) ) currency_code = settings.DEFAULT_CURRENCY_SYMBOL or "RUB" @@ -106,7 +106,7 @@ async def pay_platega_callback_handler( "provider": "platega", "sale_mode": sale_mode, "tariff_key": sale_mode.split("@", 1)[1] if "@" in sale_mode else None, - "purchased_gb": float(months) if sale_base in {"traffic", "traffic_package", "topup"} 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", "hwid_devices"} else None, } diff --git a/bot/handlers/user/subscription/payments_severpay.py b/bot/handlers/user/subscription/payments_severpay.py index f92ab9f..b924988 100644 --- a/bot/handlers/user/subscription/payments_severpay.py +++ b/bot/handlers/user/subscription/payments_severpay.py @@ -1,4 +1,4 @@ -import logging +import logging from typing import Optional from aiogram import F, Router, types @@ -63,7 +63,7 @@ async def pay_severpay_callback_handler( sale_base = sale_mode.split("@", 1)[0].split("|", 1)[0] payment_description = ( get_text("payment_description_traffic", traffic_gb=human_value) - if sale_base in {"traffic", "traffic_package", "topup"} + if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"} else (get_text("payment_description_hwid_devices", count=int(months)) if sale_base in {"hwid_device", "hwid_devices"} else get_text("payment_description_subscription", months=int(months))) ) currency_code = settings.DEFAULT_CURRENCY_SYMBOL or "RUB" @@ -78,7 +78,7 @@ async def pay_severpay_callback_handler( "provider": "severpay", "sale_mode": sale_mode, "tariff_key": sale_mode.split("@", 1)[1] if "@" in sale_mode else None, - "purchased_gb": float(months) if sale_base in {"traffic", "traffic_package", "topup"} 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", "hwid_devices"} else None, } diff --git a/bot/handlers/user/subscription/payments_stars.py b/bot/handlers/user/subscription/payments_stars.py index e9b5d31..b81b9ba 100644 --- a/bot/handlers/user/subscription/payments_stars.py +++ b/bot/handlers/user/subscription/payments_stars.py @@ -1,4 +1,4 @@ -import logging +import logging from typing import Optional from aiogram import F, Router, types @@ -56,7 +56,7 @@ async def pay_stars_callback_handler( sale_base = sale_mode.split("@", 1)[0].split("|", 1)[0] payment_description = ( get_text("payment_description_traffic", traffic_gb=human_value) - if sale_base in {"traffic", "traffic_package", "topup"} + if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"} else (get_text("payment_description_hwid_devices", count=int(months)) if sale_base in {"hwid_device", "hwid_devices"} else get_text("payment_description_subscription", months=int(months))) ) @@ -73,7 +73,7 @@ async def pay_stars_callback_handler( try: await callback.message.edit_text( get_text( - "payment_invoice_sent_message_traffic" if sale_base in {"traffic", "traffic_package", "topup"} else "payment_invoice_sent_message", + "payment_invoice_sent_message_traffic" if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"} else "payment_invoice_sent_message", months=int(months), traffic_gb=human_value, ), diff --git a/bot/handlers/user/subscription/payments_yookassa.py b/bot/handlers/user/subscription/payments_yookassa.py index 0405416..67f2879 100644 --- a/bot/handlers/user/subscription/payments_yookassa.py +++ b/bot/handlers/user/subscription/payments_yookassa.py @@ -1,4 +1,4 @@ -import logging +import logging from typing import List, Optional, Tuple from aiogram import F, Router, types @@ -84,7 +84,7 @@ async def _initiate_yk_payment( sale_base = _sale_mode_base(sale_mode) payment_description = ( get_text("payment_description_traffic", traffic_gb=_format_value(months)) - if sale_base in {"traffic", "traffic_package", "topup"} + if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"} else (get_text("payment_description_hwid_devices", count=int(months)) if sale_base in {"hwid_device", "hwid_devices"} else get_text("payment_description_subscription", months=int(months))) ) payment_record_data = { @@ -96,7 +96,7 @@ async def _initiate_yk_payment( "subscription_duration_months": int(months), "sale_mode": sale_base, "tariff_key": sale_mode.split("@", 1)[1] if "@" in sale_mode else None, - "purchased_gb": float(months) if sale_base in {"traffic", "traffic_package", "topup"} 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", "hwid_devices"} else None, } @@ -132,7 +132,7 @@ async def _initiate_yk_payment( "payment_db_id": str(db_payment_record.payment_id), "sale_mode": sale_mode, } - if sale_base in {"traffic", "traffic_package", "topup"}: + if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"}: yookassa_metadata["traffic_gb"] = str(months) if payment_method_id: yookassa_metadata["used_saved_payment_method_id"] = payment_method_id diff --git a/bot/services/crypto_pay_service.py b/bot/services/crypto_pay_service.py index 161af71..243571e 100644 --- a/bot/services/crypto_pay_service.py +++ b/bot/services/crypto_pay_service.py @@ -1,4 +1,4 @@ -import hashlib +import hashlib import logging import json import hmac @@ -91,7 +91,7 @@ class CryptoPayService: "provider": "cryptopay", "sale_mode": sale_mode, "tariff_key": sale_mode.split("@", 1)[1] if "@" in sale_mode else None, - "purchased_gb": float(months) if sale_base in {"traffic", "traffic_package", "topup"} else None, + "purchased_gb": float(months) if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"} else None, }, ) await session.commit() @@ -107,7 +107,7 @@ class CryptoPayService: "subscription_months": str(months), "payment_db_id": str(payment_record.payment_id), "sale_mode": sale_mode, - "traffic_gb": str(months) if sale_base in {"traffic", "traffic_package", "topup"} else None, + "traffic_gb": str(months) if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"} else None, }) try: invoice = await self.client.create_invoice( @@ -184,7 +184,7 @@ class CryptoPayService: payment_db_id, provider="cryptopay", sale_mode=sale_mode, - traffic_gb=traffic_gb if sale_base in {"traffic", "traffic_package", "topup"} else None, + traffic_gb=traffic_gb if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"} else None, ) referral_bonus = None if sale_base == "subscription": @@ -215,7 +215,7 @@ class CryptoPayService: final_end = referral_bonus["referee_new_end_date"] applied_days = referral_bonus.get("referee_bonus_applied_days", 0) - if sale_base in {"traffic", "traffic_package", "topup"}: + if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"}: text = _("payment_successful_traffic_full", traffic_gb=str(int(traffic_gb)) if float(traffic_gb).is_integer() else f"{traffic_gb:g}", end_date=final_end.strftime('%Y-%m-%d') if final_end else "—", @@ -271,7 +271,7 @@ class CryptoPayService: amount=float(invoice.amount), currency=invoice.asset or settings.DEFAULT_CURRENCY_SYMBOL, months=int(months) if sale_base == "subscription" else 0, - traffic_gb=traffic_gb if sale_base in {"traffic", "traffic_package", "topup"} else None, + traffic_gb=traffic_gb if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"} else None, payment_provider="crypto_pay", username=user.username if user else None ) diff --git a/bot/services/freekassa_service.py b/bot/services/freekassa_service.py index ccaaa93..675dd05 100644 --- a/bot/services/freekassa_service.py +++ b/bot/services/freekassa_service.py @@ -1,4 +1,4 @@ -import asyncio +import asyncio from datetime import datetime import hashlib import hmac @@ -286,7 +286,7 @@ class FreeKassaService: payment.payment_id, provider="freekassa", sale_mode=sale_mode, - traffic_gb=float(months) if sale_base in {"traffic", "traffic_package", "topup"} else None, + traffic_gb=float(months) if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"} else None, ) referral_bonus = None @@ -331,7 +331,7 @@ class FreeKassaService: traffic_label = str(int(months)) if float(months).is_integer() else f"{months:g}" - if sale_mode.split("@", 1)[0].split("|", 1)[0] in {"traffic", "traffic_package", "topup"}: + if sale_mode.split("@", 1)[0].split("|", 1)[0] in {"traffic", "traffic_package", "topup", "premium_topup"}: text = _("payment_successful_traffic_full", traffic_gb=traffic_label, end_date=end_date_str if final_end else "", diff --git a/bot/services/panel_api_service.py b/bot/services/panel_api_service.py index c00c37f..e862612 100644 --- a/bot/services/panel_api_service.py +++ b/bot/services/panel_api_service.py @@ -564,6 +564,76 @@ class PanelApiService: logging.error("Failed to get bandwidth stats for user %s. Response: %s", user_uuid, response_data) return None + async def get_node_users_bandwidth_stats( + self, + node_uuid: str, + *, + start: str, + end: str, + top_users_limit: int = 10000, + ) -> Optional[Dict[str, Any]]: + endpoint = f"/bandwidth-stats/nodes/{node_uuid}/users" + response_data = await self._request( + "GET", + endpoint, + params={"start": start, "end": end, "topUsersLimit": top_users_limit}, + log_full_response=False, + ) + if response_data and not response_data.get("error") and "response" in response_data: + response = response_data.get("response") + if isinstance(response, dict): + return response + if isinstance(response, list): + return {"topUsers": response} + logging.error( + "Failed to get node bandwidth stats for node %s. Response: %s", + node_uuid, + response_data, + ) + return None + + async def get_internal_squads(self) -> Optional[List[Dict[str, Any]]]: + response_data = await self._request("GET", "/internal-squads", log_full_response=False) + if response_data and not response_data.get("error") and "response" in response_data: + response = response_data.get("response") + if isinstance(response, list): + return response + if isinstance(response, dict): + for key in ("internalSquads", "squads", "items", "data"): + value = response.get(key) + if isinstance(value, list): + return value + logging.error("Failed to get internal squads. Response: %s", response_data) + return None + + async def get_internal_squad_accessible_nodes( + self, + squad_uuid: str, + ) -> Optional[List[Dict[str, Any]]]: + endpoints = ( + f"/internal-squads/{squad_uuid}/accessible-nodes", + f"/internal-squads/{squad_uuid}/nodes", + ) + last_response = None + for endpoint in endpoints: + response_data = await self._request("GET", endpoint, log_full_response=False) + last_response = response_data + if response_data and not response_data.get("error") and "response" in response_data: + response = response_data.get("response") + if isinstance(response, list): + return response + if isinstance(response, dict): + for key in ("nodes", "accessibleNodes", "items", "data"): + value = response.get(key) + if isinstance(value, list): + return value + logging.error( + "Failed to get accessible nodes for internal squad %s. Response: %s", + squad_uuid, + last_response, + ) + return None + async def reset_user_traffic(self, user_uuid: str) -> bool: endpoint = f"/users/{user_uuid}/actions/reset-traffic" response_data = await self._request("POST", endpoint, log_full_response=False) diff --git a/bot/services/platega_service.py b/bot/services/platega_service.py index 20ce3bf..6b6ef62 100644 --- a/bot/services/platega_service.py +++ b/bot/services/platega_service.py @@ -1,4 +1,4 @@ -import hmac +import hmac import json import logging from decimal import Decimal, ROUND_HALF_UP @@ -214,7 +214,7 @@ class PlategaService: payment.payment_id, provider="platega", sale_mode=sale_mode, - traffic_gb=float(payment_months) if sale_base in {"traffic", "traffic_package", "topup"} else None, + traffic_gb=float(payment_months) if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"} else None, ) referral_bonus = None @@ -250,7 +250,7 @@ class PlategaService: traffic_label = str(int(payment_months)) if float(payment_months).is_integer() else f"{payment_months:g}" - if sale_base in {"traffic", "traffic_package", "topup"}: + if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"}: text = _( "payment_successful_traffic_full", traffic_gb=traffic_label, diff --git a/bot/services/severpay_service.py b/bot/services/severpay_service.py index ac75a9e..be1ea75 100644 --- a/bot/services/severpay_service.py +++ b/bot/services/severpay_service.py @@ -1,4 +1,4 @@ -import json +import json import logging import secrets import hmac @@ -210,7 +210,7 @@ class SeverPayService: payment.payment_id, provider="severpay", sale_mode=sale_mode, - traffic_gb=float(payment_months) if sale_base in {"traffic", "traffic_package", "topup"} else None, + traffic_gb=float(payment_months) if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"} else None, ) referral_bonus = None @@ -246,7 +246,7 @@ class SeverPayService: traffic_label = str(int(payment_months)) if float(payment_months).is_integer() else f"{payment_months:g}" - if sale_base in {"traffic", "traffic_package", "topup"}: + if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"}: text = _( "payment_successful_traffic_full", traffic_gb=traffic_label, diff --git a/bot/services/stars_service.py b/bot/services/stars_service.py index 10e44fe..eff5142 100644 --- a/bot/services/stars_service.py +++ b/bot/services/stars_service.py @@ -1,4 +1,4 @@ -import logging +import logging from typing import Optional from aiogram import Bot, types @@ -39,7 +39,7 @@ class StarsService: "provider": "telegram_stars", "sale_mode": sale_mode, "tariff_key": sale_mode.split("@", 1)[1] if "@" in sale_mode else None, - "purchased_gb": float(months) if sale_base in {"traffic", "traffic_package", "topup"} else None, + "purchased_gb": float(months) if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"} else None, } try: db_payment_record = await payment_dal.create_payment_record( @@ -103,7 +103,7 @@ class StarsService: payment_db_id, provider="telegram_stars", sale_mode=sale_mode, - traffic_gb=months if sale_base in {"traffic", "traffic_package", "topup"} else None, + traffic_gb=months if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"} else None, ) if not activation_details or not activation_details.get("end_date"): logging.error( @@ -136,7 +136,7 @@ class StarsService: config_link_display, connect_button_url = await prepare_config_links(self.settings, raw_config_link) config_link_text = config_link_display or _("config_link_not_available") - if sale_base in {"traffic", "traffic_package", "topup"}: + if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"}: success_msg = _( "payment_successful_traffic_full", traffic_gb=str(int(months)) if float(months).is_integer() else f"{months:g}", @@ -201,7 +201,7 @@ class StarsService: months=int(months) if sale_base == "subscription" else 0, payment_provider="stars", username=user.username if user else None, - traffic_gb=months if sale_base in {"traffic", "traffic_package", "topup"} else None, + traffic_gb=months if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"} else None, ) except Exception as e: logging.error(f"Failed to send stars payment notification: {e}") diff --git a/bot/services/subscription_service.py b/bot/services/subscription_service.py index 97c5b9c..4789f29 100644 --- a/bot/services/subscription_service.py +++ b/bot/services/subscription_service.py @@ -8,7 +8,7 @@ from bot.middlewares.i18n import JsonI18n from db.dal import user_dal, subscription_dal, promo_code_dal, payment_dal, user_billing_dal, tariff_dal from config.tariffs_config import Tariff -from bot.utils.date_utils import add_months +from bot.utils.date_utils import add_months, month_start from bot.utils.config_link import prepare_config_links from db.models import User, Subscription @@ -31,6 +31,7 @@ class SubscriptionService: self.panel_service = panel_service self.bot = bot self.i18n = i18n + self._premium_access_cache: Dict[Tuple[str, ...], Dict[str, Any]] = {} @staticmethod def gb_to_bytes(gb: float) -> int: @@ -71,9 +72,17 @@ class SubscriptionService: raise ValueError(f"Tariff {tariff.key} is {tariff.billing_model}, expected {billing_model}") return tariff - def _panel_squads_for_tariff(self, tariff: Optional[Tariff]) -> Optional[List[str]]: + def _panel_squads_for_tariff( + self, + tariff: Optional[Tariff], + *, + include_premium: bool = True, + ) -> Optional[List[str]]: if tariff: - return tariff.squad_uuids + squads = list(tariff.squad_uuids or []) + if include_premium: + squads.extend(tariff.premium_squad_uuids or []) + return list(dict.fromkeys(squads)) return self.settings.parsed_user_squad_uuids def _traffic_limit_for_period_tariff(self, tariff: Optional[Tariff], topup_balance_bytes: int = 0) -> int: @@ -81,6 +90,85 @@ class SubscriptionService: return int(tariff.monthly_bytes + max(0, topup_balance_bytes)) return self.settings.user_traffic_limit_bytes + def _premium_limit_for_tariff(self, tariff: Optional[Tariff], topup_balance_bytes: int = 0) -> int: + if not tariff: + return 0 + return int(tariff.premium_monthly_bytes + max(0, topup_balance_bytes)) + + @staticmethod + def _premium_effective_limit_bytes( + premium_baseline_bytes: int, + premium_topup_balance_bytes: int = 0, + premium_topup_used_bytes: int = 0, + ) -> int: + return int(premium_baseline_bytes or 0) + max(0, int(premium_topup_balance_bytes or 0)) + max( + 0, int(premium_topup_used_bytes or 0) + ) + + async def premium_access_for_tariff(self, tariff: Optional[Tariff]) -> Dict[str, Any]: + if not tariff or not tariff.premium_squad_uuids: + return {"squad_uuids": [], "squad_labels": [], "node_labels": []} + + cache_key = tuple(sorted(str(uuid) for uuid in tariff.premium_squad_uuids)) + now_ts = datetime.now(timezone.utc).timestamp() + cached = self._premium_access_cache.get(cache_key) + if cached and now_ts - float(cached.get("ts", 0)) < 600: + return { + "squad_uuids": list(cached.get("squad_uuids") or []), + "squad_labels": list(cached.get("squad_labels") or []), + "node_labels": list(cached.get("node_labels") or []), + } + + squad_name_map: Dict[str, str] = {} + try: + squads = await self.panel_service.get_internal_squads() or [] + for squad in squads: + if not isinstance(squad, dict): + continue + squad_uuid = str(squad.get("uuid") or squad.get("id") or "") + if not squad_uuid: + continue + squad_name_map[squad_uuid] = str(squad.get("name") or squad.get("title") or squad_uuid) + except Exception: + logging.debug("Failed to load internal squad names for premium display", exc_info=True) + + node_labels: List[str] = [] + for squad_uuid in tariff.premium_squad_uuids: + try: + nodes = await self.panel_service.get_internal_squad_accessible_nodes(squad_uuid) or [] + except Exception: + logging.debug("Failed to load accessible nodes for premium squad %s", squad_uuid, exc_info=True) + nodes = [] + for node in nodes: + if not isinstance(node, dict): + continue + node_uuid = str(node.get("uuid") or node.get("nodeUuid") or node.get("node_uuid") or "") + node_name = str(node.get("name") or node.get("address") or node.get("host") or "").strip() + if node_name: + label = node_name + elif node_uuid: + label = f"{node_uuid[:8]}..." + else: + continue + node_labels.append(label) + + squad_labels = [ + squad_name_map.get(str(uuid), f"{str(uuid)[:8]}...") + for uuid in tariff.premium_squad_uuids + ] + payload = { + "ts": now_ts, + "squad_uuids": list(tariff.premium_squad_uuids), + "squad_labels": list(dict.fromkeys(squad_labels)), + "node_labels": list(dict.fromkeys(node_labels)), + } + self._premium_access_cache[cache_key] = payload + return { + "squad_uuids": list(payload["squad_uuids"]), + "squad_labels": list(payload["squad_labels"]), + "node_labels": list(payload["node_labels"]), + } + def _base_hwid_limit_for_tariff(self, tariff: Optional[Tariff]) -> Optional[int]: if tariff and tariff.hwid_device_limit is not None: return int(tariff.hwid_device_limit) @@ -649,6 +737,12 @@ class SubscriptionService: "tariff_key": tariff.key if tariff else None, "tier_baseline_bytes": 0, "topup_balance_bytes": new_balance, + "premium_baseline_bytes": self._premium_limit_for_tariff(tariff, 0), + "premium_topup_balance_bytes": 0, + "premium_topup_used_bytes": 0, + "premium_used_bytes": 0, + "premium_is_limited": False, + "premium_period_start_at": None, "period_start_at": None, "is_throttled": False, "effective_monthly_price_rub": None, @@ -671,7 +765,7 @@ class SubscriptionService: hwid_device_limit=effective_hwid_limit, ) if tariff: - panel_update_payload["activeInternalSquads"] = tariff.squad_uuids + panel_update_payload["activeInternalSquads"] = self._panel_squads_for_tariff(tariff) panel_update_payload.update(self._panel_identity_payload_for_user(db_user)) @@ -785,7 +879,10 @@ class SubscriptionService: traffic_limit_bytes=new_limit, hwid_device_limit=effective_hwid_limit, ) - panel_payload["activeInternalSquads"] = tariff.squad_uuids + panel_payload["activeInternalSquads"] = self._panel_squads_for_tariff( + tariff, + include_premium=not bool(getattr(updated_sub, "premium_is_limited", False)), + ) panel_payload.update(self._panel_identity_payload_for_user(db_user)) await self.panel_service.update_user_details_on_panel(db_user.panel_user_uuid, panel_payload) await tariff_dal.create_traffic_topup( @@ -802,6 +899,93 @@ class SubscriptionService: "tariff_key": tariff.key, } + async def activate_premium_topup( + self, + session: AsyncSession, + user_id: int, + tariff_key: str, + traffic_gb: float, + payment_amount: float, + payment_db_id: int, + provider: str = "yookassa", + ) -> Optional[Dict[str, Any]]: + tariff = self._resolve_tariff(tariff_key) + if not tariff or not tariff.premium_squad_uuids: + logging.error("Premium top-up requires a tariff with premium squads for user %s", user_id) + return None + + await self._record_payment_context( + session, + payment_db_id, + sale_mode="premium_topup", + tariff_key=tariff.key, + purchased_gb=float(traffic_gb), + ) + 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: + return None + + purchase_bytes = self.gb_to_bytes(traffic_gb) + now = datetime.now(timezone.utc) + premium_period_start = month_start(now) + current_period_start = getattr(sub, "premium_period_start_at", None) + same_period = bool(current_period_start and current_period_start == premium_period_start) + previous_topup_used = int(sub.premium_topup_used_bytes or 0) if same_period else 0 + premium_used = int(sub.premium_used_bytes or 0) if same_period else 0 + premium_baseline = int(tariff.premium_monthly_bytes or sub.premium_baseline_bytes or 0) + premium_topup_balance = int(sub.premium_topup_balance_bytes or 0) + purchase_bytes + overflow_to_cover = max(0, premium_used - premium_baseline - previous_topup_used) + consume_now = min(premium_topup_balance, overflow_to_cover) + premium_topup_balance -= consume_now + premium_topup_used = previous_topup_used + consume_now + premium_limit = self._premium_effective_limit_bytes( + premium_baseline, + premium_topup_balance, + premium_topup_used, + ) + premium_is_limited = premium_limit > 0 and premium_used >= premium_limit + + updated_sub = await subscription_dal.update_subscription( + session, + sub.subscription_id, + { + "premium_baseline_bytes": premium_baseline, + "premium_topup_balance_bytes": premium_topup_balance, + "premium_topup_used_bytes": premium_topup_used, + "premium_used_bytes": premium_used, + "premium_is_limited": premium_is_limited, + "premium_period_start_at": premium_period_start, + "tariff_key": tariff.key, + }, + ) + + panel_payload = { + "uuid": db_user.panel_user_uuid, + "activeInternalSquads": self._panel_squads_for_tariff( + tariff, + include_premium=not premium_is_limited, + ), + } + await self.panel_service.update_user_details_on_panel(db_user.panel_user_uuid, panel_payload) + await tariff_dal.create_traffic_topup( + session, + subscription_id=sub.subscription_id, + payment_id=payment_db_id, + purchased_bytes=purchase_bytes, + kind="premium_topup", + ) + return { + "subscription_id": sub.subscription_id, + "premium_limit_bytes": premium_limit, + "premium_topup_balance_bytes": premium_topup_balance, + "premium_topup_used_bytes": premium_topup_used, + "premium_is_limited": premium_is_limited, + "tariff_key": tariff.key, + } + async def activate_hwid_device_topup( self, session: AsyncSession, @@ -966,7 +1150,23 @@ class SubscriptionService: before_tariff_key = sub.tariff_key options = self.calculate_tariff_switch_options(sub, target) now = datetime.now(timezone.utc) - update_data: Dict[str, Any] = {"tariff_key": target.key, "is_throttled": False} + premium_topup_balance = int(sub.premium_topup_balance_bytes or 0) + premium_topup_used = int(getattr(sub, "premium_topup_used_bytes", 0) or 0) + premium_baseline = target.premium_monthly_bytes + premium_limit = self._premium_effective_limit_bytes( + premium_baseline, + premium_topup_balance, + premium_topup_used, + ) + premium_used = int(sub.premium_used_bytes or 0) + update_data: Dict[str, Any] = { + "tariff_key": target.key, + "is_throttled": False, + "premium_baseline_bytes": premium_baseline, + "premium_topup_balance_bytes": premium_topup_balance, + "premium_topup_used_bytes": premium_topup_used, + "premium_is_limited": bool(premium_limit > 0 and premium_used >= premium_limit), + } converted_bytes = None base_hwid_limit = self._base_hwid_limit_for_tariff(target) extra_hwid_devices = int(sub.extra_hwid_devices or 0) @@ -1011,7 +1211,10 @@ class SubscriptionService: traffic_limit_strategy="NO_RESET" if target.billing_model == "traffic" else "MONTH", hwid_device_limit=self._effective_hwid_limit(base_hwid_limit, extra_hwid_devices), ) - panel_payload["activeInternalSquads"] = target.squad_uuids + panel_payload["activeInternalSquads"] = self._panel_squads_for_tariff( + target, + include_premium=not bool(updated.premium_is_limited), + ) panel_payload.update(self._panel_identity_payload_for_user(db_user)) await self.panel_service.update_user_details_on_panel(db_user.panel_user_uuid, panel_payload) if converted_bytes: @@ -1092,6 +1295,29 @@ class SubscriptionService: payment_db_id=payment_db_id, provider=provider, ) + if sale_mode_base == "premium_topup": + if not tariff_key: + active_user = await user_dal.get_user_by_id(session, user_id) + active_sub = ( + await subscription_dal.get_active_subscription_by_user_id( + session, user_id, active_user.panel_user_uuid + ) + if active_user and active_user.panel_user_uuid + else None + ) + tariff_key = active_sub.tariff_key if active_sub else None + if not tariff_key: + logging.error("Premium top-up activation requires tariff_key for user %s", user_id) + return None + return await self.activate_premium_topup( + session=session, + user_id=user_id, + tariff_key=tariff_key, + traffic_gb=traffic_gb if traffic_gb is not None else float(months), + payment_amount=payment_amount, + payment_db_id=payment_db_id, + provider=provider, + ) if sale_mode_base in {"hwid_device", "hwid_devices"}: target_devices = int(traffic_gb if traffic_gb is not None else months) return await self.activate_hwid_device_topup( @@ -1239,11 +1465,22 @@ class SubscriptionService: topup_balance_bytes = int(getattr(current_active_sub, "topup_balance_bytes", 0) or 0) extra_hwid_devices = int(getattr(current_active_sub, "extra_hwid_devices", 0) or 0) + premium_topup_balance_bytes = int(getattr(current_active_sub, "premium_topup_balance_bytes", 0) or 0) + premium_topup_used_bytes = int(getattr(current_active_sub, "premium_topup_used_bytes", 0) or 0) + premium_used_bytes = int(getattr(current_active_sub, "premium_used_bytes", 0) or 0) + premium_period_start_at = getattr(current_active_sub, "premium_period_start_at", None) tier_baseline_bytes = tariff.monthly_bytes if tariff else self.settings.user_traffic_limit_bytes + premium_baseline_bytes = tariff.premium_monthly_bytes if tariff else 0 + premium_limit_bytes = self._premium_effective_limit_bytes( + premium_baseline_bytes, + premium_topup_balance_bytes, + premium_topup_used_bytes, + ) effective_monthly_price = float(payment_amount) / max(1, months_int) traffic_limit_bytes = self._traffic_limit_for_period_tariff(tariff, topup_balance_bytes) base_hwid_limit = self._base_hwid_limit_for_tariff(tariff) effective_hwid_limit = self._effective_hwid_limit(base_hwid_limit, extra_hwid_devices) + premium_is_limited = bool(premium_limit_bytes > 0 and premium_used_bytes >= premium_limit_bytes) sub_payload = { "user_id": user_id, "panel_user_uuid": panel_user_uuid, @@ -1260,6 +1497,12 @@ class SubscriptionService: "tariff_key": tariff.key if tariff else None, "tier_baseline_bytes": tier_baseline_bytes, "topup_balance_bytes": topup_balance_bytes, + "premium_baseline_bytes": premium_baseline_bytes, + "premium_topup_balance_bytes": premium_topup_balance_bytes, + "premium_topup_used_bytes": premium_topup_used_bytes, + "premium_used_bytes": premium_used_bytes, + "premium_is_limited": premium_is_limited, + "premium_period_start_at": premium_period_start_at, "period_start_at": None, "is_throttled": False, "effective_monthly_price_rub": effective_monthly_price, @@ -1286,7 +1529,10 @@ class SubscriptionService: hwid_device_limit=effective_hwid_limit, ) if tariff: - panel_update_payload["activeInternalSquads"] = tariff.squad_uuids + panel_update_payload["activeInternalSquads"] = self._panel_squads_for_tariff( + tariff, + include_premium=not premium_is_limited, + ) panel_update_payload.update(self._panel_identity_payload_for_user(db_user)) @@ -1552,6 +1798,14 @@ class SubscriptionService: tariff = None billing_model_display = tariff.billing_model if tariff else ("traffic" if getattr(self.settings, "traffic_sale_mode", False) else "period") traffic_limit_strategy = panel_traffic_strategy + premium_access = await self.premium_access_for_tariff(tariff) if tariff else { + "squad_uuids": [], + "squad_labels": [], + "node_labels": [], + } + premium_baseline = int(local_active_sub.premium_baseline_bytes or 0) if local_active_sub else 0 + premium_topup_balance = int(local_active_sub.premium_topup_balance_bytes or 0) if local_active_sub else 0 + premium_topup_used = int(getattr(local_active_sub, "premium_topup_used_bytes", 0) or 0) if local_active_sub else 0 return { "user_id": panel_user_data.get("uuid"), @@ -1568,6 +1822,19 @@ class SubscriptionService: "billing_model": billing_model_display, "tier_baseline_bytes": local_active_sub.tier_baseline_bytes if local_active_sub else None, "topup_balance_bytes": local_active_sub.topup_balance_bytes if local_active_sub else 0, + "premium_baseline_bytes": premium_baseline, + "premium_topup_balance_bytes": premium_topup_balance, + "premium_topup_used_bytes": premium_topup_used, + "premium_used_bytes": local_active_sub.premium_used_bytes if local_active_sub else 0, + "premium_limit_bytes": self._premium_effective_limit_bytes( + premium_baseline, + premium_topup_balance, + premium_topup_used, + ), + "premium_is_limited": bool(local_active_sub.premium_is_limited) if local_active_sub else False, + "premium_period_start_at": getattr(local_active_sub, "premium_period_start_at", None) if local_active_sub else None, + "premium_squad_labels": premium_access.get("squad_labels") or [], + "premium_node_labels": premium_access.get("node_labels") or [], "period_start_at": local_active_sub.period_start_at if local_active_sub else None, "is_throttled": bool(local_active_sub.is_throttled) if local_active_sub else False, "base_hwid_device_limit": local_active_sub.hwid_device_limit if local_active_sub else None, diff --git a/bot/services/tariff_worker.py b/bot/services/tariff_worker.py index 9da6974..029d224 100644 --- a/bot/services/tariff_worker.py +++ b/bot/services/tariff_worker.py @@ -17,6 +17,8 @@ from config.settings import Settings from db.dal import subscription_dal, tariff_dal from db.models import Subscription +PREMIUM_WARNING_LEVEL_OFFSET = 1000 + class TariffTrafficWorker: def __init__( @@ -35,6 +37,7 @@ class TariffTrafficWorker: self.bot = bot self.i18n = i18n self._stopped = asyncio.Event() + self._premium_nodes_cache = {} async def run(self) -> None: if not self.settings.tariffs_config: @@ -93,6 +96,8 @@ class TariffTrafficWorker: warning_period_start=warning_period_start if tariff.billing_model == "period" else None, ) + await self._sync_premium_squad_limit(session, sub, tariff, now) + async def _ensure_period_reset_strategy( self, sub: Subscription, @@ -109,7 +114,10 @@ class TariffTrafficWorker: traffic_limit_bytes=traffic_limit_bytes, traffic_limit_strategy="MONTH", ) - payload["activeInternalSquads"] = tariff.squad_uuids + payload["activeInternalSquads"] = self.subscription_service._panel_squads_for_tariff( + tariff, + include_premium=not bool(getattr(sub, "premium_is_limited", False)), + ) await self.panel_service.update_user_details_on_panel(sub.panel_user_uuid, payload, log_response=False) async def _maybe_warn_or_throttle( @@ -176,6 +184,236 @@ class TariffTrafficWorker: sub.subscription_id, ) + async def _sync_premium_squad_limit( + self, + session: AsyncSession, + sub: Subscription, + tariff, + now: datetime, + ) -> None: + if not getattr(tariff, "premium_squad_uuids", None): + if any( + int(value or 0) > 0 + for value in ( + sub.premium_baseline_bytes, + sub.premium_topup_balance_bytes, + sub.premium_used_bytes, + ) + ) or sub.premium_is_limited: + sub.premium_baseline_bytes = 0 + sub.premium_topup_balance_bytes = 0 + sub.premium_used_bytes = 0 + sub.premium_is_limited = False + return + + premium_period_start = month_start(now) + same_period = bool(getattr(sub, "premium_period_start_at", None) == premium_period_start) + premium_baseline = int(tariff.premium_monthly_bytes or 0) + premium_topup_balance = int(sub.premium_topup_balance_bytes or 0) + premium_topup_used = int(getattr(sub, "premium_topup_used_bytes", 0) or 0) if same_period else 0 + premium_limit = premium_baseline + premium_topup_balance + premium_topup_used + if premium_limit <= 0: + return + + node_uuids = await self._premium_node_uuids_for_tariff(tariff) + if not node_uuids: + logging.warning("Premium squads for tariff %s have no accessible nodes", tariff.key) + return + + start_date = now.date().replace(day=1).isoformat() + end_date = now.date().isoformat() + premium_used = await self._premium_usage_for_user(sub.panel_user_uuid, node_uuids, start_date, end_date) + if premium_used is None: + return + + overflow = max(0, int(premium_used) - premium_baseline) + delta_overflow = max(0, overflow - premium_topup_used) + consume_from_topup = min(premium_topup_balance, delta_overflow) + if consume_from_topup > 0: + premium_topup_balance -= consume_from_topup + premium_topup_used += consume_from_topup + premium_limit = premium_baseline + premium_topup_balance + premium_topup_used + + should_limit = premium_used >= premium_limit + changed = ( + int(sub.premium_baseline_bytes or 0) != premium_baseline + or int(sub.premium_topup_balance_bytes or 0) != premium_topup_balance + or int(getattr(sub, "premium_topup_used_bytes", 0) or 0) != premium_topup_used + or int(sub.premium_used_bytes or 0) != premium_used + or bool(sub.premium_is_limited) != should_limit + or getattr(sub, "premium_period_start_at", None) != premium_period_start + ) + sub.premium_baseline_bytes = premium_baseline + sub.premium_topup_balance_bytes = premium_topup_balance + sub.premium_topup_used_bytes = premium_topup_used + sub.premium_used_bytes = int(premium_used) + sub.premium_is_limited = bool(should_limit) + sub.premium_period_start_at = premium_period_start + await self._maybe_warn_premium_squad_limit( + session, + sub, + tariff, + premium_used, + premium_limit, + premium_period_start, + ) + if not changed: + return + + squads = self.subscription_service._panel_squads_for_tariff( + tariff, + include_premium=not should_limit, + ) + await self.panel_service.update_user_details_on_panel( + sub.panel_user_uuid, + {"uuid": sub.panel_user_uuid, "activeInternalSquads": squads}, + log_response=False, + ) + logging.info( + "Premium squad access %s for user %s tariff %s: %s/%s bytes", + "limited" if should_limit else "restored", + sub.user_id, + tariff.key, + premium_used, + premium_limit, + ) + + @staticmethod + def _fmt_bytes(value: int) -> str: + size = float(max(0, int(value or 0))) + for unit in ("B", "KB", "MB", "GB", "TB"): + if size < 1024 or unit == "TB": + return f"{size:.1f} {unit}" if unit != "B" else f"{int(size)} B" + size /= 1024 + return f"{size:.1f} TB" + + async def _maybe_warn_premium_squad_limit( + self, + session: AsyncSession, + sub: Subscription, + tariff, + used: int, + limit: int, + period_start_at: datetime, + ) -> None: + if limit <= 0: + return + ratio = int(used or 0) / int(limit) + levels = list(getattr(self.settings, "tariff_traffic_warning_levels", [85, 90, 95])) + for level in levels: + if ratio < level / 100: + continue + storage_level = PREMIUM_WARNING_LEVEL_OFFSET + int(level) + warning = await tariff_dal.get_warning( + session, + subscription_id=sub.subscription_id, + period_start_at=period_start_at, + level=storage_level, + ) + if warning: + continue + await tariff_dal.create_warning( + session, + subscription_id=sub.subscription_id, + period_start_at=period_start_at, + level=storage_level, + traffic_limit_bytes=None, + ) + if not self.bot: + continue + try: + access = await self.subscription_service.premium_access_for_tariff(tariff) + labels = access.get("node_labels") or access.get("squad_labels") or [] + if labels: + visible = labels[:8] + servers = "\n".join(f"• {label}" for label in visible) + if len(labels) > len(visible): + servers += f"\n• ... еще {len(labels) - len(visible)}" + else: + servers = "• premium-серверы тарифа" + text = ( + "⚠️ Отдельный лимит premium-серверов почти закончился.\n\n" + f"Тариф: {tariff.name(self.settings.DEFAULT_LANGUAGE)}\n" + f"Использовано: {self._fmt_bytes(used)} из {self._fmt_bytes(limit)} ({level}%).\n\n" + "Этот лимит действует на:\n" + f"{servers}\n\n" + "Можно докупить premium-трафик. Докупленный остаток переносится на следующие месяцы, пока не израсходуется." + ) + markup = InlineKeyboardMarkup( + inline_keyboard=[ + [ + InlineKeyboardButton( + text="Докупить premium-трафик", + callback_data="tariff_topup:list", + ) + ] + ] + ) + await self.bot.send_message(sub.user_id, text, reply_markup=markup) + except Exception: + logging.exception("Failed to send premium traffic warning to user %s", sub.user_id) + + async def _premium_node_uuids_for_tariff(self, tariff) -> list[str]: + cache_key = tuple(sorted(tariff.premium_squad_uuids or [])) + cached = self._premium_nodes_cache.get(cache_key) + now_ts = datetime.now(timezone.utc).timestamp() + if cached and now_ts - cached["ts"] < 600: + return list(cached["nodes"]) + + nodes: list[str] = [] + for squad_uuid in tariff.premium_squad_uuids or []: + accessible = await self.panel_service.get_internal_squad_accessible_nodes(squad_uuid) or [] + for node in accessible: + if not isinstance(node, dict): + continue + node_uuid = node.get("uuid") or node.get("nodeUuid") or node.get("node_uuid") + if node_uuid: + nodes.append(str(node_uuid)) + deduped = list(dict.fromkeys(nodes)) + self._premium_nodes_cache[cache_key] = {"ts": now_ts, "nodes": deduped} + return deduped + + async def _premium_usage_for_user( + self, + user_uuid: str, + node_uuids: list[str], + start_date: str, + end_date: str, + ) -> Optional[int]: + total = 0 + found = False + for node_uuid in node_uuids: + stats = await self.panel_service.get_node_users_bandwidth_stats( + node_uuid, + start=start_date, + end=end_date, + ) + if not stats: + continue + entries = stats.get("topUsers") or stats.get("usersStats") or stats.get("users") or [] + if not isinstance(entries, list): + continue + for entry in entries: + if not isinstance(entry, dict): + continue + user_obj = entry.get("user") if isinstance(entry.get("user"), dict) else {} + entry_uuid = ( + user_obj.get("uuid") + or entry.get("userUuid") + or entry.get("uuid") + or entry.get("user_uuid") + ) + if entry_uuid != user_uuid: + continue + value = entry.get("total") + if value is None: + value = int(entry.get("download", 0) or 0) + int(entry.get("upload", 0) or 0) + total += int(value or 0) + found = True + if len(node_uuids) > 1: + await asyncio.sleep(0.1) + return total if found else 0 + async def legacy_throttle_recovery_tick(self, session: AsyncSession) -> None: """Recover subscriptions throttled by older bot versions. diff --git a/config/tariffs.example.json b/config/tariffs.example.json index fa6563d..6029e9a 100644 --- a/config/tariffs.example.json +++ b/config/tariffs.example.json @@ -1,23 +1,34 @@ { "default_tariff": "standard", - "topup_packages_default": { - "rub": [ - { "gb": 10, "price": 99 }, - { "gb": 50, "price": 399 }, - { "gb": 200, "price": 1299 } - ], - "stars": [ - { "gb": 10, "price": 2500 } - ] - }, "tariffs": [ { "key": "standard", "names": { "ru": "Стандарт", "en": "Standard" }, "descriptions": { "ru": "Базовый набор серверов", "en": "Base server pool" }, "squad_uuids": ["uuid-1", "uuid-2"], + "premium_squad_uuids": ["premium-squad-uuid"], + "premium_monthly_gb": 50, + "premium_topup_packages": { + "rub": [ + { "gb": 10, "price": 99 }, + { "gb": 50, "price": 399 } + ], + "stars": [ + { "gb": 10, "price": 2500 } + ] + }, "billing_model": "period", "monthly_gb": 500, + "topup_packages": { + "rub": [ + { "gb": 10, "price": 99 }, + { "gb": 50, "price": 399 }, + { "gb": 200, "price": 1299 } + ], + "stars": [ + { "gb": 10, "price": 2500 } + ] + }, "hwid_device_limit": 5, "hwid_device_packages": { "rub": [ diff --git a/config/tariffs_config.py b/config/tariffs_config.py index 21ba431..eed4d00 100644 --- a/config/tariffs_config.py +++ b/config/tariffs_config.py @@ -76,6 +76,9 @@ class Tariff(BaseModel): conversion_rate_rub_per_gb: Optional[float] = None hwid_device_limit: Optional[int] = None hwid_device_packages: Optional[HwidDevicePackageSet] = None + premium_squad_uuids: List[str] = Field(default_factory=list) + premium_monthly_gb: Optional[float] = None + premium_topup_packages: Optional[PackageSet] = None @model_validator(mode="after") def validate_tariff(self) -> "Tariff": @@ -83,8 +86,17 @@ class Tariff(BaseModel): raise ValueError("tariff key must not be empty") self.key = self.key.strip() self.squad_uuids = [uuid.strip() for uuid in self.squad_uuids if uuid.strip()] + self.premium_squad_uuids = [ + uuid.strip() for uuid in self.premium_squad_uuids if uuid.strip() + ] if self.hwid_device_limit is not None and self.hwid_device_limit < 0: raise ValueError(f"tariff {self.key}: hwid_device_limit must be >= 0") + if self.premium_monthly_gb is not None and self.premium_monthly_gb < 0: + raise ValueError(f"tariff {self.key}: premium_monthly_gb must be >= 0") + if self.premium_topup_packages and not self.premium_squad_uuids: + raise ValueError(f"tariff {self.key}: premium_topup_packages require premium_squad_uuids") + if self.premium_monthly_gb and self.premium_monthly_gb > 0 and not self.premium_squad_uuids: + raise ValueError(f"tariff {self.key}: premium_monthly_gb requires premium_squad_uuids") if self.billing_model == "period": if self.monthly_gb is None or self.monthly_gb < 0: @@ -150,6 +162,15 @@ class Tariff(BaseModel): def has_hwid_device_packages(self) -> bool: return bool(self.hwid_device_packages and self.hwid_device_packages.has_any()) + @property + def premium_monthly_bytes(self) -> int: + if self.premium_monthly_gb is None or self.premium_monthly_gb <= 0: + return 0 + return int(float(self.premium_monthly_gb) * (1024**3)) + + def has_premium_squad_limit(self) -> bool: + return bool(self.premium_squad_uuids and (self.premium_monthly_bytes > 0 or self.premium_topup_packages)) + class TariffsConfig(BaseModel): default_tariff: str @@ -189,7 +210,7 @@ class TariffsConfig(BaseModel): def topup_packages_for(self, tariff: Tariff) -> Optional[PackageSet]: if tariff.billing_model == "traffic": return tariff.traffic_packages - return tariff.topup_packages if tariff.topup_packages is not None else self.topup_packages_default + return tariff.topup_packages def load_tariffs_config(path: str | Path) -> Optional[TariffsConfig]: diff --git a/db/database_setup.py b/db/database_setup.py index abe15f3..8e3bed8 100644 --- a/db/database_setup.py +++ b/db/database_setup.py @@ -109,6 +109,11 @@ async def init_db(settings: Settings, session_factory: sessionmaker): tariff_key = COALESCE(s.tariff_key, :tariff_key), tier_baseline_bytes = COALESCE(s.tier_baseline_bytes, s.traffic_limit_bytes, :baseline), topup_balance_bytes = COALESCE(s.topup_balance_bytes, 0), + premium_baseline_bytes = COALESCE(s.premium_baseline_bytes, :premium_baseline), + premium_topup_balance_bytes = COALESCE(s.premium_topup_balance_bytes, 0), + premium_topup_used_bytes = COALESCE(s.premium_topup_used_bytes, 0), + premium_used_bytes = COALESCE(s.premium_used_bytes, 0), + premium_is_limited = COALESCE(s.premium_is_limited, FALSE), period_start_at = NULL, effective_monthly_price_rub = COALESCE( s.effective_monthly_price_rub, @@ -130,6 +135,7 @@ async def init_db(settings: Settings, session_factory: sessionmaker): { "tariff_key": default_tariff.key, "baseline": default_tariff.monthly_bytes, + "premium_baseline": default_tariff.premium_monthly_bytes, "default_price": default_price, }, ) diff --git a/db/migrator.py b/db/migrator.py index 99b20ec..283025b 100644 --- a/db/migrator.py +++ b/db/migrator.py @@ -339,6 +339,18 @@ def _migration_0012_add_tariffs_schema(connection: Connection) -> None: sub_statements.append("ALTER TABLE subscriptions ADD COLUMN tier_baseline_bytes BIGINT") if "topup_balance_bytes" not in sub_columns: sub_statements.append("ALTER TABLE subscriptions ADD COLUMN topup_balance_bytes BIGINT NOT NULL DEFAULT 0") + if "premium_baseline_bytes" not in sub_columns: + sub_statements.append("ALTER TABLE subscriptions ADD COLUMN premium_baseline_bytes BIGINT NOT NULL DEFAULT 0") + if "premium_topup_balance_bytes" not in sub_columns: + sub_statements.append("ALTER TABLE subscriptions ADD COLUMN premium_topup_balance_bytes BIGINT NOT NULL DEFAULT 0") + if "premium_topup_used_bytes" not in sub_columns: + sub_statements.append("ALTER TABLE subscriptions ADD COLUMN premium_topup_used_bytes BIGINT NOT NULL DEFAULT 0") + if "premium_used_bytes" not in sub_columns: + sub_statements.append("ALTER TABLE subscriptions ADD COLUMN premium_used_bytes BIGINT NOT NULL DEFAULT 0") + if "premium_is_limited" not in sub_columns: + sub_statements.append("ALTER TABLE subscriptions ADD COLUMN premium_is_limited BOOLEAN NOT NULL DEFAULT FALSE") + if "premium_period_start_at" not in sub_columns: + sub_statements.append("ALTER TABLE subscriptions ADD COLUMN premium_period_start_at TIMESTAMPTZ") if "period_start_at" not in sub_columns: sub_statements.append("ALTER TABLE subscriptions ADD COLUMN period_start_at TIMESTAMPTZ") if "is_throttled" not in sub_columns: @@ -430,6 +442,7 @@ def _migration_0012_add_tariffs_schema(connection: Connection) -> None: for stmt in [ "CREATE INDEX IF NOT EXISTS ix_subscriptions_tariff_key ON subscriptions (tariff_key)", "CREATE INDEX IF NOT EXISTS ix_subscriptions_is_throttled ON subscriptions (is_throttled)", + "CREATE INDEX IF NOT EXISTS ix_subscriptions_premium_is_limited ON subscriptions (premium_is_limited)", "CREATE INDEX IF NOT EXISTS ix_payments_sale_mode ON payments (sale_mode)", "CREATE INDEX IF NOT EXISTS ix_payments_tariff_key ON payments (tariff_key)", "CREATE INDEX IF NOT EXISTS ix_traffic_topups_subscription_id ON traffic_topups (subscription_id)", @@ -470,6 +483,43 @@ def _migration_0009_add_composite_indexes(connection: Connection) -> None: ) +def _migration_0014_add_premium_squad_traffic_fields(connection: Connection) -> None: + inspector = inspect(connection) + sub_columns: Set[str] = {col["name"] for col in inspector.get_columns("subscriptions")} + statements: List[str] = [] + if "premium_baseline_bytes" not in sub_columns: + statements.append("ALTER TABLE subscriptions ADD COLUMN premium_baseline_bytes BIGINT NOT NULL DEFAULT 0") + if "premium_topup_balance_bytes" not in sub_columns: + statements.append("ALTER TABLE subscriptions ADD COLUMN premium_topup_balance_bytes BIGINT NOT NULL DEFAULT 0") + if "premium_topup_used_bytes" not in sub_columns: + statements.append("ALTER TABLE subscriptions ADD COLUMN premium_topup_used_bytes BIGINT NOT NULL DEFAULT 0") + if "premium_used_bytes" not in sub_columns: + statements.append("ALTER TABLE subscriptions ADD COLUMN premium_used_bytes BIGINT NOT NULL DEFAULT 0") + if "premium_is_limited" not in sub_columns: + statements.append("ALTER TABLE subscriptions ADD COLUMN premium_is_limited BOOLEAN NOT NULL DEFAULT FALSE") + if "premium_period_start_at" not in sub_columns: + statements.append("ALTER TABLE subscriptions ADD COLUMN premium_period_start_at TIMESTAMPTZ") + for stmt in statements: + connection.execute(text(stmt)) + connection.execute( + text( + "CREATE INDEX IF NOT EXISTS ix_subscriptions_premium_is_limited ON subscriptions (premium_is_limited)" + ) + ) + + +def _migration_0015_add_premium_topup_carryover_fields(connection: Connection) -> None: + inspector = inspect(connection) + sub_columns: Set[str] = {col["name"] for col in inspector.get_columns("subscriptions")} + statements: List[str] = [] + if "premium_topup_used_bytes" not in sub_columns: + statements.append("ALTER TABLE subscriptions ADD COLUMN premium_topup_used_bytes BIGINT NOT NULL DEFAULT 0") + if "premium_period_start_at" not in sub_columns: + statements.append("ALTER TABLE subscriptions ADD COLUMN premium_period_start_at TIMESTAMPTZ") + for stmt in statements: + connection.execute(text(stmt)) + + MIGRATIONS: List[Migration] = [ Migration( id="0001_add_channel_subscription_fields", @@ -547,6 +597,16 @@ MIGRATIONS: List[Migration] = [ ) ), ), + Migration( + id="0014_add_premium_squad_traffic_fields", + description="Track premium squad traffic limits and top-ups per subscription", + upgrade=_migration_0014_add_premium_squad_traffic_fields, + ), + Migration( + id="0015_add_premium_topup_carryover_fields", + description="Track premium top-up usage within the current monthly period", + upgrade=_migration_0015_add_premium_topup_carryover_fields, + ), ] diff --git a/db/models.py b/db/models.py index 0dd58e8..80cb78a 100644 --- a/db/models.py +++ b/db/models.py @@ -113,6 +113,12 @@ class Subscription(Base): tariff_key = Column(String, nullable=True, index=True) tier_baseline_bytes = Column(BigInteger, nullable=True) topup_balance_bytes = Column(BigInteger, nullable=False, default=0) + premium_baseline_bytes = Column(BigInteger, nullable=False, default=0) + premium_topup_balance_bytes = Column(BigInteger, nullable=False, default=0) + premium_topup_used_bytes = Column(BigInteger, nullable=False, default=0) + premium_used_bytes = Column(BigInteger, nullable=False, default=0) + premium_is_limited = Column(Boolean, nullable=False, default=False, index=True) + premium_period_start_at = Column(DateTime(timezone=True), nullable=True) period_start_at = Column(DateTime(timezone=True), nullable=True) is_throttled = Column(Boolean, nullable=False, default=False, index=True) effective_monthly_price_rub = Column(Numeric, nullable=True) diff --git a/docs/configuration.md b/docs/configuration.md index 0605819..7dfe324 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -79,6 +79,14 @@ nano .env Если файл из `TARIFFS_CONFIG_PATH` существует, бот использует каталог тарифов. Если файла нет, применяется конфигурация из переменных `.env`. +В `docker-compose-dev.yml` каталог `./config` монтируется в контейнер как `/app/config`, чтобы Web App админка могла сохранять `config/tariffs.json`. После изменения compose-файла пересоздайте контейнер: + +```bash +docker compose -f docker-compose-dev.yml up -d --build --force-recreate +``` + +Переопределения из веб-админки сохраняются в БД и применяются поверх `.env` без перезапуска. Для платежных методов кнопка отображается только если соответствующий `*_ENABLED=true` и сервис настроен. + ## Web App и email-вход | Переменная | Назначение | diff --git a/docs/tariffs.md b/docs/tariffs.md index 9a7e9be..87e935f 100644 --- a/docs/tariffs.md +++ b/docs/tariffs.md @@ -41,10 +41,6 @@ JSON-каталог может содержать несколько тариф ```json { "default_tariff": "standard", - "topup_packages_default": { - "rub": [{ "gb": 10, "price": 99 }], - "stars": [{ "gb": 10, "price": 2500 }] - }, "tariffs": [ { "key": "standard", @@ -55,6 +51,10 @@ JSON-каталог может содержать несколько тариф "monthly_gb": 500, "prices_rub": { "1": 150, "3": 400 }, "enabled_periods": [1, 3], + "topup_packages": { + "rub": [{ "gb": 10, "price": 99 }], + "stars": [{ "gb": 10, "price": 2500 }] + }, "enabled": true } ] @@ -66,12 +66,14 @@ JSON-каталог может содержать несколько тариф | Поле | Назначение | | --- | --- | | `default_tariff` | Тариф по умолчанию для первичного выбора и привязки активных подписок без `tariff_key`. | -| `topup_packages_default` | Пакеты докупки трафика для period-тарифов, у которых не задан `topup_packages`. | | `tariffs[].key` | Стабильный ключ тарифа. Используется в платежах, подписках и смене тарифа. | | `tariffs[].names` | Названия тарифа по языкам. | | `tariffs[].descriptions` | Описания тарифа по языкам. | | `tariffs[].enabled` | Доступность тарифа на витрине. | | `tariffs[].squad_uuids` | Internal Squads Remnawave для пользователей тарифа. | +| `tariffs[].premium_squad_uuids` | Internal Squads с отдельным premium-лимитом. Ноды для учета берутся автоматически из accessible nodes этих сквадов через API панели. | +| `tariffs[].premium_monthly_gb` | Отдельный месячный лимит трафика по premium-сквадам. `0` или отсутствие поля отключает отдельное ограничение. | +| `tariffs[].premium_topup_packages` | Пакеты докупки premium-трафика: `{ "gb": 10, "price": 99 }`. | | `tariffs[].billing_model` | Модель тарифа: `period` или `traffic`. | | `tariffs[].hwid_device_limit` | Базовый лимит HWID-устройств. `0` означает безлимит, отсутствие поля использует `USER_HWID_DEVICE_LIMIT`. | | `tariffs[].hwid_device_packages` | Пакеты докупки устройств: `{ "count": 1, "price": 99 }`. | @@ -84,7 +86,7 @@ JSON-каталог может содержать несколько тариф | `prices_rub` | Цены периодов в рублях, ключ - количество месяцев. | | `prices_stars` | Цены периодов в Telegram Stars. | | `enabled_periods` | Периоды, доступные для покупки. | -| `topup_packages` | Пакеты докупки трафика именно для этого тарифа. | +| `topup_packages` | Пакеты докупки трафика именно для этого тарифа. Если поле не задано или списки пустые, докупка для тарифа не показывается в Web App и Telegram-боте. | Для `traffic`-тарифа используются: @@ -115,6 +117,48 @@ JSON-каталог может содержать несколько тариф Докупка трафика для period-тарифа увеличивает `topup_balance_bytes` и общий `traffic_limit_bytes`. Этот баланс сохраняется в подписке и учитывается при продлении period-тарифа. В панель отправляется актуальный лимит, а доступ переводится в `ACTIVE`. +## Premium-сквады и отдельный лимит + +Тариф может включать дополнительный набор Internal Squads с отдельным лимитом трафика. Это удобно для сценария “обычные серверы без изменений, premium-серверы ограничены отдельно”. + +```json +{ + "squad_uuids": ["standard-squad-uuid"], + "premium_squad_uuids": ["premium-squad-uuid"], + "premium_monthly_gb": 50, + "premium_topup_packages": { + "rub": [{ "gb": 10, "price": 99 }], + "stars": [{ "gb": 10, "price": 2500 }] + } +} +``` + +Правила: + +- обычный лимит тарифа продолжает работать через `trafficLimitBytes` Remnawave; +- premium-трафик считается отдельно по нодам, доступным из `premium_squad_uuids`; +- список UUID нод не хранится в тарифе: бот запрашивает accessible nodes каждого premium-сквада у Remnawave и кеширует результат; +- пока premium-лимит не исчерпан, пользователь получает `squad_uuids + premium_squad_uuids`; +- при исчерпании premium-лимита бот убирает только premium-сквады, обычный доступ остается; +- после докупки premium-трафика бот возвращает premium-сквады, если новый лимит снова больше использованного premium-трафика. +- докупленный premium-трафик не сгорает в конце месяца: каждый месяц сначала расходуется `premium_monthly_gb`, а докупленный остаток уменьшается только на трафик сверх месячного лимита; +- при новом календарном месяце счетчик premium-трафика и `premium_topup_used_bytes` сбрасываются, но `premium_topup_balance_bytes` переносится дальше. + +Состояние хранится в подписке: + +- `premium_baseline_bytes` - базовый premium-лимит тарифа; +- `premium_topup_balance_bytes` - оставшийся докупленный premium-трафик; +- `premium_topup_used_bytes` - часть докупленного premium-трафика, уже потраченная в текущем месяце; +- `premium_used_bytes` - использованный premium-трафик за текущий календарный месяц; +- `premium_period_start_at` - месяц, к которому относится `premium_used_bytes`; +- `premium_is_limited` - признак, что premium-сквад временно снят. + +В пользовательском Web App premium-лимит показывается отдельной карточкой: использовано, лимит, остаток, докупленный переносимый остаток и список серверов/сквадов, на которые действует отдельное ограничение. В Telegram-разделе “Моя подписка” выводится тот же блок. + +Предупреждения по premium-лимиту отправляются отдельно от обычного трафика на тех же процентах `TARIFF_TRAFFIC_WARNING_LEVELS`. Сообщение объясняет, что это именно premium-серверы, перечисляет серверы/сквады и ведет пользователя в докупку premium-трафика. + +В Web App админке premium-сквады можно выбрать из выпадающего списка. Список берется из API Remnawave, поэтому UUID не нужно копировать вручную. + ## Traffic-тарифы `traffic` продает объем трафика без пользовательского срока действия. @@ -197,7 +241,7 @@ limit_after = current_used + balance_after | Поле | Назначение | | --- | --- | -| `sale_mode` | Тип продажи: `subscription`, `traffic_package`, `topup`, `tariff_upgrade`, `hwid_devices`. | +| `sale_mode` | Тип продажи: `subscription`, `traffic_package`, `topup`, `premium_topup`, `tariff_upgrade`, `hwid_devices`. | | `tariff_key` | Ключ тарифа, к которому относится платеж. | | `purchased_gb` | Купленный объем GB для traffic-пакетов и докупки трафика. | | `purchased_hwid_devices` | Количество устройств при докупке HWID. | diff --git a/locales/en.json b/locales/en.json index 846824b..6d9cf1a 100644 --- a/locales/en.json +++ b/locales/en.json @@ -632,6 +632,12 @@ "wa_home_subscription_inactive": "Subscription inactive", "wa_until_date": "until {date}", "wa_home_traffic_used": "Traffic used", + "wa_premium_traffic_title": "Premium servers", + "wa_premium_reset_monthly": "Separate monthly limit", + "wa_premium_access_limited": "Premium access is temporarily limited", + "wa_premium_left": "Left", + "wa_premium_topup_balance": "Top-up balance", + "wa_premium_servers_limited": "Separate limit applies to", "wa_renew": "Renew", "wa_pay_subscription": "Pay subscription", "wa_subscription_title": "Subscription", @@ -672,6 +678,7 @@ "wa_confirm_and_apply": "Confirm and apply", "wa_confirm_and_pay": "Confirm and pay", "wa_topup_for_tariff": "Packages for {tariff}", + "wa_topup_carryover": "Top-up traffic does not expire: the monthly limit is used first, then the top-up balance.", "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.", diff --git a/locales/ru.json b/locales/ru.json index 3b6c8df..e112809 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -632,6 +632,12 @@ "wa_home_subscription_inactive": "Подписка не активна", "wa_until_date": "до {date}", "wa_home_traffic_used": "Использовано трафика", + "wa_premium_traffic_title": "Premium-серверы", + "wa_premium_reset_monthly": "Отдельный лимит на месяц", + "wa_premium_access_limited": "Доступ к premium временно ограничен", + "wa_premium_left": "Осталось", + "wa_premium_topup_balance": "Докупленный остаток", + "wa_premium_servers_limited": "Отдельный лимит действует на", "wa_renew": "Продлить", "wa_pay_subscription": "Оплатить подписку", "wa_subscription_title": "Подписка", @@ -672,6 +678,7 @@ "wa_confirm_and_apply": "Подтвердить и применить", "wa_confirm_and_pay": "Подтвердить и оплатить", "wa_topup_for_tariff": "Пакеты для тарифа {tariff}", + "wa_topup_carryover": "Докупленный трафик не сгорает: сначала расходуется месячный лимит, затем докупленный остаток.", "wa_topup_warning_levels": "Предупреждения о трафике приходят на {levels}%.", "wa_topup_warning_medium": "Использовано {percent}% трафика. Предупреждения настроены на {levels}%.", "wa_topup_warning_high": "Использовано {percent}% трафика. Лучше докупить пакет заранее.", diff --git a/tests/test_tariff_worker.py b/tests/test_tariff_worker.py index 4e99505..4219d4d 100644 --- a/tests/test_tariff_worker.py +++ b/tests/test_tariff_worker.py @@ -124,3 +124,134 @@ class TariffWorkerTests(unittest.IsolatedAsyncioTestCase): panel_service.remove_users_from_internal_squad.assert_not_awaited() self.assertFalse(sub.is_throttled) + + async def test_premium_limit_removes_only_premium_squad(self): + payload = _tariffs_config_payload() + payload["tariffs"][0]["premium_squad_uuids"] = ["premium-squad"] + payload["tariffs"][0]["premium_monthly_gb"] = 1 + with tempfile.TemporaryDirectory() as tmpdir: + config_path = Path(tmpdir) / "tariffs.json" + config_path.write_text(json.dumps(payload), encoding="utf-8") + + settings = Settings( + _env_file=None, + BOT_TOKEN="token", + POSTGRES_USER="app_user", + POSTGRES_PASSWORD="app_password", + TARIFFS_CONFIG_PATH=str(config_path), + ) + panel_service = AsyncMock(spec=PanelApiService) + panel_service.get_internal_squad_accessible_nodes = AsyncMock( + return_value=[{"uuid": "node-1", "name": "Premium"}] + ) + panel_service.get_node_users_bandwidth_stats = AsyncMock( + return_value={ + "topUsers": [ + { + "user": {"uuid": "panel-uuid"}, + "total": 2 * (1024**3), + } + ] + } + ) + panel_service.update_user_details_on_panel = AsyncMock(return_value={"response": {}}) + subscription_service = SubscriptionService(settings, panel_service) + worker = TariffTrafficWorker( + settings=settings, + session_factory=SimpleNamespace(), + panel_service=panel_service, + subscription_service=subscription_service, + ) + sub = SimpleNamespace( + subscription_id=1, + user_id=123, + panel_user_uuid="panel-uuid", + premium_baseline_bytes=1 * (1024**3), + premium_topup_balance_bytes=0, + premium_topup_used_bytes=0, + premium_used_bytes=0, + premium_is_limited=False, + premium_period_start_at=None, + ) + tariff = settings.tariffs_config.require("standard") + + with patch("bot.services.tariff_worker.tariff_dal.get_warning", new=AsyncMock(return_value=True)): + await worker._sync_premium_squad_limit(AsyncMock(), sub, tariff, datetime.now(timezone.utc)) + + self.assertTrue(sub.premium_is_limited) + panel_service.update_user_details_on_panel.assert_awaited_once() + payload = panel_service.update_user_details_on_panel.await_args.args[1] + self.assertEqual(payload["activeInternalSquads"], ["squad-1"]) + + async def test_premium_topup_balance_carries_over_and_is_spent_only_above_monthly_limit(self): + payload = _tariffs_config_payload() + payload["tariffs"][0]["premium_squad_uuids"] = ["premium-squad"] + payload["tariffs"][0]["premium_monthly_gb"] = 1 + with tempfile.TemporaryDirectory() as tmpdir: + config_path = Path(tmpdir) / "tariffs.json" + config_path.write_text(json.dumps(payload), encoding="utf-8") + + settings = Settings( + _env_file=None, + BOT_TOKEN="token", + POSTGRES_USER="app_user", + POSTGRES_PASSWORD="app_password", + TARIFFS_CONFIG_PATH=str(config_path), + TARIFF_TRAFFIC_WARNING_LEVELS="101", + ) + panel_service = AsyncMock(spec=PanelApiService) + panel_service.get_internal_squad_accessible_nodes = AsyncMock(return_value=[{"uuid": "node-1"}]) + panel_service.get_node_users_bandwidth_stats = AsyncMock( + return_value={ + "topUsers": [ + { + "user": {"uuid": "panel-uuid"}, + "total": int(1.5 * (1024**3)), + } + ] + } + ) + panel_service.update_user_details_on_panel = AsyncMock(return_value={"response": {}}) + subscription_service = SubscriptionService(settings, panel_service) + worker = TariffTrafficWorker( + settings=settings, + session_factory=SimpleNamespace(), + panel_service=panel_service, + subscription_service=subscription_service, + ) + now = datetime(2026, 5, 9, tzinfo=timezone.utc) + sub = SimpleNamespace( + subscription_id=1, + user_id=123, + panel_user_uuid="panel-uuid", + premium_baseline_bytes=1 * (1024**3), + premium_topup_balance_bytes=2 * (1024**3), + premium_topup_used_bytes=0, + premium_used_bytes=0, + premium_is_limited=False, + premium_period_start_at=datetime(2026, 5, 1, tzinfo=timezone.utc), + ) + tariff = settings.tariffs_config.require("standard") + + await worker._sync_premium_squad_limit(AsyncMock(), sub, tariff, now) + + self.assertEqual(sub.premium_topup_balance_bytes, int(1.5 * (1024**3))) + self.assertEqual(sub.premium_topup_used_bytes, int(0.5 * (1024**3))) + self.assertFalse(sub.premium_is_limited) + + panel_service.get_node_users_bandwidth_stats = AsyncMock( + return_value={ + "topUsers": [ + { + "user": {"uuid": "panel-uuid"}, + "total": int(0.1 * (1024**3)), + } + ] + } + ) + next_month = datetime(2026, 6, 2, tzinfo=timezone.utc) + await worker._sync_premium_squad_limit(AsyncMock(), sub, tariff, next_month) + + self.assertEqual(sub.premium_topup_balance_bytes, int(1.5 * (1024**3))) + self.assertEqual(sub.premium_topup_used_bytes, 0) + self.assertEqual(sub.premium_period_start_at, datetime(2026, 6, 1, tzinfo=timezone.utc)) diff --git a/tests/test_tariffs_config.py b/tests/test_tariffs_config.py index cb2b779..71782ca 100644 --- a/tests/test_tariffs_config.py +++ b/tests/test_tariffs_config.py @@ -53,6 +53,24 @@ class TariffsConfigTests(unittest.TestCase): self.assertEqual(config.default.key, "standard") self.assertEqual(config.require("traffic").rub_per_gb_for_conversion(), 19.9) + def test_period_tariff_without_topup_packages_has_no_topup(self): + config = TariffsConfig.model_validate(_valid_config()) + + self.assertIsNone(config.topup_packages_for(config.require("standard"))) + + def test_period_tariff_uses_only_own_topup_packages(self): + data = _valid_config() + data["tariffs"][0]["topup_packages"] = { + "rub": [{"gb": 25, "price": 199}], + "stars": [], + } + config = TariffsConfig.model_validate(data) + + packages = config.topup_packages_for(config.require("standard")) + + self.assertIsNotNone(packages) + self.assertEqual(packages.rub[0].gb, 25) + def test_missing_config_returns_none(self): import tempfile from pathlib import Path @@ -110,3 +128,26 @@ class TariffsConfigTests(unittest.TestCase): with self.assertRaises(ValueError): TariffsConfig.model_validate(data) + + def test_premium_squad_limit_and_topups_load(self): + data = _valid_config() + data["tariffs"][0]["premium_squad_uuids"] = [" premium-squad "] + data["tariffs"][0]["premium_monthly_gb"] = 50 + data["tariffs"][0]["premium_topup_packages"] = { + "rub": [{"gb": 10, "price": 99}], + "stars": [], + } + + config = TariffsConfig.model_validate(data) + tariff = config.require("standard") + + self.assertEqual(tariff.premium_squad_uuids, ["premium-squad"]) + self.assertEqual(tariff.premium_monthly_bytes, 50 * 1024**3) + self.assertTrue(tariff.has_premium_squad_limit()) + + def test_premium_limit_requires_premium_squad(self): + data = _valid_config() + data["tariffs"][0]["premium_monthly_gb"] = 50 + + with self.assertRaises(ValueError): + TariffsConfig.model_validate(data) diff --git a/tests/test_webapp_assets.py b/tests/test_webapp_assets.py index 02ab3e4..9c5f647 100644 --- a/tests/test_webapp_assets.py +++ b/tests/test_webapp_assets.py @@ -126,6 +126,33 @@ class WebAppAssetTests(unittest.IsolatedAsyncioTestCase): self.assertEqual(plans[0]["price"], 199.0) self.assertEqual(plans[1]["stars_price"], 2500) + def test_serialize_payment_methods_respects_runtime_provider_toggles(self): + settings = Settings( + _env_file=None, + BOT_TOKEN="token", + POSTGRES_USER="app_user", + POSTGRES_PASSWORD="app_password", + TARIFFS_CONFIG_PATH="missing-tariffs.json", + CRYPTOPAY_ENABLED=False, + FREEKASSA_ENABLED=False, + SEVERPAY_ENABLED=False, + YOOKASSA_ENABLED=False, + PLATEGA_ENABLED=False, + STARS_ENABLED=False, + ) + configured_service = SimpleNamespace(configured=True) + app = { + "cryptopay_service": configured_service, + "freekassa_service": configured_service, + "severpay_service": configured_service, + "yookassa_service": configured_service, + "platega_service": configured_service, + } + + methods = subscription_webapp._serialize_payment_methods(settings, app) + + self.assertEqual(methods, []) + def test_serialize_plans_includes_stars_only_subscription_options(self): settings = Settings( _env_file=None,