feat: premium squads inside one tariff

This commit is contained in:
3252a8
2026-05-10 12:51:53 +03:00
parent 6f9a87cf98
commit 990eba1e1e
34 changed files with 1898 additions and 135 deletions
+45 -5
View File
@@ -253,11 +253,20 @@ def _write_tariffs_config_file(path: Path, config: TariffsConfig) -> None:
data = _tariffs_config_payload(config) data = _tariffs_config_payload(config)
path.parent.mkdir(parents=True, exist_ok=True) path.parent.mkdir(parents=True, exist_ok=True)
tmp_path = path.with_suffix(f"{path.suffix}.tmp") tmp_path = path.with_suffix(f"{path.suffix}.tmp")
tmp_path.write_text( payload = json.dumps(data, ensure_ascii=False, indent=2) + "\n"
json.dumps(data, ensure_ascii=False, indent=2) + "\n", try:
encoding="utf-8", tmp_path.write_text(payload, encoding="utf-8")
) tmp_path.replace(path)
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 ──────────────────────────────────────────────────────── # ─── 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)}) 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 ────────────────────────────────────────────────── # ─── 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_get("/api/admin/tariffs", admin_tariffs_get_route)
router.add_put("/api/admin/tariffs", admin_tariffs_save_route) router.add_put("/api/admin/tariffs", admin_tariffs_save_route)
router.add_get("/api/admin/panel/internal-squads", admin_panel_internal_squads_route)
+220 -20
View File
@@ -127,6 +127,14 @@
traffic_limit: "100 GB", traffic_limit: "100 GB",
traffic_used_bytes: 19756849561, traffic_used_bytes: 19756849561,
traffic_limit_bytes: 107374182400, 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, max_devices: 5,
}, },
devices: { devices: {
@@ -496,9 +504,16 @@
$: devicesEnabled = Boolean(appSettings?.my_devices_enabled); $: devicesEnabled = Boolean(appSettings?.my_devices_enabled);
$: subscription = data?.subscription || DEV_MOCK.data.subscription; $: subscription = data?.subscription || DEV_MOCK.data.subscription;
$: hasActiveTariffSubscription = Boolean(tariffMode && subscription?.active && subscription?.tariff_key); $: hasActiveTariffSubscription = Boolean(tariffMode && subscription?.active && subscription?.tariff_key);
$: canChangeTariff = Boolean(hasActiveTariffSubscription && hasMultipleTariffs);
$: currentTariffName = activeTariffName(subscription, plans); $: 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( $: canShowTopupButton = Boolean(
hasActiveTariffSubscription && Number(subscription?.traffic_limit_bytes || 0) > 0 && trafficPercent(subscription) >= 85, canOpenTopupModal &&
(trafficPercent(subscription) >= 85 || premiumTrafficPercent(subscription) >= 85),
); );
$: user = data?.user || {}; $: user = data?.user || {};
$: isAdmin = Boolean(user?.is_admin); $: isAdmin = Boolean(user?.is_admin);
@@ -1845,7 +1860,7 @@
months: selectedTopupPlan.months, months: selectedTopupPlan.months,
traffic_gb: selectedTopupPlan.traffic_gb, traffic_gb: selectedTopupPlan.traffic_gb,
tariff_key: selectedTopupPlan.tariff_key || topupOptions?.tariff_key, tariff_key: selectedTopupPlan.tariff_key || topupOptions?.tariff_key,
sale_mode: "topup", sale_mode: selectedTopupPlan.sale_mode || "topup",
method: selectedMethod, method: selectedMethod,
}), }),
}); });
@@ -2231,6 +2246,7 @@
} }
function openTopupModal() { function openTopupModal() {
if (!canOpenTopupModal) return;
topupModalOpen = true; topupModalOpen = true;
loadTopupOptions(); loadTopupOptions();
} }
@@ -2313,6 +2329,11 @@
return `${formatted} GB`; return `${formatted} GB`;
} }
function formatTrafficBytes(value) {
const gb = Number(value || 0) / 1073741824;
return formatTrafficGb(gb);
}
function planKey(plan) { function planKey(plan) {
return plan?.id || `${plan?.tariff_key || "legacy"}:${plan?.sale_mode || "subscription"}:${plan?.months || plan?.traffic_gb || ""}`; 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"); 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) { function planDisplayTitle(plan) {
if (plan?.tariff_key) { if (plan?.tariff_key) {
return plan?.tariff_name || plan?.title || plan?.tariff_key; return plan?.tariff_name || plan?.title || plan?.tariff_key;
@@ -2438,14 +2486,14 @@
function planSubtitle(plan) { function planSubtitle(plan) {
if (!plan?.tariff_key) return ""; if (!plan?.tariff_key) return "";
if (plan?.subtitle) return plan.subtitle; 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 formatTrafficGb(plan?.traffic_gb || plan?.months);
} }
return _formatMonthsForClient(plan?.months); return _formatMonthsForClient(plan?.months);
} }
function planUnitHint(plan) { 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); const gb = Number(plan?.traffic_gb || plan?.months || 0);
if (!gb) return ""; if (!gb) return "";
if (String(selectedMethod || "").toLowerCase().includes("stars") && Number(plan?.stars_price || 0) > 0) { if (String(selectedMethod || "").toLowerCase().includes("stars") && Number(plan?.stars_price || 0) > 0) {
@@ -2537,6 +2585,34 @@
return t("wa_topup_warning_levels", { levels }); 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) { function _formatMonthsForClient(value) {
const months = Number(value || 0); const months = Number(value || 0);
if (months === 1) return currentLang === "en" ? "1 month" : "1 месяц"; if (months === 1) return currentLang === "en" ? "1 month" : "1 месяц";
@@ -2913,7 +2989,10 @@
</Card> </Card>
{#if subscription.active} {#if subscription.active}
<Card> <Card class={canOpenTopupModal ? "traffic-card-clickable" : ""}>
{#if canOpenTopupModal}
<button class="card-click-target" type="button" on:click={openTopupModal} aria-label={t("wa_topup_traffic")}></button>
{/if}
<div class="traffic-top"> <div class="traffic-top">
<span>{t("wa_home_traffic_used")}</span> <span>{t("wa_home_traffic_used")}</span>
<strong>{trafficLabel(subscription)}</strong> <strong>{trafficLabel(subscription)}</strong>
@@ -2926,6 +3005,44 @@
<span class="traffic-percent">{trafficPercent(subscription)}%</span> <span class="traffic-percent">{trafficPercent(subscription)}%</span>
</div> </div>
</Card> </Card>
{#if Number(subscription?.premium_limit_bytes || 0) > 0}
<Card class={`${canOpenTopupModal ? "traffic-card-clickable " : ""}premium-traffic-card${subscription?.premium_is_limited ? " premium-traffic-card-limited" : ""}`}>
{#if canOpenTopupModal}
<button class="card-click-target" type="button" on:click={openTopupModal} aria-label={t("wa_topup_traffic")}></button>
{/if}
<div class="traffic-top">
<span>{t("wa_premium_traffic_title", {}, "Premium-серверы")}</span>
<strong>{premiumTrafficLabel(subscription)}</strong>
</div>
<div class="progress premium-progress">
<span style={`width: ${premiumTrafficPercent(subscription)}%`}></span>
</div>
<div class="traffic-meta">
<span>{subscription?.premium_is_limited ? t("wa_premium_access_limited", {}, "Доступ к premium временно ограничен") : t("wa_premium_reset_monthly", {}, "Отдельный лимит на месяц")}</span>
<span class="traffic-percent">{premiumTrafficPercent(subscription)}%</span>
</div>
<div class="premium-detail-grid">
<span>
<small>{t("wa_premium_left", {}, "Осталось")}</small>
<strong>{premiumTrafficLeftLabel(subscription)}</strong>
</span>
<span>
<small>{t("wa_premium_topup_balance", {}, "Докупленный остаток")}</small>
<strong>{premiumTopupBalanceLabel(subscription)}</strong>
</span>
</div>
{#if premiumServerLabels(subscription).length}
<div class="premium-server-list">
<small>{t("wa_premium_servers_limited", {}, "Отдельный лимит действует на")}</small>
<div>
{#each premiumServerLabels(subscription).slice(0, 8) as label}
<span>{label}</span>
{/each}
</div>
</div>
{/if}
</Card>
{/if}
{:else if appSettings?.trial_enabled && appSettings?.trial_available} {:else if appSettings?.trial_enabled && appSettings?.trial_available}
<Card class="trial-card"> <Card class="trial-card">
<div class="trial-card-head"> <div class="trial-card-head">
@@ -2959,7 +3076,7 @@
{t("wa_activate_trial")} {t("wa_activate_trial")}
</Button> </Button>
{/if} {/if}
{#if hasActiveTariffSubscription} {#if canChangeTariff}
<Button class="wide" variant="secondary" onclick={openTariffChangeModal}> <Button class="wide" variant="secondary" onclick={openTariffChangeModal}>
<RefreshCw size={18} /> <RefreshCw size={18} />
{t("wa_change_tariff")} {t("wa_change_tariff")}
@@ -3159,7 +3276,6 @@
</span> </span>
<ArrowRight size={17} /> <ArrowRight size={17} />
</button> </button>
<div class="settings-divider" aria-hidden="true"></div>
</div> </div>
{/if} {/if}
<div class="settings-links-block"> <div class="settings-links-block">
@@ -3480,13 +3596,39 @@
<Dialog <Dialog
open={changeModalOpen} open={changeModalOpen}
title={t("wa_change_tariff")} title={t("wa_change_tariff")}
description={changeOptions?.current ? t("wa_current_tariff", { tariff: changeOptions.current.title }) : t("wa_tariff_options_loading")} description={tariffChangeModalDescription()}
closeLabel={t("wa_close")} closeLabel={t("wa_close")}
onclose={closeTariffChangeModal} onclose={closeTariffChangeModal}
class="payment-dialog-card" class="payment-dialog-card"
> >
<div class="payment-dialog-body"> <div class="payment-dialog-body">
{#if changeOptions?.targets?.length} {#if !changeOptions}
<div class="dialog-skeleton" aria-label={t("wa_tariff_options_loading")}>
<div class="tariff-action-list">
{#each [1, 2] as _}
<div class="tariff-action-card skeleton-row">
<span>
<span class="skeleton-line skeleton-line-title"></span>
<span class="skeleton-line skeleton-line-short"></span>
</span>
<span class="skeleton-line skeleton-line-price"></span>
</div>
{/each}
</div>
<div class="payment-divider" aria-hidden="true"></div>
<div class="option-list">
{#each [1, 2] as _}
<div class="option-row change-action-row skeleton-row">
<span class="option-row-main">
<span class="skeleton-line skeleton-line-title"></span>
<span class="skeleton-line skeleton-line-short"></span>
</span>
</div>
{/each}
</div>
<div class="skeleton-pay-button"></div>
</div>
{:else if changeOptions?.targets?.length}
<p class="section-kicker">{t("wa_tariff_change_targets_title")}</p> <p class="section-kicker">{t("wa_tariff_change_targets_title")}</p>
<div class="tariff-action-list"> <div class="tariff-action-list">
{#each changeOptions.targets as target} {#each changeOptions.targets as target}
@@ -3562,7 +3704,7 @@
<Card class="empty-card">{t("wa_no_tariff_change_options")}</Card> <Card class="empty-card">{t("wa_no_tariff_change_options")}</Card>
{/if} {/if}
{:else} {:else}
<Card class="empty-card">{tariffActionBusy ? t("wa_tariff_options_loading") : t("wa_no_tariff_change_options")}</Card> <Card class="empty-card">{t("wa_no_tariff_change_options")}</Card>
{/if} {/if}
</div> </div>
</Dialog> </Dialog>
@@ -3594,13 +3736,39 @@
<Dialog <Dialog
open={topupModalOpen} open={topupModalOpen}
title={t("wa_topup_traffic")} title={t("wa_topup_traffic")}
description={topupOptions?.tariff_name ? t("wa_topup_for_tariff", { tariff: topupOptions.tariff_name }) : t("wa_tariff_options_loading")} description={topupModalDescription()}
closeLabel={t("wa_close")} closeLabel={t("wa_close")}
onclose={closeTopupModal} onclose={closeTopupModal}
class="payment-dialog-card" class="payment-dialog-card"
> >
<div class="payment-dialog-body"> <div class="payment-dialog-body">
{#if topupOptions?.plans?.length} {#if !topupOptions}
<div class="dialog-skeleton" aria-label={t("wa_tariff_options_loading")}>
<div class="option-list">
{#each [1, 2, 3] as _}
<div class="option-row plan-row skeleton-row">
<span class="option-row-main">
<span class="skeleton-line skeleton-line-title"></span>
<span class="skeleton-line skeleton-line-short"></span>
</span>
<span class="option-row-meta">
<span class="skeleton-line skeleton-line-price"></span>
<span class="skeleton-line skeleton-line-tiny"></span>
</span>
</div>
{/each}
</div>
<div class="method-grid">
{#each [1, 2] as _}
<div class="method-card skeleton-method">
<span class="skeleton-dot"></span>
<span class="skeleton-line skeleton-line-method"></span>
</div>
{/each}
</div>
<div class="skeleton-pay-button"></div>
</div>
{:else if topupOptions?.plans?.length}
<div class="option-list"> <div class="option-list">
{#each topupOptions.plans as plan} {#each topupOptions.plans as plan}
<button <button
@@ -3611,20 +3779,27 @@
> >
<span class="option-row-main"> <span class="option-row-main">
<strong>{plan.title}</strong> <strong>{plan.title}</strong>
<small>{plan.subtitle || topupOptions.tariff_name}</small> {#if !singleTariffMode || plan.sale_mode === "premium_topup"}
<small>{plan.subtitle || topupOptions.tariff_name}</small>
{/if}
</span> </span>
<span class="option-row-meta"> <span class="option-row-meta">
<em>{priceLabel(plan)}</em> <em>{priceLabel(plan)}</em>
{#if planUnitHint(plan)} {#if planUnitHint(plan)}
<small>{planUnitHint(plan)}</small> <small>{planUnitHint(plan)}</small>
{/if} {/if}
{#if planKey(selectedTopupPlan) === planKey(plan)}
<CheckCircle2 size={18} />
{/if}
</span> </span>
</button> </button>
{/each} {/each}
</div> </div>
{@const carryoverNotes = topupCarryoverNotes()}
{#if carryoverNotes.length}
<div class="topup-carryover-note">
{#each carryoverNotes as note}
<p>{note}</p>
{/each}
</div>
{/if}
<div class="method-grid"> <div class="method-grid">
{#each methods as method} {#each methods as method}
{@const meta = methodMeta(method)} {@const meta = methodMeta(method)}
@@ -3648,7 +3823,7 @@
<LockKeyhole size={17} /> <LockKeyhole size={17} />
</Button> </Button>
{:else} {:else}
<Card class="empty-card">{tariffActionBusy ? t("wa_tariff_options_loading") : t("wa_no_topup_options")}</Card> <Card class="empty-card">{t("wa_no_topup_options")}</Card>
{/if} {/if}
</div> </div>
</Dialog> </Dialog>
@@ -3656,13 +3831,38 @@
<Dialog <Dialog
open={deviceTopupModalOpen} open={deviceTopupModalOpen}
title={t("wa_buy_hwid_devices")} title={t("wa_buy_hwid_devices")}
description={deviceTopupOptions?.tariff_name ? t("wa_device_topup_for_tariff", { tariff: deviceTopupOptions.tariff_name }) : t("wa_tariff_options_loading")} description={deviceTopupModalDescription()}
closeLabel={t("wa_close")} closeLabel={t("wa_close")}
onclose={closeDeviceTopupModal} onclose={closeDeviceTopupModal}
class="payment-dialog-card" class="payment-dialog-card"
> >
<div class="payment-dialog-body"> <div class="payment-dialog-body">
{#if deviceTopupOptions?.plans?.length} {#if !deviceTopupOptions}
<div class="dialog-skeleton" aria-label={t("wa_tariff_options_loading")}>
<div class="option-list">
{#each [1, 2, 3] as _}
<div class="option-row plan-row skeleton-row">
<span class="option-row-main">
<span class="skeleton-line skeleton-line-title"></span>
<span class="skeleton-line skeleton-line-short"></span>
</span>
<span class="option-row-meta">
<span class="skeleton-line skeleton-line-price"></span>
</span>
</div>
{/each}
</div>
<div class="method-grid">
{#each [1, 2] as _}
<div class="method-card skeleton-method">
<span class="skeleton-dot"></span>
<span class="skeleton-line skeleton-line-method"></span>
</div>
{/each}
</div>
<div class="skeleton-pay-button"></div>
</div>
{:else if deviceTopupOptions?.plans?.length}
<div class="option-list"> <div class="option-list">
{#each deviceTopupOptions.plans as plan} {#each deviceTopupOptions.plans as plan}
<button <button
@@ -3707,7 +3907,7 @@
<LockKeyhole size={17} /> <LockKeyhole size={17} />
</Button> </Button>
{:else} {:else}
<Card class="empty-card">{tariffActionBusy ? t("wa_tariff_options_loading") : t("wa_no_hwid_device_options")}</Card> <Card class="empty-card">{t("wa_no_hwid_device_options")}</Card>
{/if} {/if}
</div> </div>
</Dialog> </Dialog>
+186 -11
View File
@@ -174,6 +174,10 @@
let tariffDeleteOpen = false; let tariffDeleteOpen = false;
let tariffDeleteTarget = null; let tariffDeleteTarget = null;
let tariffDraft = emptyTariffDraft(); let tariffDraft = emptyTariffDraft();
let panelSquads = [];
let panelSquadsLoading = false;
let selectedBaseSquad = "";
let selectedPremiumSquad = "";
// Settings // Settings
let settingsSections = []; let settingsSections = [];
@@ -551,10 +555,12 @@
nameEn: "", nameEn: "",
descriptionRu: "", descriptionRu: "",
descriptionEn: "", descriptionEn: "",
squadUuids: "", squadUuids: [],
premiumSquadUuids: [],
billing_model: "period", billing_model: "period",
enabled: true, enabled: true,
monthly_gb: 500, monthly_gb: 500,
premium_monthly_gb: "",
hwid_device_limit: "", hwid_device_limit: "",
conversion_rate_rub_per_gb: "", conversion_rate_rub_per_gb: "",
periodRows: [ periodRows: [
@@ -565,6 +571,8 @@
], ],
topupRubRows: [], topupRubRows: [],
topupStarsRows: [], topupStarsRows: [],
premiumTopupRubRows: [],
premiumTopupStarsRows: [],
trafficRubRows: [ trafficRubRows: [
{ gb: 10, price: 199 }, { gb: 10, price: 199 },
{ gb: 50, price: 799 }, { gb: 50, price: 799 },
@@ -612,15 +620,19 @@
nameEn: tariff.names?.en || "", nameEn: tariff.names?.en || "",
descriptionRu: tariff.descriptions?.ru || "", descriptionRu: tariff.descriptions?.ru || "",
descriptionEn: tariff.descriptions?.en || "", descriptionEn: tariff.descriptions?.en || "",
squadUuids: (tariff.squad_uuids || []).join("\n"), squadUuids: tariff.squad_uuids || [],
premiumSquadUuids: tariff.premium_squad_uuids || [],
billing_model: tariff.billing_model || "period", billing_model: tariff.billing_model || "period",
enabled: tariff.enabled !== false, enabled: tariff.enabled !== false,
monthly_gb: tariff.monthly_gb ?? "", monthly_gb: tariff.monthly_gb ?? "",
premium_monthly_gb: tariff.premium_monthly_gb ?? "",
hwid_device_limit: tariff.hwid_device_limit ?? "", hwid_device_limit: tariff.hwid_device_limit ?? "",
conversion_rate_rub_per_gb: tariff.conversion_rate_rub_per_gb ?? "", conversion_rate_rub_per_gb: tariff.conversion_rate_rub_per_gb ?? "",
periodRows: periodRows.length ? periodRows : emptyTariffDraft().periodRows, periodRows: periodRows.length ? periodRows : emptyTariffDraft().periodRows,
topupRubRows: rowsFromPackages(tariff.topup_packages, "rub", "gb"), topupRubRows: rowsFromPackages(tariff.topup_packages, "rub", "gb"),
topupStarsRows: rowsFromPackages(tariff.topup_packages, "stars", "gb"), topupStarsRows: rowsFromPackages(tariff.topup_packages, "stars", "gb"),
premiumTopupRubRows: rowsFromPackages(tariff.premium_topup_packages, "rub", "gb"),
premiumTopupStarsRows: rowsFromPackages(tariff.premium_topup_packages, "stars", "gb"),
trafficRubRows: rowsFromPackages(tariff.traffic_packages, "rub", "gb"), trafficRubRows: rowsFromPackages(tariff.traffic_packages, "rub", "gb"),
trafficStarsRows: rowsFromPackages(tariff.traffic_packages, "stars", "gb"), trafficStarsRows: rowsFromPackages(tariff.traffic_packages, "stars", "gb"),
hwidRubRows: rowsFromPackages(tariff.hwid_device_packages, "rub", "count"), hwidRubRows: rowsFromPackages(tariff.hwid_device_packages, "rub", "count"),
@@ -672,10 +684,8 @@
key, key,
names, names,
descriptions, descriptions,
squad_uuids: tariffDraft.squadUuids squad_uuids: normalizeUuidList(tariffDraft.squadUuids),
.split(/[\n,]+/) premium_squad_uuids: normalizeUuidList(tariffDraft.premiumSquadUuids),
.map((item) => item.trim())
.filter(Boolean),
billing_model: tariffDraft.billing_model, billing_model: tariffDraft.billing_model,
enabled: Boolean(tariffDraft.enabled), enabled: Boolean(tariffDraft.enabled),
}; };
@@ -684,6 +694,14 @@
if (hwidLimit !== null) tariff.hwid_device_limit = hwidLimit; if (hwidLimit !== null) tariff.hwid_device_limit = hwidLimit;
const hwidPackages = packageSetFromRows(tariffDraft.hwidRubRows, tariffDraft.hwidStarsRows, "count"); const hwidPackages = packageSetFromRows(tariffDraft.hwidRubRows, tariffDraft.hwidStarsRows, "count");
if (hwidPackages) tariff.hwid_device_packages = hwidPackages; if (hwidPackages) tariff.hwid_device_packages = hwidPackages;
const premiumMonthlyGb = parseNumber(tariffDraft.premium_monthly_gb);
if (premiumMonthlyGb !== null) tariff.premium_monthly_gb = premiumMonthlyGb;
const premiumTopupPackages = packageSetFromRows(
tariffDraft.premiumTopupRubRows,
tariffDraft.premiumTopupStarsRows,
"gb",
);
if (premiumTopupPackages) tariff.premium_topup_packages = premiumTopupPackages;
if (tariff.billing_model === "period") { if (tariff.billing_model === "period") {
const seenMonths = new Set(); const seenMonths = new Set();
@@ -719,6 +737,7 @@
async function loadTariffs() { async function loadTariffs() {
tariffsLoading = true; tariffsLoading = true;
try { try {
loadPanelSquads();
const data = await api("/admin/tariffs"); const data = await api("/admin/tariffs");
if (data?.ok) { if (data?.ok) {
tariffsCatalog = cloneCatalog(data.catalog); tariffsCatalog = cloneCatalog(data.catalog);
@@ -731,6 +750,46 @@
} }
} }
async function loadPanelSquads() {
if (panelSquadsLoading) return;
panelSquadsLoading = true;
try {
const data = await api("/admin/panel/internal-squads");
if (data?.ok) panelSquads = data.squads || [];
} catch (e) {
panelSquads = [];
} finally {
panelSquadsLoading = false;
}
}
function normalizeUuidList(value) {
if (Array.isArray(value)) return value.map((item) => String(item).trim()).filter(Boolean);
return String(value || "")
.split(/[\n,]+/)
.map((item) => item.trim())
.filter(Boolean);
}
function squadLabel(uuid) {
const squad = panelSquads.find((item) => item.uuid === uuid);
return squad ? `${squad.name} · ${uuid.slice(0, 8)}…` : uuid;
}
function addSquadToDraft(field, uuid) {
if (!uuid) return;
const current = normalizeUuidList(tariffDraft[field]);
if (current.includes(uuid)) return;
tariffDraft = { ...tariffDraft, [field]: [...current, uuid] };
}
function removeSquadFromDraft(field, uuid) {
tariffDraft = {
...tariffDraft,
[field]: normalizeUuidList(tariffDraft[field]).filter((item) => item !== uuid),
};
}
async function persistTariffs(nextCatalog, successText) { async function persistTariffs(nextCatalog, successText) {
tariffsSaving = true; tariffsSaving = true;
try { try {
@@ -757,6 +816,8 @@
tariffEditingKey = ""; tariffEditingKey = "";
tariffDraft = emptyTariffDraft(); tariffDraft = emptyTariffDraft();
tariffEditorTab = "general"; tariffEditorTab = "general";
selectedBaseSquad = "";
selectedPremiumSquad = "";
tariffEditorOpen = true; tariffEditorOpen = true;
} }
@@ -764,6 +825,8 @@
tariffEditingKey = tariff.key; tariffEditingKey = tariff.key;
tariffDraft = draftFromTariff(tariff); tariffDraft = draftFromTariff(tariff);
tariffEditorTab = "general"; tariffEditorTab = "general";
selectedBaseSquad = "";
selectedPremiumSquad = "";
tariffEditorOpen = true; tariffEditorOpen = true;
} }
@@ -1794,6 +1857,7 @@
<span>{tariff.billing_model === "traffic" ? "Трафик" : "Периоды"}</span> <span>{tariff.billing_model === "traffic" ? "Трафик" : "Периоды"}</span>
<span>{tariffPriceSummary(tariff)}</span> <span>{tariffPriceSummary(tariff)}</span>
<span>Squads: {(tariff.squad_uuids || []).length}</span> <span>Squads: {(tariff.squad_uuids || []).length}</span>
<span>Premium: {(tariff.premium_squad_uuids || []).length ? `${tariff.premium_monthly_gb || 0} GB` : "—"}</span>
<span>Устройства: {tariff.hwid_device_limit ?? "env"}</span> <span>Устройства: {tariff.hwid_device_limit ?? "env"}</span>
</div> </div>
<div class="admin-tariff-actions"> <div class="admin-tariff-actions">
@@ -1921,6 +1985,7 @@
<Tabs.Trigger value="general" class="admin-tabs-trigger">Основное</Tabs.Trigger> <Tabs.Trigger value="general" class="admin-tabs-trigger">Основное</Tabs.Trigger>
<Tabs.Trigger value="pricing" class="admin-tabs-trigger">Цены</Tabs.Trigger> <Tabs.Trigger value="pricing" class="admin-tabs-trigger">Цены</Tabs.Trigger>
<Tabs.Trigger value="topup" class="admin-tabs-trigger">Докупки</Tabs.Trigger> <Tabs.Trigger value="topup" class="admin-tabs-trigger">Докупки</Tabs.Trigger>
<Tabs.Trigger value="premium" class="admin-tabs-trigger">Premium</Tabs.Trigger>
<Tabs.Trigger value="hwid" class="admin-tabs-trigger">Устройства</Tabs.Trigger> <Tabs.Trigger value="hwid" class="admin-tabs-trigger">Устройства</Tabs.Trigger>
</Tabs.List> </Tabs.List>
@@ -1992,11 +2057,40 @@
</Label.Root> </Label.Root>
</div> </div>
<Label.Root class="admin-field-label"> <div class="admin-field-label">
<span>Internal Squads UUID</span> <span>Основные Internal Squads</span>
<small>Один UUID на строку или через запятую</small> <small>{panelSquadsLoading ? "Загружаю список из панели…" : "Выберите сквады из Remnawave"}</small>
<textarea class="admin-textarea" rows="3" placeholder="db786ee8-816b-4760-80aa-1fc7a3669ff2" bind:value={tariffDraft.squadUuids}></textarea> <Select.Root
</Label.Root> type="single"
bind:value={selectedBaseSquad}
onValueChange={(value) => {
addSquadToDraft("squadUuids", value);
selectedBaseSquad = "";
}}
>
<Select.Trigger class="admin-select-trigger" aria-label="Добавить основной сквад">
<span>Добавить сквад</span>
<ChevronDown size={14} class="admin-select-icon" />
</Select.Trigger>
<Select.Portal>
<Select.Content class="admin-select-content" sideOffset={6}>
{#each panelSquads as squad}
<Select.Item value={squad.uuid} class="admin-select-item">
<span>{squad.name}</span>
<Check size={14} class="admin-select-item-check" />
</Select.Item>
{/each}
</Select.Content>
</Select.Portal>
</Select.Root>
<div class="admin-chip-list">
{#each normalizeUuidList(tariffDraft.squadUuids) as uuid}
<button type="button" class="admin-chip" on:click={() => removeSquadFromDraft("squadUuids", uuid)}>
{squadLabel(uuid)} <X size={12} />
</button>
{/each}
</div>
</div>
<div class="admin-form-row admin-form-row-2"> <div class="admin-form-row admin-form-row-2">
<Label.Root class="admin-field-label"> <Label.Root class="admin-field-label">
@@ -2020,6 +2114,87 @@
</div> </div>
</Tabs.Content> </Tabs.Content>
<Tabs.Content value="premium" class="admin-tabs-content">
<section class="admin-editor-section">
<header class="admin-editor-section-head">
<strong>Premium-сквад и отдельный лимит</strong>
</header>
<div class="admin-form-row admin-form-row-2">
<div class="admin-field-label">
<span>Premium Internal Squads</span>
<small>Ноды для учета трафика будут взяты из accessible nodes этих сквадов</small>
<Select.Root
type="single"
bind:value={selectedPremiumSquad}
onValueChange={(value) => {
addSquadToDraft("premiumSquadUuids", value);
selectedPremiumSquad = "";
}}
>
<Select.Trigger class="admin-select-trigger" aria-label="Добавить premium-сквад">
<span>Добавить premium-сквад</span>
<ChevronDown size={14} class="admin-select-icon" />
</Select.Trigger>
<Select.Portal>
<Select.Content class="admin-select-content" sideOffset={6}>
{#each panelSquads as squad}
<Select.Item value={squad.uuid} class="admin-select-item">
<span>{squad.name}</span>
<Check size={14} class="admin-select-item-check" />
</Select.Item>
{/each}
</Select.Content>
</Select.Portal>
</Select.Root>
<div class="admin-chip-list">
{#each normalizeUuidList(tariffDraft.premiumSquadUuids) as uuid}
<button type="button" class="admin-chip" on:click={() => removeSquadFromDraft("premiumSquadUuids", uuid)}>
{squadLabel(uuid)} <X size={12} />
</button>
{/each}
</div>
</div>
<Label.Root class="admin-field-label">
<span>Premium лимит, GB/мес.</span>
<small>0 или пусто — нет отдельного premium-лимита</small>
<input class="input" type="number" min="0" step="0.1" placeholder="50" bind:value={tariffDraft.premium_monthly_gb} />
</Label.Root>
</div>
</section>
<section class="admin-editor-section">
<header class="admin-editor-section-head">
<strong>Докупка premium-трафика</strong>
<div class="admin-editor-section-actions">
<button type="button" class="admin-btn admin-btn-sm" on:click={() => addDraftRow("premiumTopupRubRows", { gb: 10, price: "" })}><Plus size={12} /> RUB</button>
<button type="button" class="admin-btn admin-btn-sm" on:click={() => addDraftRow("premiumTopupStarsRows", { gb: 10, price: "" })}><Plus size={12} /> Stars</button>
</div>
</header>
<div class="admin-package-columns">
<div class="admin-row-editor">
<span class="admin-row-editor-caption">RUB</span>
{#each tariffDraft.premiumTopupRubRows as row, index}
<div class="admin-row-editor-line">
<input class="input" type="number" min="0.1" step="0.1" placeholder="GB" bind:value={row.gb} aria-label="Premium GB" />
<input class="input" type="number" min="0" step="0.01" placeholder="Цена" bind:value={row.price} aria-label="Цена RUB" />
<button type="button" class="admin-btn admin-btn-sm admin-btn-danger" on:click={() => removeDraftRow("premiumTopupRubRows", index)} aria-label="Удалить"><Trash2 size={13} /></button>
</div>
{/each}
</div>
<div class="admin-row-editor">
<span class="admin-row-editor-caption">Stars</span>
{#each tariffDraft.premiumTopupStarsRows as row, index}
<div class="admin-row-editor-line">
<input class="input" type="number" min="0.1" step="0.1" placeholder="GB" bind:value={row.gb} aria-label="Premium GB" />
<input class="input" type="number" min="0" step="1" placeholder="Stars" bind:value={row.price} aria-label="Цена Stars" />
<button type="button" class="admin-btn admin-btn-sm admin-btn-danger" on:click={() => removeDraftRow("premiumTopupStarsRows", index)} aria-label="Удалить"><Trash2 size={13} /></button>
</div>
{/each}
</div>
</div>
</section>
</Tabs.Content>
<Tabs.Content value="pricing" class="admin-tabs-content"> <Tabs.Content value="pricing" class="admin-tabs-content">
{#if tariffDraft.billing_model === "period"} {#if tariffDraft.billing_model === "period"}
<section class="admin-editor-section"> <section class="admin-editor-section">
+230 -6
View File
@@ -388,6 +388,107 @@ a {
font-size: 11px; 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 { .trial-card {
padding: 13px 14px; padding: 13px 14px;
} }
@@ -828,6 +929,102 @@ a {
color: var(--accent); 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 { .back-inline {
display: inline-flex; display: inline-flex;
width: fit-content; width: fit-content;
@@ -3960,12 +4157,12 @@ a {
.settings-admin-block { .settings-admin-block {
display: grid; display: grid;
gap: 10px; gap: 10px;
margin: 6px 0 10px; margin: 6px 0 0;
} }
.settings-row.settings-row-admin { .settings-row.settings-row-admin {
border: 1px solid color-mix(in srgb, var(--accent) 36%, transparent); border: 1px solid color-mix(in srgb, #f59e0b 42%, transparent);
background: color-mix(in srgb, var(--accent) 9%, transparent); background: color-mix(in srgb, #f59e0b 12%, transparent);
border-radius: var(--radius); border-radius: var(--radius);
padding: 10px 12px; padding: 10px 12px;
width: 100%; width: 100%;
@@ -3974,12 +4171,12 @@ a {
} }
.settings-row.settings-row-admin:hover { .settings-row.settings-row-admin:hover {
background: color-mix(in srgb, var(--accent) 16%, transparent); background: color-mix(in srgb, #f59e0b 18%, transparent);
border-color: color-mix(in srgb, var(--accent) 50%, transparent); border-color: color-mix(in srgb, #f59e0b 58%, transparent);
} }
.settings-row.settings-row-admin > svg:first-child { .settings-row.settings-row-admin > svg:first-child {
color: var(--accent); color: #fbbf24;
opacity: 1; opacity: 1;
} }
@@ -4190,6 +4387,33 @@ a {
opacity: 1; 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) */ /* Label primitive (bits-ui Label.Root) */
.admin-field-label { .admin-field-label {
display: flex; display: flex;
+86 -17
View File
@@ -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") return _json_error(400, "invalid_plan", "Stars price is not configured")
payment_units = device_count payment_units = device_count
sale_mode = f"hwid_devices@{tariff.key}" 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() tariff_key = str(payment_payload.tariff_key or "").strip()
if not tariff_key: if not tariff_key:
return _json_error(400, "invalid_plan", "Tariff is not selected") 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): except (TypeError, ValueError):
return _json_error(400, "invalid_plan", "Invalid traffic package") 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 [])} 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 [])} 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) 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") 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 payment_units = int(traffic_gb) if float(traffic_gb).is_integer() else traffic_gb
traffic_gb_for_payment = float(payment_units) traffic_gb_for_payment = float(payment_units)
sale_mode = f"topup@{tariff.key}" sale_mode = f"{requested_sale_mode}@{tariff.key}"
elif tariffs_config: elif tariffs_config:
tariff_key = str(payment_payload.tariff_key or "").strip() tariff_key = str(payment_payload.tariff_key or "").strip()
if not tariff_key: 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 lang = db_user.language_code or settings.DEFAULT_LANGUAGE
tariff = config.require(sub.tariff_key) tariff = config.require(sub.tariff_key)
plans = _serialize_topup_packages(settings, tariff, config.topup_packages_for(tariff), lang) 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( return web.json_response(
{ {
"ok": True, "ok": True,
"tariff_key": tariff.key, "tariff_key": tariff.key,
"tariff_name": tariff.name(lang), "tariff_name": tariff.name(lang),
"traffic_percent": _traffic_percent(sub.traffic_used_bytes, sub.traffic_limit_bytes), "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, "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, "language_code": lang,
"is_admin": is_admin, "is_admin": is_admin,
}, },
"subscription": _serialize_subscription(active, local_sub, lang), "subscription": _serialize_subscription(settings, active, local_sub, lang),
"referral": { "referral": {
"code": referral_code, "code": referral_code,
"bot_link": referral_link, "bot_link": referral_link,
@@ -3052,6 +3082,7 @@ def _build_webapp_referral_link(
def _serialize_subscription( def _serialize_subscription(
settings: Settings,
active: Optional[Dict[str, Any]], active: Optional[Dict[str, Any]],
local_sub: Optional[Any], local_sub: Optional[Any],
lang: str, lang: str,
@@ -3077,6 +3108,18 @@ def _serialize_subscription(
int((end_date - datetime.now(timezone.utc)).total_seconds()), 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 { return {
"active": seconds_left > 0, "active": seconds_left > 0,
"status": active.get("status_from_panel") or "UNKNOWN", "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 ""), "traffic_limit_strategy": str(active.get("traffic_limit_strategy") or ""),
"tier_baseline_bytes": _coerce_int_or_none(active.get("tier_baseline_bytes")), "tier_baseline_bytes": _coerce_int_or_none(active.get("tier_baseline_bytes")),
"topup_balance_bytes": _coerce_int_or_none(active.get("topup_balance_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, "period_start_at": active.get("period_start_at").isoformat() if active.get("period_start_at") else None,
"is_throttled": bool(active.get("is_throttled")), "is_throttled": bool(active.get("is_throttled")),
"max_devices": _coerce_int_or_none(active.get("max_devices")), "max_devices": _coerce_int_or_none(active.get("max_devices")),
@@ -3242,6 +3296,9 @@ def _serialize_topup_packages(
tariff: Any, tariff: Any,
packages: Optional[Any], packages: Optional[Any],
lang: str, lang: str,
*,
sale_mode: str = "topup",
title_prefix: str = "",
) -> List[Dict[str, Any]]: ) -> List[Dict[str, Any]]:
rub_packages = {float(package.gb): float(package.price) for package in (packages.rub if packages else [])} 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 [])} 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 continue
traffic_value = float(traffic_gb) traffic_value = float(traffic_gb)
plan: Dict[str, Any] = { 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_key": tariff.key,
"tariff_name": tariff.name(lang), "tariff_name": tariff.name(lang),
"billing_model": tariff.billing_model, "billing_model": tariff.billing_model,
"sale_mode": "topup", "sale_mode": sale_mode,
"months": int(traffic_value) if traffic_value.is_integer() else traffic_value, "months": int(traffic_value) if traffic_value.is_integer() else traffic_value,
"traffic_gb": traffic_value, "traffic_gb": traffic_value,
"price": float(price or 0), "price": float(price or 0),
"currency": settings.DEFAULT_CURRENCY_SYMBOL or "RUB", "currency": settings.DEFAULT_CURRENCY_SYMBOL or "RUB",
"title": _format_traffic_title(traffic_value, lang), "title": f"{title_prefix}{_format_traffic_title(traffic_value, lang)}",
"subtitle": tariff.name(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: if stars_price is not None and int(stars_price) > 0:
plan["stars_price"] = int(stars_price) plan["stars_price"] = int(stars_price)
@@ -3396,19 +3453,19 @@ def _serialize_payment_methods(
methods: List[Dict[str, Any]] = [] methods: List[Dict[str, Any]] = []
for method in settings.payment_methods_order: for method in settings.payment_methods_order:
method = method.lower() 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]}) 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]}) 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]}) 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]}) 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]}) methods.append({"id": method, "name": labels[method]})
elif method == "stars" and settings.STARS_ENABLED: elif method == "stars" and settings.STARS_ENABLED:
methods.append({"id": method, "name": labels[method]}) 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]}) methods.append({"id": method, "name": labels[method]})
return methods 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: 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: def _sale_mode_is_hwid_devices(sale_mode: str) -> bool:
@@ -3462,24 +3519,36 @@ async def _create_subscription_payment(
) )
if method == "yookassa": if method == "yookassa":
if not settings.YOOKASSA_ENABLED:
return _json_error(400, "payment_unavailable", "Payment method unavailable")
return await _create_yookassa_payment( return await _create_yookassa_payment(
request, session, user_id, months, price, description, sale_mode=sale_mode, traffic_gb=traffic_gb request, session, user_id, months, price, description, sale_mode=sale_mode, traffic_gb=traffic_gb
) )
if method == "freekassa": if method == "freekassa":
if not settings.FREEKASSA_ENABLED:
return _json_error(400, "payment_unavailable", "Payment method unavailable")
return await _create_freekassa_payment( return await _create_freekassa_payment(
request, session, user_id, months, price, description, sale_mode=sale_mode, traffic_gb=traffic_gb request, session, user_id, months, price, description, sale_mode=sale_mode, traffic_gb=traffic_gb
) )
if method in ("platega", "platega_sbp", "platega_crypto"): 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( return await _create_platega_payment(
request, session, user_id, months, price, description, variant=method, sale_mode=sale_mode, traffic_gb=traffic_gb request, session, user_id, months, price, description, variant=method, sale_mode=sale_mode, traffic_gb=traffic_gb
) )
if method == "severpay": if method == "severpay":
if not settings.SEVERPAY_ENABLED:
return _json_error(400, "payment_unavailable", "Payment method unavailable")
return await _create_severpay_payment( return await _create_severpay_payment(
request, session, user_id, months, price, description, sale_mode=sale_mode, traffic_gb=traffic_gb request, session, user_id, months, price, description, sale_mode=sale_mode, traffic_gb=traffic_gb
) )
if method == "cryptopay": if method == "cryptopay":
service: CryptoPayService = request.app["cryptopay_service"] 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") return _json_error(400, "payment_unavailable", "Payment method unavailable")
url = await service.create_invoice( url = await service.create_invoice(
session=session, session=session,
+2 -2
View File
@@ -1,4 +1,4 @@
import logging import logging
import csv import csv
import io import io
from aiogram import Router, F, types 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') }.get(payment.provider, payment.provider or 'Unknown')
sale_base = (payment.sale_mode or "").split("@", 1)[0].split("|", 1)[0] 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: if traffic_like:
traffic_val = payment.purchased_gb or payment.subscription_duration_months or 0 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}" traffic_display = str(int(traffic_val)) if float(traffic_val).is_integer() else f"{traffic_val:g}"
+2 -2
View File
@@ -1,4 +1,4 @@
import logging import logging
import json import json
import asyncio import asyncio
from datetime import datetime, timezone, timedelta 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, promo_code_id_from_payment=promo_code_id,
provider="yookassa", provider="yookassa",
sale_mode=sale_mode, 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'): if not activation_details or not activation_details.get('end_date'):
+115 -4
View File
@@ -1,8 +1,10 @@
import hashlib import hashlib
import html
import logging import logging
from aiogram import Router, F, types, Bot from aiogram import Router, F, types, Bot
from aiogram.filters import Command from aiogram.filters import Command
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup, WebAppInfo from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup, WebAppInfo
from aiogram.utils.keyboard import InlineKeyboardBuilder
from typing import Optional, Union from typing import Optional, Union
from datetime import datetime from datetime import datetime
from sqlalchemy.ext.asyncio import AsyncSession 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"]) tariff = config.require(active["tariff_key"])
packages = config.topup_packages_for(tariff) packages = config.topup_packages_for(tariff)
rub_packages = packages.rub if packages else [] 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) await callback.answer(get_text("no_subscription_options_available"), show_alert=True)
return 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.message.edit_text(get_text("choose_payment_method_traffic"), reply_markup=markup)
await callback.answer() 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: if not config or not active or not callback.message:
await callback.answer("Error", show_alert=True) await callback.answer("Error", show_alert=True)
return return
if len(config.enabled_tariffs) <= 1:
await callback.answer("Смена тарифа недоступна: сейчас включен только один тариф.", show_alert=True)
return
rows = [] rows = []
for tariff in config.enabled_tariffs: for tariff in config.enabled_tariffs:
if tariff.key == active.get("tariff_key"): if tariff.key == active.get("tariff_key"):
@@ -614,6 +689,31 @@ async def my_subscription_command_handler(
if tariff_prefix: if tariff_prefix:
text = tariff_prefix + "\n" + text 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🚀 <b>Premium-серверы</b>\n"
f"Статус: <b>{premium_status}</b>\n"
f"Лимит: <b>{active.get('premium_used')} из {active.get('premium_limit')}</b>\n"
f"Осталось: <b>{premium_left / 2**30:.2f} GB</b>\n"
f"Докупленный остаток: <b>{premium_balance / 2**30:.2f} GB</b>\n"
"Отдельный лимит действует на:\n"
f"{label_block}\n\n"
"Premium-докупка не сгорает: сначала расходуется месячный лимит premium-серверов, затем докупленный premium-трафик."
)
base_markup = get_back_to_main_menu_markup( base_markup = get_back_to_main_menu_markup(
current_lang, current_lang,
i18n, i18n,
@@ -727,8 +827,19 @@ async def my_subscription_command_handler(
tariff_actions = [] tariff_actions = []
if _has_multiple_enabled_tariffs(settings): if _has_multiple_enabled_tariffs(settings):
tariff_actions.append(InlineKeyboardButton(text="Сменить тариф", callback_data="tariff_change:list")) tariff_actions.append(InlineKeyboardButton(text="Сменить тариф", callback_data="tariff_change:list"))
tariff_actions.append(InlineKeyboardButton(text="Докупить трафик", callback_data="tariff_topup:list")) try:
prepend_rows.append(tariff_actions) 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: if prepend_rows:
kb = prepend_rows + kb kb = prepend_rows + kb
@@ -1,4 +1,4 @@
from typing import Optional from typing import Optional
from aiogram import F, Router, types from aiogram import F, Router, types
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
@@ -30,7 +30,7 @@ async def pay_crypto_callback_handler(
pass pass
return 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: try:
await callback.answer(get_text("payment_service_unavailable_alert"), show_alert=True) await callback.answer(get_text("payment_service_unavailable_alert"), show_alert=True)
except Exception: except Exception:
@@ -55,7 +55,7 @@ async def pay_crypto_callback_handler(
sale_base = sale_mode.split("@", 1)[0].split("|", 1)[0] sale_base = sale_mode.split("@", 1)[0].split("|", 1)[0]
payment_description = ( payment_description = (
get_text("payment_description_traffic", traffic_gb=human_value) 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))) 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: try:
await callback.message.edit_text( await callback.message.edit_text(
get_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), months=int(months),
traffic_gb=human_value, traffic_gb=human_value,
), ),
@@ -89,7 +89,7 @@ async def pay_crypto_callback_handler(
try: try:
await callback.message.answer( await callback.message.answer(
get_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), months=int(months),
traffic_gb=human_value, traffic_gb=human_value,
), ),
@@ -1,4 +1,4 @@
import logging import logging
from datetime import datetime from datetime import datetime
from typing import Optional from typing import Optional
@@ -64,7 +64,7 @@ async def pay_fk_callback_handler(
sale_base = sale_mode.split("@", 1)[0].split("|", 1)[0] sale_base = sale_mode.split("@", 1)[0].split("|", 1)[0]
payment_description = ( payment_description = (
get_text("payment_description_traffic", traffic_gb=human_value) 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))) 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" 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", "provider": "freekassa",
"sale_mode": sale_mode, "sale_mode": sale_mode,
"tariff_key": sale_mode.split("@", 1)[1] if "@" in sale_mode else None, "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, "purchased_hwid_devices": int(months) if sale_base in {"hwid_device", "hwid_devices"} else None,
} }
@@ -1,4 +1,4 @@
import json import json
import logging import logging
from typing import Optional from typing import Optional
@@ -91,7 +91,7 @@ async def pay_platega_callback_handler(
sale_base = sale_mode.split("@", 1)[0].split("|", 1)[0] sale_base = sale_mode.split("@", 1)[0].split("|", 1)[0]
payment_description = ( payment_description = (
get_text("payment_description_traffic", traffic_gb=human_value) 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))) 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" currency_code = settings.DEFAULT_CURRENCY_SYMBOL or "RUB"
@@ -106,7 +106,7 @@ async def pay_platega_callback_handler(
"provider": "platega", "provider": "platega",
"sale_mode": sale_mode, "sale_mode": sale_mode,
"tariff_key": sale_mode.split("@", 1)[1] if "@" in sale_mode else None, "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, "purchased_hwid_devices": int(months) if sale_base in {"hwid_device", "hwid_devices"} else None,
} }
@@ -1,4 +1,4 @@
import logging import logging
from typing import Optional from typing import Optional
from aiogram import F, Router, types 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] sale_base = sale_mode.split("@", 1)[0].split("|", 1)[0]
payment_description = ( payment_description = (
get_text("payment_description_traffic", traffic_gb=human_value) 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))) 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" currency_code = settings.DEFAULT_CURRENCY_SYMBOL or "RUB"
@@ -78,7 +78,7 @@ async def pay_severpay_callback_handler(
"provider": "severpay", "provider": "severpay",
"sale_mode": sale_mode, "sale_mode": sale_mode,
"tariff_key": sale_mode.split("@", 1)[1] if "@" in sale_mode else None, "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, "purchased_hwid_devices": int(months) if sale_base in {"hwid_device", "hwid_devices"} else None,
} }
@@ -1,4 +1,4 @@
import logging import logging
from typing import Optional from typing import Optional
from aiogram import F, Router, types 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] sale_base = sale_mode.split("@", 1)[0].split("|", 1)[0]
payment_description = ( payment_description = (
get_text("payment_description_traffic", traffic_gb=human_value) 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))) 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: try:
await callback.message.edit_text( await callback.message.edit_text(
get_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), months=int(months),
traffic_gb=human_value, traffic_gb=human_value,
), ),
@@ -1,4 +1,4 @@
import logging import logging
from typing import List, Optional, Tuple from typing import List, Optional, Tuple
from aiogram import F, Router, types from aiogram import F, Router, types
@@ -84,7 +84,7 @@ async def _initiate_yk_payment(
sale_base = _sale_mode_base(sale_mode) sale_base = _sale_mode_base(sale_mode)
payment_description = ( payment_description = (
get_text("payment_description_traffic", traffic_gb=_format_value(months)) 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))) 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 = { payment_record_data = {
@@ -96,7 +96,7 @@ async def _initiate_yk_payment(
"subscription_duration_months": int(months), "subscription_duration_months": int(months),
"sale_mode": sale_base, "sale_mode": sale_base,
"tariff_key": sale_mode.split("@", 1)[1] if "@" in sale_mode else None, "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, "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), "payment_db_id": str(db_payment_record.payment_id),
"sale_mode": sale_mode, "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) yookassa_metadata["traffic_gb"] = str(months)
if payment_method_id: if payment_method_id:
yookassa_metadata["used_saved_payment_method_id"] = payment_method_id yookassa_metadata["used_saved_payment_method_id"] = payment_method_id
+6 -6
View File
@@ -1,4 +1,4 @@
import hashlib import hashlib
import logging import logging
import json import json
import hmac import hmac
@@ -91,7 +91,7 @@ class CryptoPayService:
"provider": "cryptopay", "provider": "cryptopay",
"sale_mode": sale_mode, "sale_mode": sale_mode,
"tariff_key": sale_mode.split("@", 1)[1] if "@" in sale_mode else None, "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() await session.commit()
@@ -107,7 +107,7 @@ class CryptoPayService:
"subscription_months": str(months), "subscription_months": str(months),
"payment_db_id": str(payment_record.payment_id), "payment_db_id": str(payment_record.payment_id),
"sale_mode": sale_mode, "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: try:
invoice = await self.client.create_invoice( invoice = await self.client.create_invoice(
@@ -184,7 +184,7 @@ class CryptoPayService:
payment_db_id, payment_db_id,
provider="cryptopay", provider="cryptopay",
sale_mode=sale_mode, 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 referral_bonus = None
if sale_base == "subscription": if sale_base == "subscription":
@@ -215,7 +215,7 @@ class CryptoPayService:
final_end = referral_bonus["referee_new_end_date"] final_end = referral_bonus["referee_new_end_date"]
applied_days = referral_bonus.get("referee_bonus_applied_days", 0) 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", text = _("payment_successful_traffic_full",
traffic_gb=str(int(traffic_gb)) if float(traffic_gb).is_integer() else f"{traffic_gb:g}", 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 "", end_date=final_end.strftime('%Y-%m-%d') if final_end else "",
@@ -271,7 +271,7 @@ class CryptoPayService:
amount=float(invoice.amount), amount=float(invoice.amount),
currency=invoice.asset or settings.DEFAULT_CURRENCY_SYMBOL, currency=invoice.asset or settings.DEFAULT_CURRENCY_SYMBOL,
months=int(months) if sale_base == "subscription" else 0, 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", payment_provider="crypto_pay",
username=user.username if user else None username=user.username if user else None
) )
+3 -3
View File
@@ -1,4 +1,4 @@
import asyncio import asyncio
from datetime import datetime from datetime import datetime
import hashlib import hashlib
import hmac import hmac
@@ -286,7 +286,7 @@ class FreeKassaService:
payment.payment_id, payment.payment_id,
provider="freekassa", provider="freekassa",
sale_mode=sale_mode, 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 referral_bonus = None
@@ -331,7 +331,7 @@ class FreeKassaService:
traffic_label = str(int(months)) if float(months).is_integer() else f"{months:g}" 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", text = _("payment_successful_traffic_full",
traffic_gb=traffic_label, traffic_gb=traffic_label,
end_date=end_date_str if final_end else "", end_date=end_date_str if final_end else "",
+70
View File
@@ -564,6 +564,76 @@ class PanelApiService:
logging.error("Failed to get bandwidth stats for user %s. Response: %s", user_uuid, response_data) logging.error("Failed to get bandwidth stats for user %s. Response: %s", user_uuid, response_data)
return None 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: async def reset_user_traffic(self, user_uuid: str) -> bool:
endpoint = f"/users/{user_uuid}/actions/reset-traffic" endpoint = f"/users/{user_uuid}/actions/reset-traffic"
response_data = await self._request("POST", endpoint, log_full_response=False) response_data = await self._request("POST", endpoint, log_full_response=False)
+3 -3
View File
@@ -1,4 +1,4 @@
import hmac import hmac
import json import json
import logging import logging
from decimal import Decimal, ROUND_HALF_UP from decimal import Decimal, ROUND_HALF_UP
@@ -214,7 +214,7 @@ class PlategaService:
payment.payment_id, payment.payment_id,
provider="platega", provider="platega",
sale_mode=sale_mode, 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 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}" 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 = _( text = _(
"payment_successful_traffic_full", "payment_successful_traffic_full",
traffic_gb=traffic_label, traffic_gb=traffic_label,
+3 -3
View File
@@ -1,4 +1,4 @@
import json import json
import logging import logging
import secrets import secrets
import hmac import hmac
@@ -210,7 +210,7 @@ class SeverPayService:
payment.payment_id, payment.payment_id,
provider="severpay", provider="severpay",
sale_mode=sale_mode, 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 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}" 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 = _( text = _(
"payment_successful_traffic_full", "payment_successful_traffic_full",
traffic_gb=traffic_label, traffic_gb=traffic_label,
+5 -5
View File
@@ -1,4 +1,4 @@
import logging import logging
from typing import Optional from typing import Optional
from aiogram import Bot, types from aiogram import Bot, types
@@ -39,7 +39,7 @@ class StarsService:
"provider": "telegram_stars", "provider": "telegram_stars",
"sale_mode": sale_mode, "sale_mode": sale_mode,
"tariff_key": sale_mode.split("@", 1)[1] if "@" in sale_mode else None, "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: try:
db_payment_record = await payment_dal.create_payment_record( db_payment_record = await payment_dal.create_payment_record(
@@ -103,7 +103,7 @@ class StarsService:
payment_db_id, payment_db_id,
provider="telegram_stars", provider="telegram_stars",
sale_mode=sale_mode, 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"): if not activation_details or not activation_details.get("end_date"):
logging.error( 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_display, connect_button_url = await prepare_config_links(self.settings, raw_config_link)
config_link_text = config_link_display or _("config_link_not_available") 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 = _( success_msg = _(
"payment_successful_traffic_full", "payment_successful_traffic_full",
traffic_gb=str(int(months)) if float(months).is_integer() else f"{months:g}", 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, months=int(months) if sale_base == "subscription" else 0,
payment_provider="stars", payment_provider="stars",
username=user.username if user else None, 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: except Exception as e:
logging.error(f"Failed to send stars payment notification: {e}") logging.error(f"Failed to send stars payment notification: {e}")
+275 -8
View File
@@ -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 db.dal import user_dal, subscription_dal, promo_code_dal, payment_dal, user_billing_dal, tariff_dal
from config.tariffs_config import Tariff 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 bot.utils.config_link import prepare_config_links
from db.models import User, Subscription from db.models import User, Subscription
@@ -31,6 +31,7 @@ class SubscriptionService:
self.panel_service = panel_service self.panel_service = panel_service
self.bot = bot self.bot = bot
self.i18n = i18n self.i18n = i18n
self._premium_access_cache: Dict[Tuple[str, ...], Dict[str, Any]] = {}
@staticmethod @staticmethod
def gb_to_bytes(gb: float) -> int: 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}") raise ValueError(f"Tariff {tariff.key} is {tariff.billing_model}, expected {billing_model}")
return tariff 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: 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 return self.settings.parsed_user_squad_uuids
def _traffic_limit_for_period_tariff(self, tariff: Optional[Tariff], topup_balance_bytes: int = 0) -> int: 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 int(tariff.monthly_bytes + max(0, topup_balance_bytes))
return self.settings.user_traffic_limit_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]: def _base_hwid_limit_for_tariff(self, tariff: Optional[Tariff]) -> Optional[int]:
if tariff and tariff.hwid_device_limit is not None: if tariff and tariff.hwid_device_limit is not None:
return int(tariff.hwid_device_limit) return int(tariff.hwid_device_limit)
@@ -649,6 +737,12 @@ class SubscriptionService:
"tariff_key": tariff.key if tariff else None, "tariff_key": tariff.key if tariff else None,
"tier_baseline_bytes": 0, "tier_baseline_bytes": 0,
"topup_balance_bytes": new_balance, "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, "period_start_at": None,
"is_throttled": False, "is_throttled": False,
"effective_monthly_price_rub": None, "effective_monthly_price_rub": None,
@@ -671,7 +765,7 @@ class SubscriptionService:
hwid_device_limit=effective_hwid_limit, hwid_device_limit=effective_hwid_limit,
) )
if tariff: 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)) panel_update_payload.update(self._panel_identity_payload_for_user(db_user))
@@ -785,7 +879,10 @@ class SubscriptionService:
traffic_limit_bytes=new_limit, traffic_limit_bytes=new_limit,
hwid_device_limit=effective_hwid_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)) 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 self.panel_service.update_user_details_on_panel(db_user.panel_user_uuid, panel_payload)
await tariff_dal.create_traffic_topup( await tariff_dal.create_traffic_topup(
@@ -802,6 +899,93 @@ class SubscriptionService:
"tariff_key": tariff.key, "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( async def activate_hwid_device_topup(
self, self,
session: AsyncSession, session: AsyncSession,
@@ -966,7 +1150,23 @@ class SubscriptionService:
before_tariff_key = sub.tariff_key before_tariff_key = sub.tariff_key
options = self.calculate_tariff_switch_options(sub, target) options = self.calculate_tariff_switch_options(sub, target)
now = datetime.now(timezone.utc) 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 converted_bytes = None
base_hwid_limit = self._base_hwid_limit_for_tariff(target) base_hwid_limit = self._base_hwid_limit_for_tariff(target)
extra_hwid_devices = int(sub.extra_hwid_devices or 0) 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", 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), 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)) 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 self.panel_service.update_user_details_on_panel(db_user.panel_user_uuid, panel_payload)
if converted_bytes: if converted_bytes:
@@ -1092,6 +1295,29 @@ class SubscriptionService:
payment_db_id=payment_db_id, payment_db_id=payment_db_id,
provider=provider, 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"}: if sale_mode_base in {"hwid_device", "hwid_devices"}:
target_devices = int(traffic_gb if traffic_gb is not None else months) target_devices = int(traffic_gb if traffic_gb is not None else months)
return await self.activate_hwid_device_topup( 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) 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) 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 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) effective_monthly_price = float(payment_amount) / max(1, months_int)
traffic_limit_bytes = self._traffic_limit_for_period_tariff(tariff, topup_balance_bytes) traffic_limit_bytes = self._traffic_limit_for_period_tariff(tariff, topup_balance_bytes)
base_hwid_limit = self._base_hwid_limit_for_tariff(tariff) base_hwid_limit = self._base_hwid_limit_for_tariff(tariff)
effective_hwid_limit = self._effective_hwid_limit(base_hwid_limit, extra_hwid_devices) 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 = { sub_payload = {
"user_id": user_id, "user_id": user_id,
"panel_user_uuid": panel_user_uuid, "panel_user_uuid": panel_user_uuid,
@@ -1260,6 +1497,12 @@ class SubscriptionService:
"tariff_key": tariff.key if tariff else None, "tariff_key": tariff.key if tariff else None,
"tier_baseline_bytes": tier_baseline_bytes, "tier_baseline_bytes": tier_baseline_bytes,
"topup_balance_bytes": topup_balance_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, "period_start_at": None,
"is_throttled": False, "is_throttled": False,
"effective_monthly_price_rub": effective_monthly_price, "effective_monthly_price_rub": effective_monthly_price,
@@ -1286,7 +1529,10 @@ class SubscriptionService:
hwid_device_limit=effective_hwid_limit, hwid_device_limit=effective_hwid_limit,
) )
if tariff: 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)) panel_update_payload.update(self._panel_identity_payload_for_user(db_user))
@@ -1552,6 +1798,14 @@ class SubscriptionService:
tariff = None tariff = None
billing_model_display = tariff.billing_model if tariff else ("traffic" if getattr(self.settings, "traffic_sale_mode", False) else "period") 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 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 { return {
"user_id": panel_user_data.get("uuid"), "user_id": panel_user_data.get("uuid"),
@@ -1568,6 +1822,19 @@ class SubscriptionService:
"billing_model": billing_model_display, "billing_model": billing_model_display,
"tier_baseline_bytes": local_active_sub.tier_baseline_bytes if local_active_sub else None, "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, "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, "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, "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, "base_hwid_device_limit": local_active_sub.hwid_device_limit if local_active_sub else None,
+239 -1
View File
@@ -17,6 +17,8 @@ from config.settings import Settings
from db.dal import subscription_dal, tariff_dal from db.dal import subscription_dal, tariff_dal
from db.models import Subscription from db.models import Subscription
PREMIUM_WARNING_LEVEL_OFFSET = 1000
class TariffTrafficWorker: class TariffTrafficWorker:
def __init__( def __init__(
@@ -35,6 +37,7 @@ class TariffTrafficWorker:
self.bot = bot self.bot = bot
self.i18n = i18n self.i18n = i18n
self._stopped = asyncio.Event() self._stopped = asyncio.Event()
self._premium_nodes_cache = {}
async def run(self) -> None: async def run(self) -> None:
if not self.settings.tariffs_config: 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, 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( async def _ensure_period_reset_strategy(
self, self,
sub: Subscription, sub: Subscription,
@@ -109,7 +114,10 @@ class TariffTrafficWorker:
traffic_limit_bytes=traffic_limit_bytes, traffic_limit_bytes=traffic_limit_bytes,
traffic_limit_strategy="MONTH", 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) await self.panel_service.update_user_details_on_panel(sub.panel_user_uuid, payload, log_response=False)
async def _maybe_warn_or_throttle( async def _maybe_warn_or_throttle(
@@ -176,6 +184,236 @@ class TariffTrafficWorker:
sub.subscription_id, 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: async def legacy_throttle_recovery_tick(self, session: AsyncSession) -> None:
"""Recover subscriptions throttled by older bot versions. """Recover subscriptions throttled by older bot versions.
+21 -10
View File
@@ -1,23 +1,34 @@
{ {
"default_tariff": "standard", "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": [ "tariffs": [
{ {
"key": "standard", "key": "standard",
"names": { "ru": "Стандарт", "en": "Standard" }, "names": { "ru": "Стандарт", "en": "Standard" },
"descriptions": { "ru": "Базовый набор серверов", "en": "Base server pool" }, "descriptions": { "ru": "Базовый набор серверов", "en": "Base server pool" },
"squad_uuids": ["uuid-1", "uuid-2"], "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", "billing_model": "period",
"monthly_gb": 500, "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_limit": 5,
"hwid_device_packages": { "hwid_device_packages": {
"rub": [ "rub": [
+22 -1
View File
@@ -76,6 +76,9 @@ class Tariff(BaseModel):
conversion_rate_rub_per_gb: Optional[float] = None conversion_rate_rub_per_gb: Optional[float] = None
hwid_device_limit: Optional[int] = None hwid_device_limit: Optional[int] = None
hwid_device_packages: Optional[HwidDevicePackageSet] = 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") @model_validator(mode="after")
def validate_tariff(self) -> "Tariff": def validate_tariff(self) -> "Tariff":
@@ -83,8 +86,17 @@ class Tariff(BaseModel):
raise ValueError("tariff key must not be empty") raise ValueError("tariff key must not be empty")
self.key = self.key.strip() self.key = self.key.strip()
self.squad_uuids = [uuid.strip() for uuid in self.squad_uuids if uuid.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: 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") 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.billing_model == "period":
if self.monthly_gb is None or self.monthly_gb < 0: 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: def has_hwid_device_packages(self) -> bool:
return bool(self.hwid_device_packages and self.hwid_device_packages.has_any()) 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): class TariffsConfig(BaseModel):
default_tariff: str default_tariff: str
@@ -189,7 +210,7 @@ class TariffsConfig(BaseModel):
def topup_packages_for(self, tariff: Tariff) -> Optional[PackageSet]: def topup_packages_for(self, tariff: Tariff) -> Optional[PackageSet]:
if tariff.billing_model == "traffic": if tariff.billing_model == "traffic":
return tariff.traffic_packages 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]: def load_tariffs_config(path: str | Path) -> Optional[TariffsConfig]:
+6
View File
@@ -109,6 +109,11 @@ async def init_db(settings: Settings, session_factory: sessionmaker):
tariff_key = COALESCE(s.tariff_key, :tariff_key), tariff_key = COALESCE(s.tariff_key, :tariff_key),
tier_baseline_bytes = COALESCE(s.tier_baseline_bytes, s.traffic_limit_bytes, :baseline), tier_baseline_bytes = COALESCE(s.tier_baseline_bytes, s.traffic_limit_bytes, :baseline),
topup_balance_bytes = COALESCE(s.topup_balance_bytes, 0), 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, period_start_at = NULL,
effective_monthly_price_rub = COALESCE( effective_monthly_price_rub = COALESCE(
s.effective_monthly_price_rub, s.effective_monthly_price_rub,
@@ -130,6 +135,7 @@ async def init_db(settings: Settings, session_factory: sessionmaker):
{ {
"tariff_key": default_tariff.key, "tariff_key": default_tariff.key,
"baseline": default_tariff.monthly_bytes, "baseline": default_tariff.monthly_bytes,
"premium_baseline": default_tariff.premium_monthly_bytes,
"default_price": default_price, "default_price": default_price,
}, },
) )
+60
View File
@@ -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") sub_statements.append("ALTER TABLE subscriptions ADD COLUMN tier_baseline_bytes BIGINT")
if "topup_balance_bytes" not in sub_columns: if "topup_balance_bytes" not in sub_columns:
sub_statements.append("ALTER TABLE subscriptions ADD COLUMN topup_balance_bytes BIGINT NOT NULL DEFAULT 0") 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: if "period_start_at" not in sub_columns:
sub_statements.append("ALTER TABLE subscriptions ADD COLUMN period_start_at TIMESTAMPTZ") sub_statements.append("ALTER TABLE subscriptions ADD COLUMN period_start_at TIMESTAMPTZ")
if "is_throttled" not in sub_columns: if "is_throttled" not in sub_columns:
@@ -430,6 +442,7 @@ def _migration_0012_add_tariffs_schema(connection: Connection) -> None:
for stmt in [ for stmt in [
"CREATE INDEX IF NOT EXISTS ix_subscriptions_tariff_key ON subscriptions (tariff_key)", "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_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_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_payments_tariff_key ON payments (tariff_key)",
"CREATE INDEX IF NOT EXISTS ix_traffic_topups_subscription_id ON traffic_topups (subscription_id)", "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] = [ MIGRATIONS: List[Migration] = [
Migration( Migration(
id="0001_add_channel_subscription_fields", 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,
),
] ]
+6
View File
@@ -113,6 +113,12 @@ class Subscription(Base):
tariff_key = Column(String, nullable=True, index=True) tariff_key = Column(String, nullable=True, index=True)
tier_baseline_bytes = Column(BigInteger, nullable=True) tier_baseline_bytes = Column(BigInteger, nullable=True)
topup_balance_bytes = Column(BigInteger, nullable=False, default=0) 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) period_start_at = Column(DateTime(timezone=True), nullable=True)
is_throttled = Column(Boolean, nullable=False, default=False, index=True) is_throttled = Column(Boolean, nullable=False, default=False, index=True)
effective_monthly_price_rub = Column(Numeric, nullable=True) effective_monthly_price_rub = Column(Numeric, nullable=True)
+8
View File
@@ -79,6 +79,14 @@ nano .env
Если файл из `TARIFFS_CONFIG_PATH` существует, бот использует каталог тарифов. Если файла нет, применяется конфигурация из переменных `.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-вход ## Web App и email-вход
| Переменная | Назначение | | Переменная | Назначение |
+51 -7
View File
@@ -41,10 +41,6 @@ JSON-каталог может содержать несколько тариф
```json ```json
{ {
"default_tariff": "standard", "default_tariff": "standard",
"topup_packages_default": {
"rub": [{ "gb": 10, "price": 99 }],
"stars": [{ "gb": 10, "price": 2500 }]
},
"tariffs": [ "tariffs": [
{ {
"key": "standard", "key": "standard",
@@ -55,6 +51,10 @@ JSON-каталог может содержать несколько тариф
"monthly_gb": 500, "monthly_gb": 500,
"prices_rub": { "1": 150, "3": 400 }, "prices_rub": { "1": 150, "3": 400 },
"enabled_periods": [1, 3], "enabled_periods": [1, 3],
"topup_packages": {
"rub": [{ "gb": 10, "price": 99 }],
"stars": [{ "gb": 10, "price": 2500 }]
},
"enabled": true "enabled": true
} }
] ]
@@ -66,12 +66,14 @@ JSON-каталог может содержать несколько тариф
| Поле | Назначение | | Поле | Назначение |
| --- | --- | | --- | --- |
| `default_tariff` | Тариф по умолчанию для первичного выбора и привязки активных подписок без `tariff_key`. | | `default_tariff` | Тариф по умолчанию для первичного выбора и привязки активных подписок без `tariff_key`. |
| `topup_packages_default` | Пакеты докупки трафика для period-тарифов, у которых не задан `topup_packages`. |
| `tariffs[].key` | Стабильный ключ тарифа. Используется в платежах, подписках и смене тарифа. | | `tariffs[].key` | Стабильный ключ тарифа. Используется в платежах, подписках и смене тарифа. |
| `tariffs[].names` | Названия тарифа по языкам. | | `tariffs[].names` | Названия тарифа по языкам. |
| `tariffs[].descriptions` | Описания тарифа по языкам. | | `tariffs[].descriptions` | Описания тарифа по языкам. |
| `tariffs[].enabled` | Доступность тарифа на витрине. | | `tariffs[].enabled` | Доступность тарифа на витрине. |
| `tariffs[].squad_uuids` | Internal Squads Remnawave для пользователей тарифа. | | `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[].billing_model` | Модель тарифа: `period` или `traffic`. |
| `tariffs[].hwid_device_limit` | Базовый лимит HWID-устройств. `0` означает безлимит, отсутствие поля использует `USER_HWID_DEVICE_LIMIT`. | | `tariffs[].hwid_device_limit` | Базовый лимит HWID-устройств. `0` означает безлимит, отсутствие поля использует `USER_HWID_DEVICE_LIMIT`. |
| `tariffs[].hwid_device_packages` | Пакеты докупки устройств: `{ "count": 1, "price": 99 }`. | | `tariffs[].hwid_device_packages` | Пакеты докупки устройств: `{ "count": 1, "price": 99 }`. |
@@ -84,7 +86,7 @@ JSON-каталог может содержать несколько тариф
| `prices_rub` | Цены периодов в рублях, ключ - количество месяцев. | | `prices_rub` | Цены периодов в рублях, ключ - количество месяцев. |
| `prices_stars` | Цены периодов в Telegram Stars. | | `prices_stars` | Цены периодов в Telegram Stars. |
| `enabled_periods` | Периоды, доступные для покупки. | | `enabled_periods` | Периоды, доступные для покупки. |
| `topup_packages` | Пакеты докупки трафика именно для этого тарифа. | | `topup_packages` | Пакеты докупки трафика именно для этого тарифа. Если поле не задано или списки пустые, докупка для тарифа не показывается в Web App и Telegram-боте. |
Для `traffic`-тарифа используются: Для `traffic`-тарифа используются:
@@ -115,6 +117,48 @@ JSON-каталог может содержать несколько тариф
Докупка трафика для period-тарифа увеличивает `topup_balance_bytes` и общий `traffic_limit_bytes`. Этот баланс сохраняется в подписке и учитывается при продлении period-тарифа. В панель отправляется актуальный лимит, а доступ переводится в `ACTIVE`. Докупка трафика для 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-тарифы
`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` | Ключ тарифа, к которому относится платеж. | | `tariff_key` | Ключ тарифа, к которому относится платеж. |
| `purchased_gb` | Купленный объем GB для traffic-пакетов и докупки трафика. | | `purchased_gb` | Купленный объем GB для traffic-пакетов и докупки трафика. |
| `purchased_hwid_devices` | Количество устройств при докупке HWID. | | `purchased_hwid_devices` | Количество устройств при докупке HWID. |
+7
View File
@@ -632,6 +632,12 @@
"wa_home_subscription_inactive": "Subscription inactive", "wa_home_subscription_inactive": "Subscription inactive",
"wa_until_date": "until {date}", "wa_until_date": "until {date}",
"wa_home_traffic_used": "Traffic used", "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_renew": "Renew",
"wa_pay_subscription": "Pay subscription", "wa_pay_subscription": "Pay subscription",
"wa_subscription_title": "Subscription", "wa_subscription_title": "Subscription",
@@ -672,6 +678,7 @@
"wa_confirm_and_apply": "Confirm and apply", "wa_confirm_and_apply": "Confirm and apply",
"wa_confirm_and_pay": "Confirm and pay", "wa_confirm_and_pay": "Confirm and pay",
"wa_topup_for_tariff": "Packages for {tariff}", "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_levels": "Traffic warnings are sent at {levels}%.",
"wa_topup_warning_medium": "{percent}% of traffic is used. Warnings are set at {levels}%.", "wa_topup_warning_medium": "{percent}% of traffic is used. Warnings are set at {levels}%.",
"wa_topup_warning_high": "{percent}% of traffic is used. Buying a package now is recommended.", "wa_topup_warning_high": "{percent}% of traffic is used. Buying a package now is recommended.",
+7
View File
@@ -632,6 +632,12 @@
"wa_home_subscription_inactive": "Подписка не активна", "wa_home_subscription_inactive": "Подписка не активна",
"wa_until_date": "до {date}", "wa_until_date": "до {date}",
"wa_home_traffic_used": "Использовано трафика", "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_renew": "Продлить",
"wa_pay_subscription": "Оплатить подписку", "wa_pay_subscription": "Оплатить подписку",
"wa_subscription_title": "Подписка", "wa_subscription_title": "Подписка",
@@ -672,6 +678,7 @@
"wa_confirm_and_apply": "Подтвердить и применить", "wa_confirm_and_apply": "Подтвердить и применить",
"wa_confirm_and_pay": "Подтвердить и оплатить", "wa_confirm_and_pay": "Подтвердить и оплатить",
"wa_topup_for_tariff": "Пакеты для тарифа {tariff}", "wa_topup_for_tariff": "Пакеты для тарифа {tariff}",
"wa_topup_carryover": "Докупленный трафик не сгорает: сначала расходуется месячный лимит, затем докупленный остаток.",
"wa_topup_warning_levels": "Предупреждения о трафике приходят на {levels}%.", "wa_topup_warning_levels": "Предупреждения о трафике приходят на {levels}%.",
"wa_topup_warning_medium": "Использовано {percent}% трафика. Предупреждения настроены на {levels}%.", "wa_topup_warning_medium": "Использовано {percent}% трафика. Предупреждения настроены на {levels}%.",
"wa_topup_warning_high": "Использовано {percent}% трафика. Лучше докупить пакет заранее.", "wa_topup_warning_high": "Использовано {percent}% трафика. Лучше докупить пакет заранее.",
+131
View File
@@ -124,3 +124,134 @@ class TariffWorkerTests(unittest.IsolatedAsyncioTestCase):
panel_service.remove_users_from_internal_squad.assert_not_awaited() panel_service.remove_users_from_internal_squad.assert_not_awaited()
self.assertFalse(sub.is_throttled) 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))
+41
View File
@@ -53,6 +53,24 @@ class TariffsConfigTests(unittest.TestCase):
self.assertEqual(config.default.key, "standard") self.assertEqual(config.default.key, "standard")
self.assertEqual(config.require("traffic").rub_per_gb_for_conversion(), 19.9) 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): def test_missing_config_returns_none(self):
import tempfile import tempfile
from pathlib import Path from pathlib import Path
@@ -110,3 +128,26 @@ class TariffsConfigTests(unittest.TestCase):
with self.assertRaises(ValueError): with self.assertRaises(ValueError):
TariffsConfig.model_validate(data) 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)
+27
View File
@@ -126,6 +126,33 @@ class WebAppAssetTests(unittest.IsolatedAsyncioTestCase):
self.assertEqual(plans[0]["price"], 199.0) self.assertEqual(plans[0]["price"], 199.0)
self.assertEqual(plans[1]["stars_price"], 2500) 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): def test_serialize_plans_includes_stars_only_subscription_options(self):
settings = Settings( settings = Settings(
_env_file=None, _env_file=None,