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",
|
||||
)
|
||||
tmp_path.replace(path)
|
||||
payload = json.dumps(data, ensure_ascii=False, indent=2) + "\n"
|
||||
try:
|
||||
tmp_path.write_text(payload, encoding="utf-8")
|
||||
tmp_path.replace(path)
|
||||
except PermissionError:
|
||||
# A docker-compose single-file bind mount can make /app/config
|
||||
# unwritable while the mounted tariffs.json itself is writable.
|
||||
# Fall back to updating the existing file in-place.
|
||||
if tmp_path.exists():
|
||||
try:
|
||||
tmp_path.unlink()
|
||||
except OSError:
|
||||
pass
|
||||
path.write_text(payload, encoding="utf-8")
|
||||
|
||||
|
||||
# ─── Routes ────────────────────────────────────────────────────────
|
||||
@@ -1225,6 +1234,36 @@ async def admin_tariffs_save_route(request: web.Request) -> web.Response:
|
||||
return _ok({"exists": True, "path": str(path), "catalog": _tariffs_config_payload(config)})
|
||||
|
||||
|
||||
async def admin_panel_internal_squads_route(request: web.Request) -> web.Response:
|
||||
_require_admin_user_id(request)
|
||||
panel_service = request.app.get("panel_service")
|
||||
if panel_service is None:
|
||||
return _error(503, "panel_unavailable", "Panel service unavailable")
|
||||
try:
|
||||
squads = await panel_service.get_internal_squads()
|
||||
except Exception as exc:
|
||||
logger.exception("Failed to load internal squads from panel")
|
||||
return _error(502, "panel_request_failed", str(exc))
|
||||
if squads is None:
|
||||
return _error(502, "panel_request_failed", "Unable to load internal squads")
|
||||
items = []
|
||||
for squad in squads:
|
||||
if not isinstance(squad, dict):
|
||||
continue
|
||||
uuid = squad.get("uuid") or squad.get("id")
|
||||
if not uuid:
|
||||
continue
|
||||
items.append(
|
||||
{
|
||||
"uuid": str(uuid),
|
||||
"name": squad.get("name") or squad.get("title") or str(uuid),
|
||||
"members_count": squad.get("membersCount") or squad.get("usersCount") or squad.get("members_count"),
|
||||
"active_inbounds_count": squad.get("activeInboundsCount") or squad.get("active_inbounds_count"),
|
||||
}
|
||||
)
|
||||
return _ok({"squads": items})
|
||||
|
||||
|
||||
# ─── Router setup ──────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -1265,3 +1304,4 @@ def setup_admin_routes(app: web.Application) -> None:
|
||||
|
||||
router.add_get("/api/admin/tariffs", admin_tariffs_get_route)
|
||||
router.add_put("/api/admin/tariffs", admin_tariffs_save_route)
|
||||
router.add_get("/api/admin/panel/internal-squads", admin_panel_internal_squads_route)
|
||||
|
||||
@@ -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>
|
||||
<small>{plan.subtitle || topupOptions.tariff_name}</small>
|
||||
{#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,
|
||||
|
||||
Reference in New Issue
Block a user