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