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