{#if paymentsLoading}
-
+
{:else if !payments.length}
{at("payments_empty", {}, "Нет платежей")}
{:else}
@@ -49,6 +107,9 @@
| {at("id", {}, "ID")} |
{at("user", {}, "Пользователь")} |
+ {at("payments_col_telegram_id", {}, "Telegram ID")} |
+ {at("payments_col_traffic_regular", {}, "Основной трафик")} |
+ {at("payments_col_traffic_premium", {}, "Премиум")} |
{at("amount", {}, "Сумма")} |
{at("provider", {}, "Провайдер")} |
{at("description", {}, "Описание")} |
@@ -60,10 +121,33 @@
{#each payments as p}
| #{p.payment_id} |
- {p.user_label || p.user_id} |
+
+
+ onOpenUserCard(p.user_id)}
+ >
+
+
+ {p.user_label || p.user_id}
+
+ |
+
+ {p.telegram_id != null ? p.telegram_id : "—"}
+ |
+
+ {formatTrafficGbCell(p.traffic_regular_gb)}
+ |
+
+ {formatTrafficGbCell(p.traffic_premium_gb)}
+ |
{fmtMoney(p.amount, p.currency)} |
{p.provider} |
- {p.description || "—"} |
+ {paymentDescriptionDisplay(p)} |
{p.status}
|
@@ -81,6 +165,47 @@
nextLabel={at("next", {}, "Далее")}
prevDisabled={paymentsPage === 0}
nextDisabled={!paymentsHasMore}
- onPrev={() => { paymentsStore.setPage(Math.max(0, paymentsPage - 1)); }}
- onNext={() => { paymentsStore.setPage(paymentsPage + 1); }}
+ onPrev={() => {
+ paymentsStore.setPage(Math.max(0, paymentsPage - 1));
+ }}
+ onNext={() => {
+ paymentsStore.setPage(paymentsPage + 1);
+ }}
/>
+
+
diff --git a/bot/app/web/frontend/src/admin/sections/UserDetailModal.svelte b/bot/app/web/frontend/src/admin/sections/UserDetailModal.svelte
index 277bcea..32ab908 100644
--- a/bot/app/web/frontend/src/admin/sections/UserDetailModal.svelte
+++ b/bot/app/web/frontend/src/admin/sections/UserDetailModal.svelte
@@ -1,7 +1,7 @@
@@ -177,7 +179,17 @@
warning={openedUserDetail.active_subscription.is_throttled}
label={at("aria_label_main_traffic", {}, "Использование основного трафика")}
/>
- {#if Number(openedUserDetail.active_subscription.premium_limit_bytes || 0) > 0}
+ {#if openedUserDetail.active_subscription.premium_unlimited_override}
+
+ {:else if Number(openedUserDetail.active_subscription.premium_limit_bytes || 0) > 0}
+ {#if openedUserDetail?.active_subscription}
+
+
+
+
+
+
+
+
+ {at("user_traffic_grant_kind", {}, "Тип трафика")}
+ usersStore.updateState({ grantTrafficKindDraft: v })}
+ ariaLabel={at("user_traffic_grant_kind", {}, "Тип трафика")}
+ />
+
+
+ {at("user_traffic_grant_gb", {}, "ГБ к выдаче")}
+
+
+
+ {at("user_traffic_grant_submit", {}, "Выдать")}
+
+
+
+
+
+ {/if}
+
{at("user_label_telegram_msg", {}, "Сообщение в Telegram")}
{at("user_hint_telegram_msg", {}, "Поддерживается HTML-разметка Telegram")}
@@ -339,3 +494,106 @@
+
+
diff --git a/bot/app/web/frontend/src/lib/admin/stores/usersStore.js b/bot/app/web/frontend/src/lib/admin/stores/usersStore.js
index 8c78030..7664486 100644
--- a/bot/app/web/frontend/src/lib/admin/stores/usersStore.js
+++ b/bot/app/web/frontend/src/lib/admin/stores/usersStore.js
@@ -23,6 +23,12 @@ export function createUsersStore({ api, onToast, at }) {
userBanConfirmOpen: false,
userMessageConfirmOpen: false,
userDetailTab: "profile",
+ premiumUnlimitedDraft: false,
+ premiumBonusGbDraft: "",
+ regularUnlimitedDraft: false,
+ regularBonusGbDraft: "",
+ grantTrafficGbDraft: "",
+ grantTrafficKindDraft: "regular",
});
let _activeRef = "stats"; // fallback if active isn't tracked
@@ -86,10 +92,19 @@ export function createUsersStore({ api, onToast, at }) {
try {
const res = await api(`/admin/users/${userId}`);
if (res?.ok) {
+ const sub = res.active_subscription || null;
+ const bonusBytes = Number(sub?.premium_bonus_bytes || 0);
+ const regularBonusBytes = Number(sub?.regular_bonus_bytes || 0);
state.update(s => ({
...s,
openedUserDetail: res,
- openedUser: res.user ? { ...res.user, ...s.openedUser, ...res.user } : s.openedUser
+ openedUser: res.user ? { ...res.user, ...s.openedUser, ...res.user } : s.openedUser,
+ premiumUnlimitedDraft: Boolean(sub?.premium_unlimited_override),
+ premiumBonusGbDraft: bonusBytes > 0 ? +(bonusBytes / (1024 ** 3)).toFixed(2) : "",
+ regularUnlimitedDraft: Boolean(sub?.regular_unlimited_override),
+ regularBonusGbDraft: regularBonusBytes > 0 ? +(regularBonusBytes / (1024 ** 3)).toFixed(2) : "",
+ grantTrafficGbDraft: "",
+ grantTrafficKindDraft: "regular",
}));
} else {
onToast(res?.error || "load_failed");
@@ -249,6 +264,103 @@ export function createUsersStore({ api, onToast, at }) {
}
}
+ async function savePremiumTrafficOverride() {
+ let s;
+ state.update(st => { s = st; return st; });
+ if (!s.openedUser) return;
+ state.update(st => ({ ...st, userActionBusy: true }));
+ try {
+ const bonusGbRaw = s.premiumBonusGbDraft;
+ const bonusGb = bonusGbRaw === "" || bonusGbRaw === null || bonusGbRaw === undefined
+ ? 0
+ : Number(bonusGbRaw);
+ if (Number.isNaN(bonusGb) || bonusGb < 0) {
+ onToast(at("premium_override_invalid_bonus", {}, "Некорректное значение GB"));
+ return;
+ }
+ const res = await api(`/admin/users/${s.openedUser.user_id}/premium-override`, {
+ method: "POST",
+ body: JSON.stringify({
+ unlimited: Boolean(s.premiumUnlimitedDraft),
+ bonus_gb: bonusGb,
+ }),
+ });
+ if (res?.ok) {
+ onToast(at("premium_override_saved", {}, "Премиум-оверрайд сохранён"));
+ await openUser(s.openedUser, { skipPush: true });
+ } else {
+ onToast(res?.error || at("error", {}, "Ошибка"));
+ }
+ } finally {
+ state.update(st => ({ ...st, userActionBusy: false }));
+ }
+ }
+
+ async function saveRegularTrafficOverride() {
+ let s;
+ state.update(st => { s = st; return st; });
+ if (!s.openedUser) return;
+ state.update(st => ({ ...st, userActionBusy: true }));
+ try {
+ const regGbRaw = s.regularBonusGbDraft;
+ const regularGb = regGbRaw === "" || regGbRaw === null || regGbRaw === undefined
+ ? 0
+ : Number(regGbRaw);
+ if (Number.isNaN(regularGb) || regularGb < 0) {
+ onToast(at("regular_override_invalid_bonus", {}, "Некорректное значение GB для основного трафика"));
+ return;
+ }
+ const res = await api(`/admin/users/${s.openedUser.user_id}/regular-traffic-override`, {
+ method: "POST",
+ body: JSON.stringify({
+ unlimited: Boolean(s.regularUnlimitedDraft),
+ regular_bonus_gb: regularGb,
+ }),
+ });
+ if (res?.ok) {
+ onToast(at("regular_override_saved", {}, "Оверрайд основного трафика сохранён"));
+ await openUser(s.openedUser, { skipPush: true });
+ } else {
+ onToast(res?.error || at("error", {}, "Ошибка"));
+ }
+ } finally {
+ state.update(st => ({ ...st, userActionBusy: false }));
+ }
+ }
+
+ async function grantTraffic() {
+ let s;
+ state.update(st => { s = st; return st; });
+ if (!s.openedUser) return;
+ const gbRaw = s.grantTrafficGbDraft;
+ const gb = Number(gbRaw);
+ if (!gbRaw || Number.isNaN(gb) || gb <= 0) {
+ onToast(at("traffic_grant_invalid_gb", {}, "Введите положительное число GB"));
+ return;
+ }
+ const kind = s.grantTrafficKindDraft === "premium" ? "premium" : "regular";
+ state.update(st => ({ ...st, userActionBusy: true }));
+ try {
+ const res = await api(`/admin/users/${s.openedUser.user_id}/traffic-grant`, {
+ method: "POST",
+ body: JSON.stringify({ kind, gb }),
+ });
+ if (res?.ok) {
+ onToast(
+ kind === "premium"
+ ? at("traffic_grant_premium_done", { gb }, `+${gb} ГБ премиум-трафика`)
+ : at("traffic_grant_regular_done", { gb }, `+${gb} ГБ трафика`)
+ );
+ state.update(st => ({ ...st, grantTrafficGbDraft: "" }));
+ await openUser(s.openedUser, { skipPush: true });
+ } else {
+ onToast(res?.error || at("error", {}, "Ошибка"));
+ }
+ } finally {
+ state.update(st => ({ ...st, userActionBusy: false }));
+ }
+ }
+
async function deleteUser() {
let s;
state.update(st => { s = st; return st; });
@@ -291,5 +403,8 @@ export function createUsersStore({ api, onToast, at }) {
extendUser,
resetTrialUser,
deleteUser,
+ savePremiumTrafficOverride,
+ saveRegularTrafficOverride,
+ grantTraffic,
};
}
diff --git a/bot/app/web/frontend/src/styles/admin-controls.css b/bot/app/web/frontend/src/styles/admin-controls.css
index 926a138..7e8d957 100644
--- a/bot/app/web/frontend/src/styles/admin-controls.css
+++ b/bot/app/web/frontend/src/styles/admin-controls.css
@@ -267,6 +267,17 @@
flex: 0 0 auto;
}
+.admin-extend-control .admin-btn.admin-btn-primary {
+ background: var(--accent);
+ color: #02110a;
+ border-color: color-mix(in srgb, var(--accent) 70%, #000);
+ font-weight: 600;
+}
+
+.admin-extend-control .admin-btn.admin-btn-primary:hover:not(:disabled) {
+ background: color-mix(in srgb, var(--accent) 90%, #fff);
+}
+
.admin-input-row .input,
.admin-input-row .admin-btn {
height: 36px;
diff --git a/bot/app/web/frontend/src/webapp/screens/HomeScreen.svelte b/bot/app/web/frontend/src/webapp/screens/HomeScreen.svelte
index fbb0a0b..3b89ee1 100644
--- a/bot/app/web/frontend/src/webapp/screens/HomeScreen.svelte
+++ b/bot/app/web/frontend/src/webapp/screens/HomeScreen.svelte
@@ -100,7 +100,34 @@
{trafficPercent(subscription)}%
- {#if Number(subscription?.premium_limit_bytes || 0) > 0}
+ {#if subscription?.premium_unlimited_override}
+
+
+ {premiumTitle(subscription)}
+ ∞
+
+
+
+ {:else if Number(subscription?.premium_limit_bytes || 0) > 0}
{#if canOpenPremiumTopupModal}
diff --git a/bot/app/web/subscription_webapp.py b/bot/app/web/subscription_webapp.py
index 5787571..ecb0b7a 100644
--- a/bot/app/web/subscription_webapp.py
+++ b/bot/app/web/subscription_webapp.py
@@ -2113,10 +2113,13 @@ async def tariff_topup_options_route(request: web.Request) -> web.Response:
sale_mode="premium_topup",
title_prefix=f"{tariff.premium_name(lang)} ",
) if topup_kind in {"all", "premium"} and tariff.premium_squad_uuids else []
+ premium_bonus_bytes = int(getattr(sub, "premium_bonus_bytes", 0) or 0)
+ premium_unlimited_override = bool(getattr(sub, "premium_unlimited_override", False))
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_bonus_bytes
)
premium_access = await request.app["subscription_service"].premium_access_for_tariff(tariff)
return web.json_response(
@@ -2127,7 +2130,7 @@ async def tariff_topup_options_route(request: web.Request) -> web.Response:
"topup_kind": topup_kind,
"premium_title": tariff.premium_name(lang),
"traffic_percent": _traffic_percent(sub.traffic_used_bytes, sub.traffic_limit_bytes),
- "premium_traffic_percent": _traffic_percent(
+ "premium_traffic_percent": 0 if premium_unlimited_override else _traffic_percent(
sub.premium_used_bytes,
premium_limit_bytes,
),
@@ -2136,6 +2139,8 @@ async def tariff_topup_options_route(request: web.Request) -> web.Response:
"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_bonus_bytes": premium_bonus_bytes,
+ "premium_unlimited_override": premium_unlimited_override,
"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 [],
@@ -3278,6 +3283,10 @@ def _serialize_subscription(
"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_bonus_bytes": _coerce_int_or_none(active.get("premium_bonus_bytes")) or 0,
+ "regular_bonus_bytes": _coerce_int_or_none(active.get("regular_bonus_bytes")) or 0,
+ "regular_unlimited_override": bool(active.get("regular_unlimited_override")),
+ "premium_unlimited_override": bool(active.get("premium_unlimited_override")),
"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 []),
diff --git a/bot/handlers/admin/user_management.py b/bot/handlers/admin/user_management.py
index 5039cca..41b3222 100644
--- a/bot/handlers/admin/user_management.py
+++ b/bot/handlers/admin/user_management.py
@@ -192,6 +192,16 @@ def get_user_card_keyboard(user_id: int, i18n_instance, lang: str,
callback_data=f"user_action:refresh:{user_id}"
)
+ # Row 3b: Premium override + traffic grant
+ builder.button(
+ text=_(key="admin_user_premium_override_button"),
+ callback_data=f"user_action:premium_override:{user_id}"
+ )
+ builder.button(
+ text=_(key="admin_user_traffic_grant_button"),
+ callback_data=f"user_action:traffic_grant:{user_id}"
+ )
+
# Row 4: Quick links — only for users with a real Telegram profile
# (synthetic email-only users have a negative user_id with no tg profile).
has_self_link = user_id > 0
@@ -225,9 +235,9 @@ def get_user_card_keyboard(user_id: int, i18n_instance, lang: str,
quick_links_count = (1 if has_self_link else 0) + (1 if has_referrer_link else 0)
if quick_links_count == 0:
- builder.adjust(2, 2, 2, 1, 2)
+ builder.adjust(2, 2, 2, 2, 1, 2)
else:
- builder.adjust(2, 2, 2, quick_links_count, 1, 2)
+ builder.adjust(2, 2, 2, 2, quick_links_count, 1, 2)
return builder
@@ -343,6 +353,18 @@ async def format_user_card(user: User, session: AsyncSession,
limit_display = _("traffic_unlimited")
card_parts.append(f"{_('admin_user_traffic_label')} {hcode(f'{used_display} / {limit_display}')}")
+
+ premium_unlimited = bool(subscription_details.get("premium_unlimited_override"))
+ premium_bonus_bytes = int(subscription_details.get("premium_bonus_bytes") or 0)
+ if premium_unlimited:
+ card_parts.append(
+ f"{_('admin_user_premium_override_label')} {hcode(_('admin_user_premium_override_unlimited'))}"
+ )
+ elif premium_bonus_bytes > 0:
+ bonus_gb = premium_bonus_bytes / (1024 ** 3)
+ card_parts.append(
+ f"{_('admin_user_premium_override_label')} {hcode(_('admin_user_premium_override_bonus_value', gb=f'{bonus_gb:.2f}'))}"
+ )
else:
card_parts.append(f"{_('admin_user_subscription_label')} {hcode(_('admin_user_subscription_none'))}")
except Exception as e:
@@ -541,10 +563,237 @@ async def user_action_handler(callback: types.CallbackQuery, state: FSMContext,
await handle_delete_user_prompt(
callback, state, user, settings, i18n, current_lang, session
)
+ elif action == "premium_override":
+ await handle_premium_override_menu(
+ callback, state, user, subscription_service, session, i18n, current_lang
+ )
+ elif action == "premium_override_set_unlimited":
+ await handle_premium_override_apply(
+ callback, user, subscription_service, session, i18n, current_lang,
+ unlimited=True, bonus_bytes=0,
+ )
+ elif action == "premium_override_clear":
+ await handle_premium_override_apply(
+ callback, user, subscription_service, session, i18n, current_lang,
+ unlimited=False, bonus_bytes=0,
+ )
+ elif action == "premium_override_set_bonus":
+ await handle_premium_override_bonus_prompt(
+ callback, state, user, i18n, current_lang
+ )
+ elif action == "traffic_grant":
+ await handle_traffic_grant_menu(callback, user, i18n, current_lang)
+ elif action == "traffic_grant_regular":
+ await handle_traffic_grant_prompt(callback, state, user, "regular", i18n, current_lang)
+ elif action == "traffic_grant_premium":
+ await handle_traffic_grant_prompt(callback, state, user, "premium", i18n, current_lang)
else:
await callback.answer(_("admin_unknown_action"), show_alert=True)
+async def handle_premium_override_menu(
+ callback: types.CallbackQuery,
+ state: FSMContext,
+ user: User,
+ subscription_service: SubscriptionService,
+ session: AsyncSession,
+ i18n_instance,
+ lang: str,
+) -> None:
+ """Show premium override sub-menu with current state and quick toggles."""
+ _ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
+
+ active_sub = await subscription_dal.get_active_subscription_by_user_id(session, user.user_id)
+ if not active_sub:
+ await callback.answer(_("admin_premium_override_no_subscription"), show_alert=True)
+ return
+
+ unlimited = bool(getattr(active_sub, "premium_unlimited_override", False))
+ bonus_bytes = int(getattr(active_sub, "premium_bonus_bytes", 0) or 0)
+ bonus_gb = bonus_bytes / (1024 ** 3) if bonus_bytes else 0
+
+ if unlimited:
+ current_text = _("admin_premium_override_state_unlimited")
+ elif bonus_bytes > 0:
+ current_text = _("admin_premium_override_state_bonus", gb=f"{bonus_gb:.2f}")
+ else:
+ current_text = _("admin_premium_override_state_none")
+
+ text = "\n".join(
+ [
+ f"{_('admin_premium_override_title')}",
+ "",
+ _("admin_premium_override_hint"),
+ "",
+ _("admin_premium_override_current", current=current_text),
+ ]
+ )
+
+ builder = InlineKeyboardBuilder()
+ builder.button(
+ text=_("admin_premium_override_btn_unlimited"),
+ callback_data=f"user_action:premium_override_set_unlimited:{user.user_id}",
+ )
+ builder.button(
+ text=_("admin_premium_override_btn_bonus"),
+ callback_data=f"user_action:premium_override_set_bonus:{user.user_id}",
+ )
+ builder.button(
+ text=_("admin_premium_override_btn_clear"),
+ callback_data=f"user_action:premium_override_clear:{user.user_id}",
+ )
+ builder.button(
+ text=_("admin_user_back_to_card_button"),
+ callback_data=f"user_action:refresh:{user.user_id}",
+ )
+ builder.adjust(1, 1, 1, 1)
+
+ try:
+ await callback.message.edit_text(text, reply_markup=builder.as_markup(), parse_mode="HTML")
+ except Exception:
+ await callback.message.answer(text, reply_markup=builder.as_markup(), parse_mode="HTML")
+ await state.update_data(target_user_id=user.user_id)
+ await callback.answer()
+
+
+async def handle_premium_override_apply(
+ callback: types.CallbackQuery,
+ user: User,
+ subscription_service: SubscriptionService,
+ session: AsyncSession,
+ i18n_instance,
+ lang: str,
+ *,
+ unlimited: bool,
+ bonus_bytes: int,
+) -> None:
+ """Persist a premium override (unlimited or explicit bonus) on the active subscription."""
+ _ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
+
+ try:
+ active_sub = await subscription_dal.get_active_subscription_by_user_id(session, user.user_id)
+ if not active_sub:
+ await callback.answer(_("admin_premium_override_no_subscription"), show_alert=True)
+ return
+
+ active_sub.premium_unlimited_override = bool(unlimited)
+ active_sub.premium_bonus_bytes = max(0, int(bonus_bytes or 0))
+ if unlimited:
+ active_sub.premium_is_limited = False
+
+ await message_log_dal.create_message_log_no_commit(
+ session,
+ {
+ "user_id": callback.from_user.id if callback.from_user else user.user_id,
+ "event_type": "admin:premium_override",
+ "content": (
+ f"unlimited={bool(unlimited)} bonus_bytes={int(bonus_bytes or 0)}"
+ ),
+ "is_admin_event": True,
+ "target_user_id": user.user_id,
+ "timestamp": datetime.now(timezone.utc),
+ },
+ )
+ await session.commit()
+
+ await callback.answer(_("admin_premium_override_saved"), show_alert=False)
+ await handle_refresh_user_card(callback, user, subscription_service, session, i18n_instance, lang)
+ except Exception as exc:
+ logging.error(
+ "Failed to apply premium override for user %s: %s", user.user_id, exc, exc_info=True
+ )
+ await session.rollback()
+ await callback.answer(_("admin_premium_override_save_error"), show_alert=True)
+
+
+async def handle_premium_override_bonus_prompt(
+ callback: types.CallbackQuery,
+ state: FSMContext,
+ user: User,
+ i18n_instance,
+ lang: str,
+) -> None:
+ """Ask admin for the bonus GB to grant."""
+ _ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
+ await state.update_data(target_user_id=user.user_id)
+ await state.set_state(AdminStates.waiting_for_premium_override_bonus_gb)
+ prompt = _("admin_premium_override_bonus_prompt", user_id=user.user_id)
+ try:
+ await callback.message.edit_text(prompt)
+ except Exception:
+ await callback.message.answer(prompt)
+ await callback.answer()
+
+
+async def handle_traffic_grant_menu(
+ callback: types.CallbackQuery,
+ user: User,
+ i18n_instance,
+ lang: str,
+) -> None:
+ """Show traffic-grant menu (regular vs premium)."""
+ _ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
+
+ text = "\n".join(
+ [
+ f"{_('admin_traffic_grant_title')}",
+ "",
+ _("admin_traffic_grant_hint"),
+ ]
+ )
+ builder = InlineKeyboardBuilder()
+ builder.button(
+ text=_("admin_traffic_grant_btn_regular"),
+ callback_data=f"user_action:traffic_grant_regular:{user.user_id}",
+ )
+ builder.button(
+ text=_("admin_traffic_grant_btn_premium"),
+ callback_data=f"user_action:traffic_grant_premium:{user.user_id}",
+ )
+ builder.button(
+ text=_("admin_user_back_to_card_button"),
+ callback_data=f"user_action:refresh:{user.user_id}",
+ )
+ builder.adjust(1, 1, 1)
+
+ try:
+ await callback.message.edit_text(text, reply_markup=builder.as_markup(), parse_mode="HTML")
+ except Exception:
+ await callback.message.answer(text, reply_markup=builder.as_markup(), parse_mode="HTML")
+ await callback.answer()
+
+
+async def handle_traffic_grant_prompt(
+ callback: types.CallbackQuery,
+ state: FSMContext,
+ user: User,
+ kind: str,
+ i18n_instance,
+ lang: str,
+) -> None:
+ """Ask the admin for the amount of GB to grant (regular or premium)."""
+ _ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
+ kind_normalized = "premium" if kind == "premium" else "regular"
+ await state.update_data(target_user_id=user.user_id, traffic_grant_kind=kind_normalized)
+ await state.set_state(AdminStates.waiting_for_traffic_grant_gb)
+ prompt_key = (
+ "admin_traffic_grant_prompt_premium"
+ if kind_normalized == "premium"
+ else "admin_traffic_grant_prompt_regular"
+ )
+ prompt = _(prompt_key, user_id=user.user_id)
+ try:
+ await callback.message.edit_text(prompt)
+ except Exception:
+ await callback.message.answer(prompt)
+ await callback.answer()
+
+
+# `process_traffic_grant_gb_handler` is declared near the other FSM-bound
+# handlers at the bottom of the module, so the router decorator can attach to
+# the same `router` instance the premium override flow uses.
+
+
async def handle_reset_trial(callback: types.CallbackQuery, user: User,
subscription_service: SubscriptionService,
session: AsyncSession, i18n_instance, lang: str):
@@ -1372,6 +1621,203 @@ async def process_unban_user_handler(message: types.Message, state: FSMContext,
await state.clear()
+@router.message(AdminStates.waiting_for_premium_override_bonus_gb, F.text)
+async def process_premium_override_bonus_handler(
+ message: types.Message,
+ state: FSMContext,
+ settings: Settings,
+ i18n_data: dict,
+ subscription_service: SubscriptionService,
+ session: AsyncSession,
+):
+ """Read bonus GB and apply premium override (non-unlimited path)."""
+ current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
+ i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
+ if not i18n:
+ await message.reply("Language service error.")
+ return
+ _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
+
+ data = await state.get_data()
+ target_user_id = data.get("target_user_id")
+ if not target_user_id:
+ await message.answer(_("admin_premium_override_state_missing"))
+ await state.clear()
+ return
+
+ raw = (message.text or "").strip().replace(",", ".")
+ try:
+ gb = float(raw)
+ if gb < 0 or gb > 1_000_000:
+ raise ValueError("out_of_range")
+ except (TypeError, ValueError):
+ await message.answer(_("admin_premium_override_invalid_gb"))
+ return
+
+ bonus_bytes = int(round(gb * (1024 ** 3)))
+ target_user = await user_dal.get_user_by_id(session, target_user_id)
+ if not target_user:
+ await message.answer(_("admin_user_not_found_action"))
+ await state.clear()
+ return
+
+ try:
+ active_sub = await subscription_dal.get_active_subscription_by_user_id(
+ session, target_user_id
+ )
+ if not active_sub:
+ await message.answer(_("admin_premium_override_no_subscription"))
+ await state.clear()
+ return
+
+ active_sub.premium_unlimited_override = False
+ active_sub.premium_bonus_bytes = max(0, bonus_bytes)
+
+ await message_log_dal.create_message_log_no_commit(
+ session,
+ {
+ "user_id": message.from_user.id if message.from_user else target_user_id,
+ "event_type": "admin:premium_override",
+ "content": f"unlimited=False bonus_bytes={int(bonus_bytes)}",
+ "is_admin_event": True,
+ "target_user_id": target_user_id,
+ "timestamp": datetime.now(timezone.utc),
+ },
+ )
+ await session.commit()
+
+ await message.answer(
+ _("admin_premium_override_bonus_set", gb=f"{gb:.2f}", user_id=target_user_id)
+ )
+
+ referral_service = ReferralService(settings, subscription_service, message.bot, i18n)
+ bot_username = await _resolve_bot_username(message.bot)
+ user_card_text = await format_user_card(
+ target_user, session, subscription_service, i18n, current_lang, referral_service,
+ settings=settings, bot_username=bot_username,
+ )
+ keyboard = get_user_card_keyboard(
+ target_user.user_id, i18n, current_lang, target_user.referred_by_id
+ )
+ await _send_with_profile_link_fallback(
+ message.answer,
+ text=user_card_text,
+ markup=keyboard.as_markup(),
+ user_id=target_user.user_id,
+ parse_mode="HTML",
+ )
+ except Exception as exc:
+ logging.error(
+ "Error setting premium override bonus for user %s: %s", target_user_id, exc, exc_info=True
+ )
+ await session.rollback()
+ await message.answer(_("admin_premium_override_save_error"))
+ finally:
+ await state.clear()
+
+
+@router.message(AdminStates.waiting_for_traffic_grant_gb, F.text)
+async def process_traffic_grant_gb_handler(
+ message: types.Message,
+ state: FSMContext,
+ settings: Settings,
+ i18n_data: dict,
+ subscription_service: SubscriptionService,
+ session: AsyncSession,
+):
+ """Read GB amount and apply admin grant of regular or premium traffic."""
+ current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
+ i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
+ if not i18n:
+ await message.reply("Language service error.")
+ return
+ _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
+
+ data = await state.get_data()
+ target_user_id = data.get("target_user_id")
+ kind = (data.get("traffic_grant_kind") or "regular").lower()
+ if not target_user_id:
+ await message.answer(_("admin_traffic_grant_no_user"))
+ await state.clear()
+ return
+
+ raw = (message.text or "").strip().replace(",", ".")
+ try:
+ gb_value = float(raw)
+ if gb_value <= 0 or gb_value > 1_000_000:
+ raise ValueError("out_of_range")
+ except (TypeError, ValueError):
+ await message.answer(_("admin_traffic_grant_invalid_gb"))
+ return
+
+ target_user = await user_dal.get_user_by_id(session, target_user_id)
+ if not target_user:
+ await message.answer(_("admin_user_not_found_action"))
+ await state.clear()
+ return
+
+ try:
+ if kind == "premium":
+ result = await subscription_service.admin_grant_premium_topup(
+ session, target_user_id, gb_value
+ )
+ else:
+ result = await subscription_service.admin_grant_topup(
+ session, target_user_id, gb_value
+ )
+ if not result:
+ await session.rollback()
+ await message.answer(_("admin_traffic_grant_failed"))
+ await state.clear()
+ return
+ await message_log_dal.create_message_log_no_commit(
+ session,
+ {
+ "user_id": message.from_user.id if message.from_user else target_user_id,
+ "event_type": "admin:traffic_grant",
+ "content": f"kind={kind} gb={gb_value:g}",
+ "is_admin_event": True,
+ "target_user_id": target_user_id,
+ "timestamp": datetime.now(timezone.utc),
+ },
+ )
+ await session.commit()
+
+ gb_text = f"{gb_value:g}"
+ success_key = (
+ "admin_traffic_grant_premium_done"
+ if kind == "premium"
+ else "admin_traffic_grant_regular_done"
+ )
+ await message.answer(_(success_key, gb=gb_text, user_id=target_user_id))
+
+ referral_service = ReferralService(settings, subscription_service, message.bot, i18n)
+ bot_username = await _resolve_bot_username(message.bot)
+ user_card_text = await format_user_card(
+ target_user, session, subscription_service, i18n, current_lang, referral_service,
+ settings=settings, bot_username=bot_username,
+ )
+ keyboard = get_user_card_keyboard(
+ target_user.user_id, i18n, current_lang, target_user.referred_by_id
+ )
+ await _send_with_profile_link_fallback(
+ message.answer,
+ text=user_card_text,
+ markup=keyboard.as_markup(),
+ user_id=target_user.user_id,
+ parse_mode="HTML",
+ )
+ except Exception as exc:
+ logging.error(
+ "Error granting traffic for user %s (kind=%s, gb=%s): %s",
+ target_user_id, kind, gb_value, exc, exc_info=True,
+ )
+ await session.rollback()
+ await message.answer(_("admin_traffic_grant_failed"))
+ finally:
+ await state.clear()
+
+
@router.callback_query(F.data.startswith("admin_user_card_from_list:"))
async def user_card_from_list_handler(callback: types.CallbackQuery,
state: FSMContext, i18n_data: dict,
@@ -1413,7 +1859,7 @@ async def user_card_from_list_handler(callback: types.CallbackQuery,
callback_data=f"admin_action:users_list:{page}"
)
quick_links_width = 2 if user.referred_by_id else 1
- keyboard.adjust(2, 2, 2, quick_links_width, 1, 2, 1)
+ keyboard.adjust(2, 2, 2, 2, quick_links_width, 1, 2, 1)
# Format user card
try:
diff --git a/bot/handlers/user/payment.py b/bot/handlers/user/payment.py
index b3ad551..f77aec0 100644
--- a/bot/handlers/user/payment.py
+++ b/bot/handlers/user/payment.py
@@ -266,7 +266,7 @@ async def process_successful_payment(session: AsyncSession, bot: Bot,
"applied_promo_bonus_days", 0)
referral_bonus_info = None
- if sale_mode != "traffic":
+ if sale_mode_base == "subscription":
referral_bonus_info = await referral_service.apply_referral_bonuses_for_payment(
session,
user_id,
@@ -292,7 +292,7 @@ async def process_successful_payment(session: AsyncSession, bot: Bot,
if should_send_lknpd_receipt:
receipt_item_name = payment_info_from_webhook.get("description")
if not receipt_item_name:
- if sale_mode == "traffic":
+ if sale_mode_base in {"traffic", "traffic_package", "topup", "premium_topup"}:
receipt_item_name = settings.LKNPD_RECEIPT_NAME_TRAFFIC.format(gb=traffic_label)
else:
receipt_item_name = settings.LKNPD_RECEIPT_NAME_SUBSCRIPTION.format(months=int(subscription_months))
@@ -313,14 +313,14 @@ async def process_successful_payment(session: AsyncSession, bot: Bot,
)
config_link_text = config_link_display or _("config_link_not_available")
# For auto-renew charges, avoid re-sending config link; send concise message
- if sale_mode != "traffic" and is_auto_renew and final_end_date_for_user:
+ if sale_mode_base == "subscription" and is_auto_renew and final_end_date_for_user:
details_message = _(
"yookassa_auto_renewal",
months=int(subscription_months),
end_date=final_end_date_for_user.strftime('%Y-%m-%d'),
)
details_markup = None
- elif sale_mode == "traffic":
+ elif sale_mode_base in {"traffic", "traffic_package", "topup", "premium_topup"}:
details_message = _(
"payment_successful_traffic_full",
traffic_gb=traffic_label,
@@ -403,14 +403,23 @@ async def process_successful_payment(session: AsyncSession, bot: Bot,
try:
notification_service = NotificationService(bot, settings, i18n)
user = await user_dal.get_user_by_id(session, user_id)
+ tariff_for_log = None
+ if payment_before_update and getattr(payment_before_update, "tariff_key", None):
+ tariff_for_log = payment_before_update.tariff_key
+ elif updated_payment_record and getattr(updated_payment_record, "tariff_key", None):
+ tariff_for_log = updated_payment_record.tariff_key
+ elif payment_record and getattr(payment_record, "tariff_key", None):
+ tariff_for_log = payment_record.tariff_key
await notification_service.notify_payment_received(
user_id=user_id,
amount=payment_value,
currency=settings.DEFAULT_CURRENCY_SYMBOL,
- months=int(subscription_months) if sale_mode != "traffic" else 0,
+ months=int(subscription_months) if sale_mode_base == "subscription" else 0,
payment_provider="yookassa", # This is specifically for YooKassa webhook
username=user.username if user else None,
- traffic_gb=traffic_amount_gb if sale_mode == "traffic" else None,
+ traffic_gb=traffic_amount_gb if sale_mode_base in {"traffic", "traffic_package", "topup", "premium_topup"} else None,
+ traffic_is_premium=sale_mode_base == "premium_topup",
+ tariff_key=tariff_for_log,
)
except Exception as e:
logging.error(f"Failed to send payment notification: {e}")
diff --git a/bot/handlers/user/subscription/payments_freekassa.py b/bot/handlers/user/subscription/payments_freekassa.py
index 5d349b3..868bb19 100644
--- a/bot/handlers/user/subscription/payments_freekassa.py
+++ b/bot/handlers/user/subscription/payments_freekassa.py
@@ -75,7 +75,7 @@ async def pay_fk_callback_handler(
"currency": currency_code,
"status": "pending_freekassa",
"description": payment_description,
- "subscription_duration_months": int(months),
+ "subscription_duration_months": int(months) if sale_base == "subscription" else None,
"provider": "freekassa",
"sale_mode": sale_mode,
"tariff_key": sale_mode.split("@", 1)[1] if "@" in sale_mode else None,
@@ -147,7 +147,7 @@ async def pay_fk_callback_handler(
try:
await callback.message.edit_text(
f"{order_info_text}\n\n" + get_text(
- key="payment_link_message_traffic" if sale_mode == "traffic" else "payment_link_message",
+ key="payment_link_message_traffic" if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"} else "payment_link_message",
months=int(months),
traffic_gb=human_value,
),
@@ -165,7 +165,7 @@ async def pay_fk_callback_handler(
try:
await callback.message.answer(
f"{order_info_text}\n\n" + get_text(
- key="payment_link_message_traffic" if sale_mode == "traffic" else "payment_link_message",
+ key="payment_link_message_traffic" if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"} else "payment_link_message",
months=int(months),
traffic_gb=human_value,
),
diff --git a/bot/handlers/user/subscription/payments_platega.py b/bot/handlers/user/subscription/payments_platega.py
index 3e134a3..4546cc7 100644
--- a/bot/handlers/user/subscription/payments_platega.py
+++ b/bot/handlers/user/subscription/payments_platega.py
@@ -102,7 +102,7 @@ async def pay_platega_callback_handler(
"currency": currency_code,
"status": "pending_platega",
"description": payment_description,
- "subscription_duration_months": int(months),
+ "subscription_duration_months": int(months) if sale_base == "subscription" else None,
"provider": "platega",
"sale_mode": sale_mode,
"tariff_key": sale_mode.split("@", 1)[1] if "@" in sale_mode else None,
@@ -178,7 +178,7 @@ async def pay_platega_callback_handler(
try:
await callback.message.edit_text(
get_text(
- key="payment_link_message_traffic" if sale_mode == "traffic" else "payment_link_message",
+ key="payment_link_message_traffic" if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"} else "payment_link_message",
months=int(months),
traffic_gb=human_value,
),
@@ -196,7 +196,7 @@ async def pay_platega_callback_handler(
try:
await callback.message.answer(
get_text(
- key="payment_link_message_traffic" if sale_mode == "traffic" else "payment_link_message",
+ key="payment_link_message_traffic" if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"} else "payment_link_message",
months=int(months),
traffic_gb=human_value,
),
diff --git a/bot/handlers/user/subscription/payments_severpay.py b/bot/handlers/user/subscription/payments_severpay.py
index b924988..f4f1c15 100644
--- a/bot/handlers/user/subscription/payments_severpay.py
+++ b/bot/handlers/user/subscription/payments_severpay.py
@@ -74,7 +74,7 @@ async def pay_severpay_callback_handler(
"currency": currency_code,
"status": "pending_severpay",
"description": payment_description,
- "subscription_duration_months": int(months),
+ "subscription_duration_months": int(months) if sale_base == "subscription" else None,
"provider": "severpay",
"sale_mode": sale_mode,
"tariff_key": sale_mode.split("@", 1)[1] if "@" in sale_mode else None,
@@ -138,7 +138,7 @@ async def pay_severpay_callback_handler(
try:
await callback.message.edit_text(
get_text(
- key="payment_link_message_traffic" if sale_mode == "traffic" else "payment_link_message",
+ key="payment_link_message_traffic" if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"} else "payment_link_message",
months=int(months),
traffic_gb=human_value,
),
@@ -156,7 +156,7 @@ async def pay_severpay_callback_handler(
try:
await callback.message.answer(
get_text(
- key="payment_link_message_traffic" if sale_mode == "traffic" else "payment_link_message",
+ key="payment_link_message_traffic" if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"} else "payment_link_message",
months=int(months),
traffic_gb=human_value,
),
diff --git a/bot/handlers/user/subscription/payments_yookassa.py b/bot/handlers/user/subscription/payments_yookassa.py
index 67f2879..9826124 100644
--- a/bot/handlers/user/subscription/payments_yookassa.py
+++ b/bot/handlers/user/subscription/payments_yookassa.py
@@ -93,7 +93,7 @@ async def _initiate_yk_payment(
"currency": currency_code_for_yk,
"status": "pending_yookassa",
"description": payment_description,
- "subscription_duration_months": int(months),
+ "subscription_duration_months": int(months) if sale_base == "subscription" else None,
"sale_mode": sale_base,
"tariff_key": sale_mode.split("@", 1)[1] if "@" in sale_mode else None,
"purchased_gb": float(months) if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"} else None,
@@ -223,7 +223,7 @@ async def _initiate_yk_payment(
try:
await callback.message.edit_text(
get_text(
- key="payment_link_message_traffic" if sale_mode == "traffic" else "payment_link_message",
+ key="payment_link_message_traffic" if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"} else "payment_link_message",
months=int(months),
traffic_gb=_format_value(months),
),
@@ -243,7 +243,7 @@ async def _initiate_yk_payment(
try:
await callback.message.answer(
get_text(
- key="payment_link_message_traffic" if sale_mode == "traffic" else "payment_link_message",
+ key="payment_link_message_traffic" if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"} else "payment_link_message",
months=int(months),
traffic_gb=_format_value(months),
),
diff --git a/bot/services/crypto_pay_service.py b/bot/services/crypto_pay_service.py
index 243571e..29f8941 100644
--- a/bot/services/crypto_pay_service.py
+++ b/bot/services/crypto_pay_service.py
@@ -87,7 +87,7 @@ class CryptoPayService:
"currency": self.settings.CRYPTOPAY_ASSET,
"status": "pending_cryptopay",
"description": description,
- "subscription_duration_months": int(months),
+ "subscription_duration_months": int(months) if sale_base == "subscription" else None,
"provider": "cryptopay",
"sale_mode": sale_mode,
"tariff_key": sale_mode.split("@", 1)[1] if "@" in sale_mode else None,
@@ -263,6 +263,10 @@ class CryptoPayService:
logging.exception("Failed to send CryptoPay success message.")
# Send notification about payment
+ try:
+ payment_row = await payment_dal.get_payment_by_db_id(session, payment_db_id)
+ except Exception:
+ payment_row = None
try:
notification_service = NotificationService(bot, settings, i18n)
user = await user_dal.get_user_by_id(session, user_id)
@@ -273,7 +277,9 @@ class CryptoPayService:
months=int(months) if sale_base == "subscription" else 0,
traffic_gb=traffic_gb if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"} else None,
payment_provider="crypto_pay",
- username=user.username if user else None
+ username=user.username if user else None,
+ traffic_is_premium=sale_base == "premium_topup",
+ tariff_key=getattr(payment_row, "tariff_key", None) if payment_row else None,
)
except Exception:
logging.exception("Failed to send crypto_pay payment notification.")
diff --git a/bot/services/email_templates.py b/bot/services/email_templates.py
index 1a064c8..7c07927 100644
--- a/bot/services/email_templates.py
+++ b/bot/services/email_templates.py
@@ -347,7 +347,7 @@ def render_payment_success(
lang = _normalize_lang(language_code, settings)
accent = _safe_color(settings.WEBAPP_PRIMARY_COLOR)
brand = _brand_title(settings)
- is_traffic = sale_mode == "traffic"
+ is_traffic = (sale_mode or "").split("@", 1)[0].split("|", 1)[0] in {"traffic", "traffic_package", "topup", "premium_topup"}
amount_text = _format_amount(amount, currency)
safe_dashboard_url = (dashboard_url or "").strip()
end_date = end_date_text or "—"
diff --git a/bot/services/freekassa_service.py b/bot/services/freekassa_service.py
index 675dd05..beb36e9 100644
--- a/bot/services/freekassa_service.py
+++ b/bot/services/freekassa_service.py
@@ -315,6 +315,7 @@ class FreeKassaService:
final_end = activation.get("end_date") if activation else None
months = payment.purchased_gb or payment.subscription_duration_months or 1
sale_mode = payment.sale_mode or ("traffic" if self.settings.traffic_sale_mode else "subscription")
+ sale_base = sale_mode.split("@", 1)[0].split("|", 1)[0]
applied_days = 0
if referral_bonus and referral_bonus.get("referee_new_end_date"):
@@ -395,10 +396,12 @@ class FreeKassaService:
user_id=payment.user_id,
amount=float(payment.amount),
currency=self.default_currency,
- months=int(months) if sale_mode != "traffic" else 0,
- traffic_gb=months if sale_mode == "traffic" else None,
+ months=int(months) if sale_base == "subscription" else 0,
+ traffic_gb=float(months) if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"} else None,
payment_provider="freekassa",
username=db_user.username if db_user else None,
+ traffic_is_premium=sale_base == "premium_topup",
+ tariff_key=getattr(payment, "tariff_key", None),
)
except Exception:
logging.exception("FreeKassa notification: failed to notify admins.")
diff --git a/bot/services/notification_service.py b/bot/services/notification_service.py
index fdfc181..1812659 100644
--- a/bot/services/notification_service.py
+++ b/bot/services/notification_service.py
@@ -324,10 +324,31 @@ class NotificationService:
profile_keyboard = self._build_profile_keyboard(_, telegram_id)
await self._send_to_log_channel(message, reply_markup=profile_keyboard)
+ def _format_traffic_gb_admin(self, traffic_gb: float) -> str:
+ value = float(traffic_gb)
+ if value.is_integer():
+ return str(int(value))
+ return f"{value:g}"
+
+ def _tariff_display_for_log(self, tariff_key: Optional[str]) -> str:
+ if not tariff_key:
+ return ""
+ cfg = getattr(self.settings, "tariffs_config", None)
+ if not cfg:
+ return str(tariff_key)
+ try:
+ tariff = cfg.require(str(tariff_key))
+ return str(tariff.name(self.settings.DEFAULT_LANGUAGE))
+ except Exception:
+ return str(tariff_key)
+
async def notify_payment_received(self, user_id: int, amount: float, currency: str,
months: int, payment_provider: str,
username: Optional[str] = None,
- traffic_gb: Optional[float] = None):
+ traffic_gb: Optional[float] = None,
+ *,
+ traffic_is_premium: bool = False,
+ tariff_key: Optional[str] = None):
"""Send notification about successful payment"""
if not self.settings.LOG_PAYMENTS:
return
@@ -350,14 +371,25 @@ class NotificationService:
}.get(payment_provider.lower(), "💰")
if traffic_gb is not None:
- traffic_label = str(int(traffic_gb)) if float(traffic_gb).is_integer() else f"{traffic_gb:g}"
+ traffic_label = self._format_traffic_gb_admin(float(traffic_gb))
+ traffic_kind = _(
+ "log_payment_traffic_kind_premium" if traffic_is_premium else "log_payment_traffic_kind_regular",
+ )
+ traffic_summary = _("log_payment_traffic_purchase_line", gb=traffic_label, kind=traffic_kind)
+ tariff_name = self._tariff_display_for_log(tariff_key)
+ tariff_line = (
+ _("log_payment_tariff_line", name=hd.quote(tariff_name))
+ if tariff_name
+ else ""
+ )
message = _(
"log_payment_received_traffic",
provider_emoji=provider_emoji,
user_display=user_display,
amount=amount,
currency=currency,
- traffic_gb=traffic_label,
+ traffic_summary=traffic_summary,
+ tariff_line=tariff_line,
payment_provider=payment_provider,
timestamp=datetime.now().strftime("%Y-%m-%d %H:%M:%S")
)
diff --git a/bot/services/platega_service.py b/bot/services/platega_service.py
index 6b6ef62..6c837a0 100644
--- a/bot/services/platega_service.py
+++ b/bot/services/platega_service.py
@@ -318,10 +318,12 @@ class PlategaService:
user_id=payment.user_id,
amount=float(payment.amount),
currency=currency,
- months=int(payment_months) if sale_mode != "traffic" else 0,
- traffic_gb=payment_months if sale_mode == "traffic" else None,
+ months=int(payment_months) if sale_base == "subscription" else 0,
+ traffic_gb=float(payment_months) if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"} else None,
payment_provider="platega",
username=db_user.username if db_user else None,
+ traffic_is_premium=sale_base == "premium_topup",
+ tariff_key=getattr(payment, "tariff_key", None),
)
except Exception:
logging.exception("Platega webhook: failed to notify admins.")
diff --git a/bot/services/severpay_service.py b/bot/services/severpay_service.py
index be1ea75..6ce1747 100644
--- a/bot/services/severpay_service.py
+++ b/bot/services/severpay_service.py
@@ -314,10 +314,12 @@ class SeverPayService:
user_id=payment.user_id,
amount=float(payment.amount),
currency=payment.currency,
- months=int(payment_months) if sale_mode != "traffic" else 0,
- traffic_gb=payment_months if sale_mode == "traffic" else None,
+ months=int(payment_months) if sale_base == "subscription" else 0,
+ traffic_gb=float(payment_months) if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"} else None,
payment_provider="severpay",
username=db_user.username if db_user else None,
+ traffic_is_premium=sale_base == "premium_topup",
+ tariff_key=getattr(payment, "tariff_key", None),
)
except Exception:
logging.exception("SeverPay webhook: failed to notify admins.")
diff --git a/bot/services/stars_service.py b/bot/services/stars_service.py
index eff5142..0dd6cab 100644
--- a/bot/services/stars_service.py
+++ b/bot/services/stars_service.py
@@ -35,7 +35,7 @@ class StarsService:
"currency": "XTR",
"status": "pending_stars",
"description": description,
- "subscription_duration_months": int(months),
+ "subscription_duration_months": int(months) if sale_base == "subscription" else None,
"provider": "telegram_stars",
"sale_mode": sale_mode,
"tariff_key": sale_mode.split("@", 1)[1] if "@" in sale_mode else None,
@@ -201,7 +201,9 @@ class StarsService:
months=int(months) if sale_base == "subscription" else 0,
payment_provider="stars",
username=user.username if user else None,
- traffic_gb=months if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"} else None,
+ traffic_gb=float(months) if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"} else None,
+ traffic_is_premium=sale_base == "premium_topup",
+ tariff_key=getattr(payment_record, "tariff_key", None),
)
except Exception as e:
logging.error(f"Failed to send stars payment notification: {e}")
diff --git a/bot/services/subscription_service.py b/bot/services/subscription_service.py
index 6372e38..781aaf4 100644
--- a/bot/services/subscription_service.py
+++ b/bot/services/subscription_service.py
@@ -85,10 +85,25 @@ class SubscriptionService:
return list(dict.fromkeys(squads))
return self.settings.parsed_user_squad_uuids
- def _traffic_limit_for_period_tariff(self, tariff: Optional[Tariff], topup_balance_bytes: int = 0) -> int:
+ def _traffic_limit_for_period_tariff(
+ self,
+ tariff: Optional[Tariff],
+ topup_balance_bytes: int = 0,
+ regular_bonus_bytes: int = 0,
+ regular_unlimited_override: bool = False,
+ traffic_used_bytes: int = 0,
+ ) -> int:
if tariff:
- return int(tariff.monthly_bytes + max(0, topup_balance_bytes))
- return self.settings.user_traffic_limit_bytes
+ baseline = int(tariff.monthly_bytes or 0)
+ else:
+ baseline = int(self.settings.user_traffic_limit_bytes)
+ return self._compute_main_traffic_limit_bytes(
+ tier_baseline_bytes=baseline,
+ topup_balance_bytes=topup_balance_bytes,
+ regular_bonus_bytes=regular_bonus_bytes,
+ regular_unlimited_override=regular_unlimited_override,
+ traffic_used_bytes=traffic_used_bytes,
+ )
def _premium_limit_for_tariff(self, tariff: Optional[Tariff], topup_balance_bytes: int = 0) -> int:
if not tariff:
@@ -100,11 +115,35 @@ class SubscriptionService:
premium_baseline_bytes: int,
premium_topup_balance_bytes: int = 0,
premium_topup_used_bytes: int = 0,
+ premium_bonus_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)
+ 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))
+ + max(0, int(premium_bonus_bytes or 0))
)
+ def _compute_main_traffic_limit_bytes(
+ self,
+ *,
+ tier_baseline_bytes: int,
+ topup_balance_bytes: int,
+ regular_bonus_bytes: int,
+ regular_unlimited_override: bool,
+ traffic_used_bytes: int,
+ ) -> int:
+ """Numeric cap sent to the panel; ``regular_unlimited_override`` uses a large practical ceiling."""
+ floor = (
+ int(tier_baseline_bytes or 0)
+ + max(0, int(topup_balance_bytes or 0))
+ + max(0, int(regular_bonus_bytes or 0))
+ )
+ if regular_unlimited_override:
+ used = max(0, int(traffic_used_bytes or 0))
+ return max(floor, used + 512 * (1024 ** 3), 1024 ** 5)
+ return floor
+
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": []}
@@ -949,7 +988,17 @@ class SubscriptionService:
purchase_bytes = self.gb_to_bytes(traffic_gb)
new_topup_balance = int(sub.topup_balance_bytes or 0) + purchase_bytes
- new_limit = int(sub.tier_baseline_bytes or tariff.monthly_bytes) + new_topup_balance
+ baseline = int(sub.tier_baseline_bytes or tariff.monthly_bytes)
+ rb = int(getattr(sub, "regular_bonus_bytes", 0) or 0)
+ runl = bool(getattr(sub, "regular_unlimited_override", False))
+ used_for_lim = int(getattr(sub, "traffic_used_bytes", 0) or 0)
+ new_limit = self._compute_main_traffic_limit_bytes(
+ tier_baseline_bytes=baseline,
+ topup_balance_bytes=new_topup_balance,
+ regular_bonus_bytes=rb,
+ regular_unlimited_override=runl,
+ traffic_used_bytes=used_for_lim,
+ )
base_hwid_limit = (
int(sub.hwid_device_limit)
if sub.hwid_device_limit is not None
@@ -1084,6 +1133,277 @@ class SubscriptionService:
"tariff_key": tariff.key,
}
+ async def admin_grant_topup(
+ self,
+ session: AsyncSession,
+ user_id: int,
+ traffic_gb: float,
+ ) -> Optional[Dict[str, Any]]:
+ """Credit regular traffic to a user as if they purchased a top-up.
+
+ Mirrors :meth:`activate_topup` but skips payment context and tariff
+ resolution: the grant simply increases ``topup_balance_bytes`` and
+ recomputes ``traffic_limit_bytes`` from the subscription's current
+ tier baseline. The audit row in ``traffic_topups`` is stored with
+ ``kind="admin_topup"`` and ``payment_id=NULL`` so reports stay clean.
+ """
+ try:
+ gb_value = float(traffic_gb)
+ except (TypeError, ValueError):
+ logging.error("admin_grant_topup: invalid traffic_gb=%r", traffic_gb)
+ return None
+ if gb_value <= 0:
+ return None
+
+ 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
+
+ tariff = self._resolve_tariff(sub.tariff_key) if sub.tariff_key else None
+ purchase_bytes = self.gb_to_bytes(gb_value)
+ baseline_bytes = int(sub.tier_baseline_bytes or (tariff.monthly_bytes if tariff else 0) or 0)
+ new_topup_balance = int(sub.topup_balance_bytes or 0) + purchase_bytes
+ rb = int(getattr(sub, "regular_bonus_bytes", 0) or 0)
+ runl = bool(getattr(sub, "regular_unlimited_override", False))
+ used_for_lim = int(getattr(sub, "traffic_used_bytes", 0) or 0)
+ new_limit = self._compute_main_traffic_limit_bytes(
+ tier_baseline_bytes=baseline_bytes,
+ topup_balance_bytes=new_topup_balance,
+ regular_bonus_bytes=rb,
+ regular_unlimited_override=runl,
+ traffic_used_bytes=used_for_lim,
+ )
+ base_hwid_limit = (
+ int(sub.hwid_device_limit)
+ if sub.hwid_device_limit is not None
+ else self._base_hwid_limit_for_tariff(tariff)
+ )
+ effective_hwid_limit = self._effective_hwid_limit(
+ base_hwid_limit,
+ int(sub.extra_hwid_devices or 0),
+ )
+ updated_sub = await subscription_dal.update_subscription(
+ session,
+ sub.subscription_id,
+ {
+ "topup_balance_bytes": new_topup_balance,
+ "traffic_limit_bytes": new_limit,
+ "is_throttled": False,
+ "hwid_device_limit": base_hwid_limit,
+ },
+ )
+ panel_payload = self._build_panel_update_payload(
+ panel_user_uuid=db_user.panel_user_uuid,
+ expire_at=updated_sub.end_date,
+ status="ACTIVE",
+ traffic_limit_bytes=new_limit,
+ hwid_device_limit=effective_hwid_limit,
+ )
+ if tariff is not None:
+ 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))
+ try:
+ await self.panel_service.update_user_details_on_panel(
+ db_user.panel_user_uuid, panel_payload
+ )
+ except Exception:
+ logging.exception(
+ "admin_grant_topup: failed to push panel update for user %s", user_id
+ )
+ await tariff_dal.create_traffic_topup(
+ session,
+ subscription_id=sub.subscription_id,
+ payment_id=None,
+ purchased_bytes=purchase_bytes,
+ kind="admin_topup",
+ )
+ return {
+ "subscription_id": sub.subscription_id,
+ "traffic_limit_bytes": new_limit,
+ "topup_balance_bytes": new_topup_balance,
+ "granted_bytes": purchase_bytes,
+ }
+
+ async def sync_main_traffic_limit_to_panel(
+ self,
+ session: AsyncSession,
+ user_id: int,
+ ) -> None:
+ """Recompute main traffic limit from tier + topups + regular_bonus_bytes and push to panel."""
+ db_user = await user_dal.get_user_by_id(session, user_id)
+ if not db_user or not db_user.panel_user_uuid:
+ return
+ sub = await subscription_dal.get_active_subscription_by_user_id(
+ session, user_id, db_user.panel_user_uuid
+ )
+ if not sub:
+ return
+ tariff = self._resolve_tariff(sub.tariff_key) if sub.tariff_key else None
+ baseline = int(sub.tier_baseline_bytes or (tariff.monthly_bytes if tariff else 0) or 0)
+ rb = int(getattr(sub, "regular_bonus_bytes", 0) or 0)
+ runl = bool(getattr(sub, "regular_unlimited_override", False))
+ used_now = int(getattr(sub, "traffic_used_bytes", 0) or 0)
+ new_limit = self._compute_main_traffic_limit_bytes(
+ tier_baseline_bytes=baseline,
+ topup_balance_bytes=int(sub.topup_balance_bytes or 0),
+ regular_bonus_bytes=rb,
+ regular_unlimited_override=runl,
+ traffic_used_bytes=used_now,
+ )
+ sub.traffic_limit_bytes = new_limit
+ if runl:
+ sub.is_throttled = False
+ base_hwid_limit = (
+ int(sub.hwid_device_limit)
+ if sub.hwid_device_limit is not None
+ else self._base_hwid_limit_for_tariff(tariff)
+ )
+ effective_hwid_limit = self._effective_hwid_limit(
+ base_hwid_limit,
+ int(sub.extra_hwid_devices or 0),
+ )
+ panel_payload = self._build_panel_update_payload(
+ panel_user_uuid=db_user.panel_user_uuid,
+ expire_at=sub.end_date,
+ status="ACTIVE",
+ traffic_limit_bytes=new_limit,
+ hwid_device_limit=effective_hwid_limit,
+ )
+ if tariff is not None:
+ panel_payload["activeInternalSquads"] = self._panel_squads_for_tariff(
+ tariff,
+ include_premium=not bool(getattr(sub, "premium_is_limited", False)),
+ )
+ panel_payload.update(self._panel_identity_payload_for_user(db_user))
+ try:
+ await self.panel_service.update_user_details_on_panel(
+ db_user.panel_user_uuid, panel_payload
+ )
+ except Exception:
+ logging.exception(
+ "sync_main_traffic_limit_to_panel failed for user %s", user_id
+ )
+
+ async def admin_grant_premium_topup(
+ self,
+ session: AsyncSession,
+ user_id: int,
+ traffic_gb: float,
+ ) -> Optional[Dict[str, Any]]:
+ """Credit premium-squad traffic to a user as if they purchased a premium top-up.
+
+ Mirrors :meth:`activate_premium_topup` but skips payment context.
+ Requires the user's current tariff to expose premium squads. The
+ balance is absorbed into ``premium_topup_balance_bytes`` (backfilling
+ any current overuse first), ``premium_is_limited`` is recomputed and,
+ if access becomes available again, the premium squads are returned to
+ the user on the panel. The audit row in ``traffic_topups`` is stored
+ with ``kind="admin_premium_topup"`` and ``payment_id=NULL``.
+ """
+ try:
+ gb_value = float(traffic_gb)
+ except (TypeError, ValueError):
+ logging.error("admin_grant_premium_topup: invalid traffic_gb=%r", traffic_gb)
+ return None
+ if gb_value <= 0:
+ return None
+
+ 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
+ tariff = self._resolve_tariff(sub.tariff_key) if sub.tariff_key else None
+ if not tariff or not tariff.premium_squad_uuids:
+ logging.error(
+ "admin_grant_premium_topup: tariff %s has no premium squads (user %s)",
+ getattr(tariff, "key", None),
+ user_id,
+ )
+ return None
+
+ purchase_bytes = self.gb_to_bytes(gb_value)
+ 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_bonus = max(0, int(getattr(sub, "premium_bonus_bytes", 0) 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 - premium_bonus
+ )
+ 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_bonus,
+ )
+ premium_unlimited = bool(getattr(sub, "premium_unlimited_override", False))
+ premium_is_limited = (
+ not premium_unlimited and 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,
+ },
+ )
+ panel_payload = {
+ "uuid": db_user.panel_user_uuid,
+ "activeInternalSquads": self._panel_squads_for_tariff(
+ tariff,
+ include_premium=not premium_is_limited,
+ ),
+ }
+ try:
+ await self.panel_service.update_user_details_on_panel(
+ db_user.panel_user_uuid, panel_payload
+ )
+ except Exception:
+ logging.exception(
+ "admin_grant_premium_topup: failed to push panel update for user %s",
+ user_id,
+ )
+ await tariff_dal.create_traffic_topup(
+ session,
+ subscription_id=sub.subscription_id,
+ payment_id=None,
+ purchased_bytes=purchase_bytes,
+ kind="admin_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,
+ "granted_bytes": purchase_bytes,
+ }
+
async def activate_hwid_device_topup(
self,
session: AsyncSession,
@@ -1272,7 +1592,16 @@ class SubscriptionService:
if target.billing_model == "period":
update_data["tier_baseline_bytes"] = target.monthly_bytes
- update_data["traffic_limit_bytes"] = target.monthly_bytes + int(sub.topup_balance_bytes or 0)
+ rb = int(getattr(sub, "regular_bonus_bytes", 0) or 0)
+ runl = bool(getattr(sub, "regular_unlimited_override", False))
+ used_sub = int(sub.traffic_used_bytes or 0)
+ update_data["traffic_limit_bytes"] = self._compute_main_traffic_limit_bytes(
+ tier_baseline_bytes=target.monthly_bytes,
+ topup_balance_bytes=int(sub.topup_balance_bytes or 0),
+ regular_bonus_bytes=rb,
+ regular_unlimited_override=runl,
+ traffic_used_bytes=used_sub,
+ )
update_data["period_start_at"] = None
update_data["effective_monthly_price_rub"] = target.period_price(1, "rub") or target.min_period_price_rub()
if mode == "recalc_days" and options.get("recalc_days") is not None:
@@ -1282,15 +1611,24 @@ class SubscriptionService:
converted_bytes = self.gb_to_bytes(converted_gb)
old_topup = int(sub.topup_balance_bytes or 0)
new_balance = old_topup + converted_bytes
+ rb = int(getattr(sub, "regular_bonus_bytes", 0) or 0)
+ runl = bool(getattr(sub, "regular_unlimited_override", False))
panel_user = await self.panel_service.get_user_by_uuid(db_user.panel_user_uuid, log_response=False) or {}
current_used, _, _ = self._extract_panel_traffic_details(panel_user)
+ cur_used_int = int(current_used or 0)
update_data.update(
{
"end_date": self._far_future(),
"period_start_at": None,
"tier_baseline_bytes": 0,
"topup_balance_bytes": new_balance,
- "traffic_limit_bytes": int(current_used or 0) + new_balance,
+ "traffic_limit_bytes": self._compute_main_traffic_limit_bytes(
+ tier_baseline_bytes=0,
+ topup_balance_bytes=new_balance,
+ regular_bonus_bytes=rb,
+ regular_unlimited_override=runl,
+ traffic_used_bytes=cur_used_int,
+ ),
"traffic_used_bytes": current_used,
"effective_monthly_price_rub": None,
"auto_renew_enabled": False,
@@ -1575,7 +1913,15 @@ class SubscriptionService:
premium_topup_used_bytes,
)
effective_monthly_price = float(payment_amount) / max(1, months_int)
- traffic_limit_bytes = self._traffic_limit_for_period_tariff(tariff, topup_balance_bytes)
+ regular_bonus_carry = int(getattr(current_active_sub, "regular_bonus_bytes", 0) or 0)
+ regular_unl_carry = bool(getattr(current_active_sub, "regular_unlimited_override", False))
+ traffic_limit_bytes = self._traffic_limit_for_period_tariff(
+ tariff,
+ topup_balance_bytes,
+ regular_bonus_carry,
+ regular_unlimited_override=regular_unl_carry,
+ traffic_used_bytes=0,
+ )
base_hwid_limit = self._base_hwid_limit_for_tariff(tariff)
effective_hwid_limit = self._effective_hwid_limit(base_hwid_limit, extra_hwid_devices)
premium_is_limited = bool(premium_limit_bytes > 0 and premium_used_bytes >= premium_limit_bytes)
@@ -1595,6 +1941,8 @@ class SubscriptionService:
"tariff_key": tariff.key if tariff else None,
"tier_baseline_bytes": tier_baseline_bytes,
"topup_balance_bytes": topup_balance_bytes,
+ "regular_bonus_bytes": regular_bonus_carry,
+ "regular_unlimited_override": regular_unl_carry,
"premium_baseline_bytes": premium_baseline_bytes,
"premium_topup_balance_bytes": premium_topup_balance_bytes,
"premium_topup_used_bytes": premium_topup_used_bytes,
@@ -1905,6 +2253,10 @@ class SubscriptionService:
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
+ premium_bonus_bytes = int(getattr(local_active_sub, "premium_bonus_bytes", 0) or 0) if local_active_sub else 0
+ premium_unlimited_override = bool(getattr(local_active_sub, "premium_unlimited_override", False)) if local_active_sub else False
+ regular_bonus_bytes = int(getattr(local_active_sub, "regular_bonus_bytes", 0) or 0) if local_active_sub else 0
+ regular_unlimited_override = bool(getattr(local_active_sub, "regular_unlimited_override", False)) if local_active_sub else False
return {
"user_id": panel_user_data.get("uuid"),
@@ -1922,14 +2274,19 @@ class SubscriptionService:
"billing_model": billing_model_display,
"tier_baseline_bytes": local_active_sub.tier_baseline_bytes if local_active_sub else None,
"topup_balance_bytes": local_active_sub.topup_balance_bytes if local_active_sub else 0,
+ "regular_bonus_bytes": regular_bonus_bytes,
+ "regular_unlimited_override": regular_unlimited_override,
"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_bonus_bytes": premium_bonus_bytes,
+ "premium_unlimited_override": premium_unlimited_override,
"premium_limit_bytes": self._premium_effective_limit_bytes(
premium_baseline,
premium_topup_balance,
premium_topup_used,
+ premium_bonus_bytes,
),
"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,
diff --git a/bot/services/tariff_worker.py b/bot/services/tariff_worker.py
index 0e43166..1f24ea3 100644
--- a/bot/services/tariff_worker.py
+++ b/bot/services/tariff_worker.py
@@ -4,7 +4,8 @@ from datetime import datetime, timezone
from typing import Optional
from aiogram import Bot
-from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
+from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup, WebAppInfo
+from aiogram.utils.text_decorations import html_decoration as hd
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import sessionmaker
@@ -14,10 +15,13 @@ from bot.services.panel_api_service import PanelApiService
from bot.services.subscription_service import SubscriptionService
from bot.utils.date_utils import month_start
from config.settings import Settings
-from db.dal import subscription_dal, tariff_dal
+from bot.utils.mini_app_url import subscription_mini_app_topup_url
+from db.dal import subscription_dal, tariff_dal, user_dal
from db.models import Subscription
PREMIUM_WARNING_LEVEL_OFFSET = 1000
+# Single warning per premium billing period when usage reached or exceeded the quota.
+PREMIUM_WARNING_DEPLETED_LEVEL = PREMIUM_WARNING_LEVEL_OFFSET + 100
class TariffTrafficWorker:
@@ -39,6 +43,47 @@ class TariffTrafficWorker:
self._stopped = asyncio.Event()
self._premium_nodes_cache = {}
+ async def _user_lang(self, session: AsyncSession, user_id: int) -> str:
+ try:
+ row = await user_dal.get_user_by_id(session, user_id)
+ if row and getattr(row, "language_code", None):
+ code = str(row.language_code or "").strip()
+ if code:
+ return code
+ except Exception:
+ logging.exception("TariffTrafficWorker: failed to load user language for %s", user_id)
+ return self.settings.DEFAULT_LANGUAGE
+
+ def _usage_placeholders(self, used_bytes: int, limit_bytes: int) -> dict:
+ """Formatted traffic stats for warning messages (HTML-safe quoted)."""
+ used_b = max(0, int(used_bytes or 0))
+ lim_b = max(0, int(limit_bytes or 0))
+ remaining_b = max(0, lim_b - used_b)
+ return {
+ "used": hd.quote(self._fmt_bytes(used_b)),
+ "remaining": hd.quote(self._fmt_bytes(remaining_b)),
+ "limit_total": hd.quote(self._fmt_bytes(lim_b)),
+ }
+
+ def _traffic_topup_markup(self, user_lang: str, kind: str) -> Optional[InlineKeyboardMarkup]:
+ if not self.bot:
+ return None
+ _ = lambda k, **kw: self.i18n.gettext(user_lang, k, **kw) if self.i18n else (lambda key, **_: key)
+ normalized = "premium" if str(kind or "").lower() == "premium" else "regular"
+ url = subscription_mini_app_topup_url(self.settings, normalized)
+ if normalized == "premium":
+ label_key = "traffic_warn_btn_topup_webapp_premium"
+ fallback_key = "traffic_warn_btn_topup_premium"
+ else:
+ label_key = "traffic_warn_btn_topup_webapp_regular"
+ fallback_key = "traffic_warn_btn_topup_regular"
+ # Mini App inside Telegram when SUBSCRIPTION_MINI_APP_URL is configured.
+ if url:
+ button = InlineKeyboardButton(text=_(label_key), web_app=WebAppInfo(url=url))
+ else:
+ button = InlineKeyboardButton(text=_(fallback_key), callback_data="tariff_topup:list")
+ return InlineKeyboardMarkup(inline_keyboard=[[button]])
+
async def run(self) -> None:
if not self.settings.tariffs_config:
return
@@ -108,7 +153,22 @@ class TariffTrafficWorker:
) -> None:
if str(panel_strategy or "").upper() == "MONTH":
return
- traffic_limit_bytes = int(limit or sub.traffic_limit_bytes or (tariff.monthly_bytes + int(sub.topup_balance_bytes or 0)))
+ rb = int(getattr(sub, "regular_bonus_bytes", 0) or 0)
+ if bool(getattr(sub, "regular_unlimited_override", False)):
+ baseline = int(sub.tier_baseline_bytes or (tariff.monthly_bytes if tariff else 0) or 0)
+ traffic_limit_bytes = self.subscription_service._compute_main_traffic_limit_bytes(
+ tier_baseline_bytes=baseline,
+ topup_balance_bytes=int(sub.topup_balance_bytes or 0),
+ regular_bonus_bytes=rb,
+ regular_unlimited_override=True,
+ traffic_used_bytes=int(sub.traffic_used_bytes or 0),
+ )
+ else:
+ traffic_limit_bytes = int(
+ limit
+ or sub.traffic_limit_bytes
+ or (tariff.monthly_bytes + int(sub.topup_balance_bytes or 0) + rb)
+ )
payload = self.subscription_service._build_panel_update_payload(
panel_user_uuid=sub.panel_user_uuid,
expire_at=sub.end_date,
@@ -131,6 +191,8 @@ class TariffTrafficWorker:
*,
warning_period_start: Optional[datetime] = None,
) -> None:
+ if bool(getattr(sub, "regular_unlimited_override", False)):
+ return
used_val = int(used or sub.traffic_used_bytes or 0)
limit_val = int(limit or sub.traffic_limit_bytes or 0)
if limit_val <= 0:
@@ -159,22 +221,35 @@ class TariffTrafficWorker:
)
if self.bot:
try:
+ user_lang = await self._user_lang(session, sub.user_id)
+ _ = (
+ (lambda k, **kw: self.i18n.gettext(user_lang, k, **kw))
+ if self.i18n
+ else (lambda k, **kw: k)
+ )
left_pct = max(0, 100 - level)
+ tariff_name = hd.quote(str(tariff.name(user_lang)))
+ usage = self._usage_placeholders(used_val, limit_val)
if level < 100:
- text = f"Трафик тарифа {tariff.name(self.settings.DEFAULT_LANGUAGE)} почти закончился. Осталось около {left_pct}%."
+ text = _(
+ "traffic_warning_regular_almost",
+ tariff_name=tariff_name,
+ left_pct=left_pct,
+ **usage,
+ )
else:
- text = "Трафик закончился. Доступ временно ограничен до сброса или докупки пакета."
- markup = InlineKeyboardMarkup(
- inline_keyboard=[
- [
- InlineKeyboardButton(
- text="Докупить трафик",
- callback_data="tariff_topup:list",
- )
- ]
- ]
+ text = _(
+ "traffic_warning_regular_depleted",
+ tariff_name=tariff_name,
+ **usage,
+ )
+ markup = self._traffic_topup_markup(user_lang, "regular")
+ await self.bot.send_message(
+ sub.user_id,
+ text,
+ reply_markup=markup,
+ parse_mode="HTML",
)
- await self.bot.send_message(sub.user_id, text, reply_markup=markup)
except Exception:
logging.exception("Failed to send traffic warning to user %s", sub.user_id)
if ratio >= 1.0 and not sub.is_throttled:
@@ -214,8 +289,11 @@ class TariffTrafficWorker:
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:
+ # Admin-side overrides for free gifted premium traffic.
+ premium_unlimited_override = bool(getattr(sub, "premium_unlimited_override", False))
+ premium_bonus = max(0, int(getattr(sub, "premium_bonus_bytes", 0) or 0))
+ premium_limit = premium_baseline + premium_topup_balance + premium_topup_used + premium_bonus
+ if premium_limit <= 0 and not premium_unlimited_override:
return
node_uuids = await self._premium_node_uuids_for_tariff(tariff)
@@ -235,15 +313,22 @@ class TariffTrafficWorker:
if premium_used is None:
return
- overflow = max(0, int(premium_used) - premium_baseline)
+ # Consume paid top-up balance only for overflow beyond baseline+bonus.
+ # Admin-granted bonus is "spent" against usage first along with baseline,
+ # so the user's paid top-up survives longer.
+ free_quota = premium_baseline + premium_bonus
+ overflow = max(0, int(premium_used) - free_quota)
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
+ premium_limit = premium_baseline + premium_topup_balance + premium_topup_used + premium_bonus
- should_limit = premium_used >= premium_limit
+ if premium_unlimited_override:
+ should_limit = False
+ else:
+ 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
@@ -258,14 +343,15 @@ class TariffTrafficWorker:
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 premium_unlimited_override:
+ await self._maybe_warn_premium_squad_limit(
+ session,
+ sub,
+ tariff,
+ premium_used,
+ premium_limit,
+ premium_period_start,
+ )
if not changed:
return
@@ -307,9 +393,67 @@ class TariffTrafficWorker:
) -> None:
if limit <= 0:
return
- ratio = int(used or 0) / int(limit)
+ used_val = int(used or 0)
+ limit_val = int(limit)
+ ratio = used_val / limit_val
levels = list(getattr(self.settings, "tariff_traffic_warning_levels", [85, 90, 95]))
+
+ # Fully exhausted or over quota — one message per period (same idea as regular traffic at 100%).
+ if ratio >= 1.0:
+ depleted_existing = await tariff_dal.get_warning(
+ session,
+ subscription_id=sub.subscription_id,
+ period_start_at=period_start_at,
+ level=PREMIUM_WARNING_DEPLETED_LEVEL,
+ )
+ if depleted_existing:
+ return
+ await tariff_dal.create_warning(
+ session,
+ subscription_id=sub.subscription_id,
+ period_start_at=period_start_at,
+ level=PREMIUM_WARNING_DEPLETED_LEVEL,
+ traffic_limit_bytes=None,
+ )
+ if self.bot:
+ try:
+ user_lang = await self._user_lang(session, sub.user_id)
+ _ = (
+ (lambda k, **kw: self.i18n.gettext(user_lang, k, **kw))
+ if self.i18n
+ else (lambda k, **kw: k)
+ )
+ access = await self.subscription_service.premium_access_for_tariff(tariff)
+ labels = access.get("node_labels") or access.get("squad_labels") or []
+ if labels:
+ visible = [hd.quote(str(x)) for x in labels[:8]]
+ servers = "\n".join(f"• {label}" for label in visible)
+ if len(labels) > len(visible):
+ more = len(labels) - len(visible)
+ servers += "\n" + _("traffic_warning_premium_servers_more", count=more)
+ else:
+ servers = _("traffic_warning_premium_generic_servers")
+ usage = self._usage_placeholders(used_val, limit_val)
+ text = _(
+ "traffic_warning_premium_depleted",
+ tariff_name=hd.quote(str(tariff.name(user_lang))),
+ servers=servers,
+ **usage,
+ )
+ markup = self._traffic_topup_markup(user_lang, "premium")
+ await self.bot.send_message(
+ sub.user_id,
+ text,
+ reply_markup=markup,
+ parse_mode="HTML",
+ )
+ except Exception:
+ logging.exception("Failed to send premium traffic depleted warning to user %s", sub.user_id)
+ return
+
for level in levels:
+ if level >= 100:
+ continue
if ratio < level / 100:
continue
storage_level = PREMIUM_WARNING_LEVEL_OFFSET + int(level)
@@ -331,34 +475,38 @@ class TariffTrafficWorker:
if not self.bot:
continue
try:
+ user_lang = await self._user_lang(session, sub.user_id)
+ _ = (
+ (lambda k, **kw: self.i18n.gettext(user_lang, k, **kw))
+ if self.i18n
+ else (lambda k, **kw: k)
+ )
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]
+ visible = [hd.quote(str(x)) for x in labels[:8]]
servers = "\n".join(f"• {label}" for label in visible)
if len(labels) > len(visible):
- servers += f"\n• ... еще {len(labels) - len(visible)}"
+ more = len(labels) - len(visible)
+ servers += "\n" + _("traffic_warning_premium_servers_more", count=more)
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-трафик. Докупленный остаток переносится на следующие месяцы, пока не израсходуется."
+ servers = _("traffic_warning_premium_generic_servers")
+ left_pct = max(0, 100 - int(level))
+ usage = self._usage_placeholders(used_val, limit_val)
+ text = _(
+ "traffic_warning_premium_almost",
+ tariff_name=hd.quote(str(tariff.name(user_lang))),
+ left_pct=left_pct,
+ servers=servers,
+ **usage,
)
- markup = InlineKeyboardMarkup(
- inline_keyboard=[
- [
- InlineKeyboardButton(
- text="Докупить premium-трафик",
- callback_data="tariff_topup:list",
- )
- ]
- ]
+ markup = self._traffic_topup_markup(user_lang, "premium")
+ await self.bot.send_message(
+ sub.user_id,
+ text,
+ reply_markup=markup,
+ parse_mode="HTML",
)
- 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)
diff --git a/bot/states/admin_states.py b/bot/states/admin_states.py
index 68b0c4a..ab15a3c 100644
--- a/bot/states/admin_states.py
+++ b/bot/states/admin_states.py
@@ -29,6 +29,8 @@ class AdminStates(StatesGroup):
waiting_for_subscription_days_to_add = State()
waiting_for_direct_message_to_user = State()
waiting_for_user_delete_confirmation = State()
+ waiting_for_premium_override_bonus_gb = State()
+ waiting_for_traffic_grant_gb = State()
# Ads campaigns
waiting_for_ad_source = State()
diff --git a/bot/utils/mini_app_url.py b/bot/utils/mini_app_url.py
new file mode 100644
index 0000000..51f51f4
--- /dev/null
+++ b/bot/utils/mini_app_url.py
@@ -0,0 +1,32 @@
+"""Helpers for Telegram Mini App URLs (subscription webapp)."""
+from __future__ import annotations
+
+from typing import Optional
+from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
+
+from config.settings import Settings
+
+
+def append_query_params(base_url: str, params: dict[str, str]) -> str:
+ """Merge params into an existing URL query string (adds or replaces keys)."""
+ raw = (base_url or "").strip()
+ if not raw:
+ return ""
+ parts = urlsplit(raw)
+ existing = dict(parse_qsl(parts.query, keep_blank_values=True))
+ for key, value in params.items():
+ if value is None:
+ existing.pop(key, None)
+ else:
+ existing[key] = str(value)
+ query = urlencode(existing)
+ return urlunsplit((parts.scheme, parts.netloc, parts.path, query, parts.fragment))
+
+
+def subscription_mini_app_topup_url(settings: Settings, kind: str) -> Optional[str]:
+ """Return Mini App URL that opens the traffic top-up flow for ``kind`` (``regular`` or ``premium``)."""
+ base = str(getattr(settings, "SUBSCRIPTION_MINI_APP_URL", None) or "").strip()
+ if not base:
+ return None
+ normalized = "premium" if str(kind or "").strip().lower() == "premium" else "regular"
+ return append_query_params(base, {"topup": normalized})
diff --git a/db/migrator.py b/db/migrator.py
index 6eee955..3452fbb 100644
--- a/db/migrator.py
+++ b/db/migrator.py
@@ -544,6 +544,87 @@ def _migration_0016_add_message_logs_admin_fields(connection: Connection) -> Non
)
+def _migration_0018_add_premium_admin_overrides(connection: Connection) -> None:
+ """Per-subscription overrides letting admins gift extra premium traffic or unlimited access."""
+ inspector = inspect(connection)
+ sub_columns: Set[str] = {col["name"] for col in inspector.get_columns("subscriptions")}
+ statements: List[str] = []
+ if "premium_unlimited_override" not in sub_columns:
+ statements.append(
+ "ALTER TABLE subscriptions ADD COLUMN premium_unlimited_override BOOLEAN NOT NULL DEFAULT FALSE"
+ )
+ if "premium_bonus_bytes" not in sub_columns:
+ statements.append(
+ "ALTER TABLE subscriptions ADD COLUMN premium_bonus_bytes BIGINT NOT NULL DEFAULT 0"
+ )
+ for stmt in statements:
+ connection.execute(text(stmt))
+ connection.execute(
+ text(
+ "CREATE INDEX IF NOT EXISTS ix_subscriptions_premium_unlimited_override "
+ "ON subscriptions (premium_unlimited_override)"
+ )
+ )
+
+
+def _migration_0021_add_regular_unlimited_override(connection: Connection) -> None:
+ inspector = inspect(connection)
+ sub_columns: Set[str] = {col["name"] for col in inspector.get_columns("subscriptions")}
+ if "regular_unlimited_override" not in sub_columns:
+ connection.execute(
+ text(
+ "ALTER TABLE subscriptions ADD COLUMN regular_unlimited_override BOOLEAN NOT NULL DEFAULT FALSE"
+ )
+ )
+ connection.execute(
+ text(
+ "CREATE INDEX IF NOT EXISTS ix_subscriptions_regular_unlimited_override "
+ "ON subscriptions (regular_unlimited_override)"
+ )
+ )
+
+
+def _migration_0020_add_regular_bonus_bytes(connection: Connection) -> None:
+ """Admin-granted extra bytes on main (non-premium) traffic limit, like premium_bonus_bytes."""
+ inspector = inspect(connection)
+ sub_columns: Set[str] = {col["name"] for col in inspector.get_columns("subscriptions")}
+ if "regular_bonus_bytes" not in sub_columns:
+ connection.execute(
+ text(
+ "ALTER TABLE subscriptions ADD COLUMN regular_bonus_bytes BIGINT NOT NULL DEFAULT 0"
+ )
+ )
+
+
+def _migration_0019_clear_subscription_months_for_non_subscription_payments(connection: Connection) -> None:
+ """Null out subscription_duration_months for legacy non-subscription payments.
+
+ Older builds stored the raw `months` callback value into
+ `subscription_duration_months` for every sale_mode, including traffic
+ top-ups and HWID device packs. This polluted CSV exports and made admin
+ log messages render top-ups as "N мес.". The new code only sets the
+ column for subscription sales; this migration aligns historical rows.
+ """
+ inspector = inspect(connection)
+ table_names = set(inspector.get_table_names())
+ if "payments" not in table_names:
+ return
+ pay_columns: Set[str] = {col["name"] for col in inspector.get_columns("payments")}
+ if "subscription_duration_months" not in pay_columns or "sale_mode" not in pay_columns:
+ return
+ connection.execute(
+ text(
+ """
+ UPDATE payments
+ SET subscription_duration_months = NULL
+ WHERE subscription_duration_months IS NOT NULL
+ AND sale_mode IS NOT NULL
+ AND split_part(split_part(sale_mode, '@', 1), '|', 1) <> 'subscription'
+ """
+ )
+ )
+
+
def _migration_0017_reconcile_legacy_admin_api_schema(connection: Connection) -> None:
"""Backfill columns required by admin user detail API on legacy databases.
@@ -705,6 +786,26 @@ MIGRATIONS: List[Migration] = [
description="Reconcile legacy DB schema for admin user detail endpoint compatibility",
upgrade=_migration_0017_reconcile_legacy_admin_api_schema,
),
+ Migration(
+ id="0018_add_premium_admin_overrides",
+ description="Per-subscription admin overrides for premium traffic (unlimited toggle + bonus bytes)",
+ upgrade=_migration_0018_add_premium_admin_overrides,
+ ),
+ Migration(
+ id="0019_clear_subscription_months_for_non_subscription_payments",
+ description="Backfill: null out subscription_duration_months for legacy traffic/topup/hwid payments",
+ upgrade=_migration_0019_clear_subscription_months_for_non_subscription_payments,
+ ),
+ Migration(
+ id="0020_add_regular_bonus_bytes",
+ description="Per-subscription admin bonus bytes on regular (main) traffic limit",
+ upgrade=_migration_0020_add_regular_bonus_bytes,
+ ),
+ Migration(
+ id="0021_add_regular_unlimited_override",
+ description="Admin toggle for effectively unlimited main traffic limit",
+ upgrade=_migration_0021_add_regular_unlimited_override,
+ ),
]
diff --git a/db/models.py b/db/models.py
index 80cb78a..8615670 100644
--- a/db/models.py
+++ b/db/models.py
@@ -119,6 +119,10 @@ class Subscription(Base):
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)
+ premium_unlimited_override = Column(Boolean, nullable=False, default=False, index=True)
+ premium_bonus_bytes = Column(BigInteger, nullable=False, default=0)
+ regular_bonus_bytes = Column(BigInteger, nullable=False, default=0)
+ regular_unlimited_override = Column(Boolean, nullable=False, default=False, index=True)
period_start_at = Column(DateTime(timezone=True), nullable=True)
is_throttled = Column(Boolean, nullable=False, default=False, index=True)
effective_monthly_price_rub = Column(Numeric, nullable=True)
diff --git a/locales/en.json b/locales/en.json
index 49d609b..ebb70a7 100644
--- a/locales/en.json
+++ b/locales/en.json
@@ -289,6 +289,38 @@
"admin_user_view_logs_button": "📜 User Actions",
"admin_user_refresh_button": "🔄 Refresh",
"admin_user_delete_button": "❌ Delete User",
+ "admin_user_premium_override_button": "⭐ Premium override",
+ "admin_user_premium_override_label": "🌟 Premium override:",
+ "admin_user_premium_override_unlimited": "Unlimited",
+ "admin_user_premium_override_bonus_value": "+{gb} GB",
+ "admin_premium_override_title": "🌟 Premium override",
+ "admin_premium_override_hint": "Grant free extra premium traffic or unlimited premium access for this user.",
+ "admin_premium_override_current": "Current: {current}",
+ "admin_premium_override_state_unlimited": "unlimited",
+ "admin_premium_override_state_bonus": "+{gb} GB bonus",
+ "admin_premium_override_state_none": "not set",
+ "admin_premium_override_btn_unlimited": "♾ Make unlimited",
+ "admin_premium_override_btn_bonus": "➕ Set bonus GB",
+ "admin_premium_override_btn_clear": "🗑 Clear override",
+ "admin_premium_override_no_subscription": "User has no active subscription",
+ "admin_premium_override_bonus_prompt": "Enter additional premium GB for user {user_id} (number, decimals allowed):",
+ "admin_premium_override_invalid_gb": "❌ Invalid value. Enter a non-negative number of GB.",
+ "admin_premium_override_state_missing": "⚠️ Target user missing in state. Open the user card again.",
+ "admin_premium_override_bonus_set": "✅ Premium override: +{gb} GB for user {user_id}",
+ "admin_premium_override_saved": "✅ Premium override saved",
+ "admin_premium_override_save_error": "❌ Failed to save premium override",
+ "admin_user_traffic_grant_button": "🎁 Grant GB",
+ "admin_traffic_grant_title": "🎁 Grant traffic",
+ "admin_traffic_grant_hint": "Credit GB to the user's balance — same effect as a top-up purchase, but without payment. The panel limit and squads refresh immediately.",
+ "admin_traffic_grant_btn_regular": "🟢 Regular traffic",
+ "admin_traffic_grant_btn_premium": "⭐ Premium traffic",
+ "admin_traffic_grant_prompt_regular": "Enter the amount of regular traffic in GB for user {user_id} (number, decimals allowed):",
+ "admin_traffic_grant_prompt_premium": "Enter the amount of premium traffic in GB for user {user_id} (number, decimals allowed):",
+ "admin_traffic_grant_invalid_gb": "❌ Enter a positive number of GB (up to 1,000,000).",
+ "admin_traffic_grant_no_user": "⚠️ No target user selected. Open the user card again.",
+ "admin_traffic_grant_failed": "❌ Failed to grant traffic. Make sure the user has an active subscription and the tariff supports premium squads.",
+ "admin_traffic_grant_regular_done": "✅ +{gb} GB of regular traffic granted to user {user_id}",
+ "admin_traffic_grant_premium_done": "✅ +{gb} GB of premium traffic granted to user {user_id}",
"admin_user_delete_not_allowed": "❌ You are not permitted to delete users.",
"admin_user_delete_confirmation_prompt": "⚠️ You are about to delete user {user_id} completely.\n\nSend the exact Telegram ID of this user to confirm.\nAny other reply will cancel the operation.",
"admin_user_delete_state_missing": "⚠️ No active delete operation. Start again.",
@@ -340,7 +372,21 @@
"log_account_email_linked": "📧 Email linked\n\n🆔 User ID: {user_id}\n📨 Telegram ID: {telegram_id}\n👤 User: {user_display}\n📧 Email: {email}\n🕐 Time: {timestamp}",
"log_account_telegram_linked": "📨 Telegram linked\n\n🆔 User ID: {user_id}\n📨 Telegram ID: {telegram_id}\n👤 User: {user_display}\n📧 Email: {email}\n🕐 Time: {timestamp}",
"log_payment_received": "{provider_emoji} Payment Received\n\n👤 User: {user_display}\n💰 Amount: {amount} {currency}\n📅 Period: {months} mo.\n🏦 Provider: {payment_provider}\n🕐 Time: {timestamp}",
- "log_payment_received_traffic": "{provider_emoji} Payment Received\n\n👤 User: {user_display}\n💰 Amount: {amount} {currency}\n🗂 Traffic: {traffic_gb} GB\n🏦 Provider: {payment_provider}\n🕐 Time: {timestamp}",
+ "log_payment_received_traffic": "{provider_emoji} Payment Received (traffic top-up)\n\n👤 User: {user_display}\n💰 Amount: {amount} {currency}\n🗂 {traffic_summary}\n{tariff_line}🏦 Provider: {payment_provider}\n🕐 Time: {timestamp}",
+ "log_payment_traffic_purchase_line": "{gb} GB · {kind}",
+ "log_payment_traffic_kind_regular": "regular traffic",
+ "log_payment_traffic_kind_premium": "premium traffic",
+ "log_payment_tariff_line": "📋 Plan: {name}\n",
+ "traffic_warning_regular_almost": "⚠️ Traffic for plan {tariff_name} is almost used up.\nAbout {left_pct}% remains.\n\nCurrent period:\nused — {used}\navailable — {remaining}\nlimit — {limit_total}\n\nUse the button below to buy more regular traffic in the mini app.",
+ "traffic_warning_regular_depleted": "⛔️ Traffic for plan {tariff_name} is depleted.\n\nCurrent period:\nused — {used}\navailable — {remaining}\nlimit — {limit_total}\n\nAccess may be limited until the period resets or you top up.\n\nUse the button below to buy traffic.",
+ "traffic_warning_premium_almost": "⚠️ The premium server quota for plan {tariff_name} is almost used up.\nAbout {left_pct}% remains.\n\nCurrent period:\nused — {used}\navailable — {remaining}\nlimit — {limit_total}\n\nThis limit applies to:\n{servers}\n\nUse the button below to buy premium traffic in the mini app; unused purchased balance carries over to later months.",
+ "traffic_warning_premium_depleted": "⛔️ The premium server quota for plan {tariff_name} is used up.\n\nCurrent period:\nused — {used}\navailable — {remaining}\nlimit — {limit_total}\n\nThis limit applies to:\n{servers}\n\nAccess may be limited until the period resets or you buy more premium traffic.\n\nUse the button below to top up.",
+ "traffic_warning_premium_generic_servers": "• servers with premium access under your plan",
+ "traffic_warning_premium_servers_more": "… and {count} more servers",
+ "traffic_warn_btn_topup_webapp_regular": "Top up traffic (mini app)",
+ "traffic_warn_btn_topup_webapp_premium": "Top up premium traffic (mini app)",
+ "traffic_warn_btn_topup_regular": "Top up traffic",
+ "traffic_warn_btn_topup_premium": "Top up premium traffic",
"log_promo_activation": "🎁 Promo Code Activated\n\n👤 User: {user_display}\n🏷 Code: {promo_code}\n🎯 Bonus: +{bonus_days}d\n🕐 Time: {timestamp}",
"log_trial_activation": "🆓 Trial Activated\n\n👤 User: {user_display}\n⏰ Valid until: {end_date}\n🕐 Time: {timestamp}",
"log_panel_sync": "{status_emoji} Panel Synchronization\n\n📊 Status: {status}\n👥 Users processed: {users_processed}\n📋 Subscriptions synced: {subs_synced}\n🕐 Time: {timestamp}\n\n📝 Details:\n{details}",
@@ -639,6 +685,7 @@
"wa_premium_traffic_title": "Premium servers",
"wa_premium_reset_monthly": "Separate monthly limit",
"wa_premium_access_limited": "Premium access is temporarily limited",
+ "wa_premium_unlimited": "Unlimited premium access",
"wa_premium_left": "Left",
"wa_premium_topup_balance": "Top-up balance",
"wa_premium_servers_limited": "Separate limit applies to",
@@ -860,6 +907,13 @@
"admin_status": "Status",
"admin_date": "Date",
"admin_payments_empty": "No payments",
+ "admin_payments_col_telegram_id": "Telegram ID",
+ "admin_payments_col_traffic_regular": "Main traffic",
+ "admin_payments_col_traffic_premium": "Premium traffic",
+ "admin_payments_col_actions": "",
+ "admin_payments_open_user": "Open user card",
+ "admin_payments_desc_traffic_package_regular": "Traffic package {gb} GB (standard)",
+ "admin_payments_desc_traffic_package_premium": "Traffic package {gb} GB (premium)",
"admin_logs_user_filter_placeholder": "Filter by user ID",
"admin_apply": "Apply",
"admin_reset": "Reset",
@@ -988,6 +1042,44 @@
"admin_user_label_main_traffic": "Main Traffic",
"admin_user_traffic_left": "Left: {left}",
"admin_user_label_premium_squads": "Premium Squads",
+ "user_override_unlimited_short": "Unlimited",
+ "user_premium_override_card_title": "Premium traffic",
+ "user_premium_override_card_hint": "Unlimited access and extra volume for premium squads on top of the tariff.",
+ "user_regular_override_card_title": "Main traffic",
+ "user_regular_override_card_hint": "Unlimited-style ceiling and a persistent bonus on the main traffic limit.",
+ "user_regular_override_save": "Save",
+ "user_regular_override_status_unlimited": "Current: unlimited",
+ "regular_override_saved": "Main traffic override saved",
+ "user_traffic_override_title": "Traffic overrides",
+ "user_traffic_override_hint": "Extra main or premium traffic limits on top of the tariff; unlimited applies to premium squads only.",
+ "user_regular_override_bonus": "Extra main traffic, GB",
+ "user_regular_override_bonus_hint": "Persistent bonus bytes added to the main traffic limit (not a balance top-up).",
+ "user_regular_override_status_bonus": "Main traffic now: +{gb} GB",
+ "user_regular_override_status_none": "No main-traffic bonus override",
+ "regular_override_invalid_bonus": "Invalid GB value for main traffic",
+ "user_premium_override_title": "Premium override",
+ "user_premium_override_hint": "Grant free unlimited access or extra premium traffic on top of the tariff limit.",
+ "user_premium_override_bonus_hint": "Ignored while unlimited premium is enabled.",
+ "user_premium_override_unlimited": "Unlimited premium",
+ "user_premium_override_bonus": "Extra premium traffic, GB",
+ "user_premium_override_save": "Save override",
+ "user_premium_override_status_unlimited": "Current: unlimited",
+ "user_premium_override_status_bonus": "Current: +{gb} GB",
+ "user_premium_override_status_none": "No premium override",
+ "user_premium_unlimited_value": "∞ (used {used})",
+ "user_premium_unlimited_hint": "Unlimited (admin override)",
+ "premium_override_saved": "Premium override saved",
+ "premium_override_invalid_bonus": "Invalid GB value",
+ "user_traffic_grant_title": "Grant traffic",
+ "user_traffic_grant_hint": "Credit GB to the user's balance — same effect as a top-up purchase, but without payment. The panel limit and squads refresh immediately.",
+ "user_traffic_grant_kind": "Traffic kind",
+ "user_traffic_grant_kind_regular": "Regular",
+ "user_traffic_grant_kind_premium": "Premium",
+ "user_traffic_grant_gb": "GB to grant",
+ "user_traffic_grant_submit": "Grant",
+ "traffic_grant_invalid_gb": "Enter a positive number of GB",
+ "traffic_grant_regular_done": "+{gb} GB of regular traffic granted",
+ "traffic_grant_premium_done": "+{gb} GB of premium traffic granted",
"admin_user_no_active_subscription": "No active subscription",
"admin_user_history_title": "Subscription History · {count}",
"admin_user_history_no_tariff": "No tariff",
diff --git a/locales/ru.json b/locales/ru.json
index aee7f56..fcd135d 100644
--- a/locales/ru.json
+++ b/locales/ru.json
@@ -289,6 +289,38 @@
"admin_user_view_logs_button": "📜 Логи",
"admin_user_refresh_button": "🔄 Обновить",
"admin_user_delete_button": "❌ Удалить пользователя",
+ "admin_user_premium_override_button": "⭐ Премиум-оверрайд",
+ "admin_user_premium_override_label": "🌟 Премиум-оверрайд:",
+ "admin_user_premium_override_unlimited": "Безлимит",
+ "admin_user_premium_override_bonus_value": "+{gb} GB",
+ "admin_premium_override_title": "🌟 Премиум-оверрайд",
+ "admin_premium_override_hint": "Бесплатно увеличьте лимит премиум-трафика или сделайте его безлимитным для этого пользователя.",
+ "admin_premium_override_current": "Сейчас: {current}",
+ "admin_premium_override_state_unlimited": "безлимит",
+ "admin_premium_override_state_bonus": "+{gb} GB бонусом",
+ "admin_premium_override_state_none": "не задан",
+ "admin_premium_override_btn_unlimited": "♾ Включить безлимит",
+ "admin_premium_override_btn_bonus": "➕ Задать бонусные GB",
+ "admin_premium_override_btn_clear": "🗑 Снять оверрайд",
+ "admin_premium_override_no_subscription": "У пользователя нет активной подписки",
+ "admin_premium_override_bonus_prompt": "Введите количество дополнительных премиум-GB для пользователя {user_id} (число, можно дробное):",
+ "admin_premium_override_invalid_gb": "❌ Неверное значение. Введите неотрицательное число GB.",
+ "admin_premium_override_state_missing": "⚠️ Целевой пользователь не найден в состоянии. Откройте карточку пользователя заново.",
+ "admin_premium_override_bonus_set": "✅ Премиум-оверрайд: +{gb} GB для пользователя {user_id}",
+ "admin_premium_override_saved": "✅ Премиум-оверрайд сохранён",
+ "admin_premium_override_save_error": "❌ Не удалось сохранить премиум-оверрайд",
+ "admin_user_traffic_grant_button": "🎁 Выдать ГБ",
+ "admin_traffic_grant_title": "🎁 Выдать трафик",
+ "admin_traffic_grant_hint": "Зачисление ГБ на баланс пользователя — как при докупке, но без оплаты. Лимит и сквады в панели обновятся сразу.",
+ "admin_traffic_grant_btn_regular": "🟢 Обычный трафик",
+ "admin_traffic_grant_btn_premium": "⭐ Премиум-трафик",
+ "admin_traffic_grant_prompt_regular": "Введите количество ГБ обычного трафика для пользователя {user_id} (число, можно дробное):",
+ "admin_traffic_grant_prompt_premium": "Введите количество ГБ премиум-трафика для пользователя {user_id} (число, можно дробное):",
+ "admin_traffic_grant_invalid_gb": "❌ Введите положительное число ГБ (до 1 000 000).",
+ "admin_traffic_grant_no_user": "⚠️ Целевой пользователь не выбран. Откройте карточку заново.",
+ "admin_traffic_grant_failed": "❌ Не удалось выдать трафик. Проверьте, что у пользователя активная подписка и тариф поддерживает премиум-сквады.",
+ "admin_traffic_grant_regular_done": "✅ +{gb} ГБ обычного трафика выдано пользователю {user_id}",
+ "admin_traffic_grant_premium_done": "✅ +{gb} ГБ премиум-трафика выдано пользователю {user_id}",
"admin_user_delete_not_allowed": "❌ У вас нет прав для удаления пользователей.",
"admin_user_delete_confirmation_prompt": "⚠️ Вы хотите полностью удалить пользователя {user_id}.\n\nОтправьте точный Telegram ID этого пользователя, чтобы подтвердить удаление.\nЛюбой другой ответ отменит операцию.",
"admin_user_delete_state_missing": "⚠️ Нет активной операции удаления. Начните заново.",
@@ -340,7 +372,21 @@
"log_account_email_linked": "📧 Привязана почта\n\n🆔 ID пользователя: {user_id}\n📨 Telegram ID: {telegram_id}\n👤 Пользователь: {user_display}\n📧 Email: {email}\n🕐 Время: {timestamp}",
"log_account_telegram_linked": "📨 Привязан Telegram\n\n🆔 ID пользователя: {user_id}\n📨 Telegram ID: {telegram_id}\n👤 Пользователь: {user_display}\n📧 Email: {email}\n🕐 Время: {timestamp}",
"log_payment_received": "{provider_emoji} Получен платеж\n\n👤 Пользователь: {user_display}\n💰 Сумма: {amount} {currency}\n📅 Период: {months} мес.\n🏦 Провайдер: {payment_provider}\n🕐 Время: {timestamp}",
- "log_payment_received_traffic": "{provider_emoji} Получен платеж\n\n👤 Пользователь: {user_display}\n💰 Сумма: {amount} {currency}\n🗂 Трафик: {traffic_gb} ГБ\n🏦 Провайдер: {payment_provider}\n🕐 Время: {timestamp}",
+ "log_payment_received_traffic": "{provider_emoji} Получен платеж (докупка трафика)\n\n👤 Пользователь: {user_display}\n💰 Сумма: {amount} {currency}\n🗂 {traffic_summary}\n{tariff_line}🏦 Провайдер: {payment_provider}\n🕐 Время: {timestamp}",
+ "log_payment_traffic_purchase_line": "{gb} ГБ · {kind}",
+ "log_payment_traffic_kind_regular": "основной трафик",
+ "log_payment_traffic_kind_premium": "премиум-трафик",
+ "log_payment_tariff_line": "📋 Тариф: {name}\n",
+ "traffic_warning_regular_almost": "⚠️ Трафик тарифа {tariff_name} почти израсходован.\nОсталось примерно {left_pct}%.\n\nТекущий период:\nизрасходовано — {used}\nдоступно — {remaining}\nлимит — {limit_total}\n\nДокупить основной трафик можно через кнопку ниже.",
+ "traffic_warning_regular_depleted": "⛔️ Трафик тарифа {tariff_name} израсходован.\n\nТекущий период:\nизрасходовано — {used}\nдоступно — {remaining}\nлимит — {limit_total}\n\nДоступ может быть ограничен до сброса периода или докупки пакета.\n\nДокупить трафик можно через кнопку ниже.",
+ "traffic_warning_premium_almost": "⚠️ Лимит премиум-серверов тарифа {tariff_name} почти израсходован.\nОсталось примерно {left_pct}%.\n\nТекущий период:\nизрасходовано — {used}\nдоступно — {remaining}\nлимит — {limit_total}\n\nЛимит действует на:\n{servers}\n\nДокупить премиум-трафик можно через кнопку ниже; неизрасходованный остаток переносится на следующие месяцы.",
+ "traffic_warning_premium_depleted": "⛔️ Лимит премиум-серверов тарифа {tariff_name} израсходован.\n\nТекущий период:\nизрасходовано — {used}\nдоступно — {remaining}\nлимит — {limit_total}\n\nЛимит действует на:\n{servers}\n\nДоступ может быть ограничен до сброса периода или докупки премиум-трафика.\n\nДокупить можно через кнопку ниже.",
+ "traffic_warning_premium_generic_servers": "• серверы с премиум-доступом по вашему тарифу",
+ "traffic_warning_premium_servers_more": "… и ещё серверов: {count}",
+ "traffic_warn_btn_topup_webapp_regular": "Докупить трафик (мини-приложение)",
+ "traffic_warn_btn_topup_webapp_premium": "Докупить премиум-трафик (мини-приложение)",
+ "traffic_warn_btn_topup_regular": "Докупить трафик",
+ "traffic_warn_btn_topup_premium": "Докупить премиум-трафик",
"log_promo_activation": "🎁 Активирован промокод\n\n👤 Пользователь: {user_display}\n🏷 Код: {promo_code}\n🎯 Бонус: +{bonus_days} дн.\n🕐 Время: {timestamp}",
"log_trial_activation": "🆓 Активирован триал\n\n👤 Пользователь: {user_display}\n⏰ Действует до: {end_date}\n🕐 Время: {timestamp}",
"log_panel_sync": "{status_emoji} Синхронизация с панелью\n\n📊 Статус: {status}\n👥 Обработано пользователей: {users_processed}\n📋 Синхронизировано подписок: {subs_synced}\n🕐 Время: {timestamp}\n\n📝 Детали:\n{details}",
@@ -639,6 +685,7 @@
"wa_premium_traffic_title": "Premium-серверы",
"wa_premium_reset_monthly": "Отдельный лимит на месяц",
"wa_premium_access_limited": "Доступ к premium временно ограничен",
+ "wa_premium_unlimited": "Безлимит на premium-сервера",
"wa_premium_left": "Осталось",
"wa_premium_topup_balance": "Докупленный остаток",
"wa_premium_servers_limited": "Отдельный лимит действует на",
@@ -860,6 +907,13 @@
"admin_status": "Статус",
"admin_date": "Дата",
"admin_payments_empty": "Нет платежей",
+ "admin_payments_col_telegram_id": "Telegram ID",
+ "admin_payments_col_traffic_regular": "Основной трафик",
+ "admin_payments_col_traffic_premium": "Премиум трафик",
+ "admin_payments_col_actions": "",
+ "admin_payments_open_user": "Открыть карточку пользователя",
+ "admin_payments_desc_traffic_package_regular": "Пакет трафика {gb} ГБ (обычный)",
+ "admin_payments_desc_traffic_package_premium": "Пакет трафика {gb} ГБ (премиум)",
"admin_logs_user_filter_placeholder": "Фильтр по ID пользователя",
"admin_apply": "Применить",
"admin_reset": "Сбросить",
@@ -988,6 +1042,44 @@
"admin_user_label_main_traffic": "Основной трафик",
"admin_user_traffic_left": "Осталось: {left}",
"admin_user_label_premium_squads": "Premium-сквады",
+ "user_override_unlimited_short": "Безлимит",
+ "user_premium_override_card_title": "Премиум-трафик",
+ "user_premium_override_card_hint": "Безлимит и дополнительный объём для премиум-сквадов поверх тарифа.",
+ "user_regular_override_card_title": "Основной трафик",
+ "user_regular_override_card_hint": "Режим безлимита и постоянный бонус к лимиту основного трафика.",
+ "user_regular_override_save": "Сохранить",
+ "user_regular_override_status_unlimited": "Сейчас: безлимит",
+ "regular_override_saved": "Оверрайд основного трафика сохранён",
+ "user_traffic_override_title": "Оверрайд трафика",
+ "user_traffic_override_hint": "Дополнительный лимит основного и премиум-трафика поверх тарифа; безлимит только для премиум-сквадов.",
+ "user_regular_override_bonus": "Доп. основной трафик, GB",
+ "user_regular_override_bonus_hint": "Постоянный бонус к лимиту основного трафика (не то же самое, что докупка ГБ).",
+ "user_regular_override_status_bonus": "Основной сейчас: +{gb} GB",
+ "user_regular_override_status_none": "Бонус основного трафика не задан",
+ "regular_override_invalid_bonus": "Некорректное значение GB для основного трафика",
+ "user_premium_override_title": "Премиум-оверрайд",
+ "user_premium_override_hint": "Подарите пользователю бесплатный безлимит или дополнительный премиум-трафик сверх лимита тарифа.",
+ "user_premium_override_bonus_hint": "Не используется, пока включён безлимит на премиум.",
+ "user_premium_override_unlimited": "Безлимит на премиум",
+ "user_premium_override_bonus": "Доп. премиум-трафик, GB",
+ "user_premium_override_save": "Сохранить оверрайд",
+ "user_premium_override_status_unlimited": "Сейчас: безлимит",
+ "user_premium_override_status_bonus": "Сейчас: +{gb} GB",
+ "user_premium_override_status_none": "Премиум-оверрайд не задан",
+ "user_premium_unlimited_value": "∞ (использовано {used})",
+ "user_premium_unlimited_hint": "Безлимит (админ-оверрайд)",
+ "premium_override_saved": "Премиум-оверрайд сохранён",
+ "premium_override_invalid_bonus": "Некорректное значение GB",
+ "user_traffic_grant_title": "Выдать трафик",
+ "user_traffic_grant_hint": "Зачисление ГБ на баланс пользователя — как при докупке, но без оплаты. Лимит и сквады в панели обновятся сразу.",
+ "user_traffic_grant_kind": "Тип трафика",
+ "user_traffic_grant_kind_regular": "Обычный",
+ "user_traffic_grant_kind_premium": "Премиум",
+ "user_traffic_grant_gb": "ГБ к выдаче",
+ "user_traffic_grant_submit": "Выдать",
+ "traffic_grant_invalid_gb": "Введите положительное число ГБ",
+ "traffic_grant_regular_done": "+{gb} ГБ обычного трафика выдано",
+ "traffic_grant_premium_done": "+{gb} ГБ премиум-трафика выдано",
"admin_user_no_active_subscription": "Активной подписки нет",
"admin_user_history_title": "История подписок · {count}",
"admin_user_history_no_tariff": "Без тарифа",
diff --git a/scripts/send_premium_traffic_warning_test.py b/scripts/send_premium_traffic_warning_test.py
new file mode 100644
index 0000000..a57b63a
--- /dev/null
+++ b/scripts/send_premium_traffic_warning_test.py
@@ -0,0 +1,116 @@
+"""One-off: send a synthetic premium traffic warning like TariffTrafficWorker (text + top-up button).
+
+Target chat (first match):
+ 1) Positional integer: ``python ... [almost|depleted]``
+ 2) Environment variable ``TELEGRAM_TEST_CHAT_ID``
+ 3) First entry in ``ADMIN_IDS`` from app settings
+
+No default Telegram ID is embedded in this script.
+"""
+import asyncio
+import os
+import sys
+
+from aiogram import Bot
+from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup, WebAppInfo
+
+from bot.middlewares.i18n import JsonI18n
+from bot.utils.mini_app_url import subscription_mini_app_topup_url
+from config.settings import Settings
+
+
+def _premium_topup_markup(settings: Settings, i18n: JsonI18n, user_lang: str) -> InlineKeyboardMarkup:
+ _ = lambda k, **kw: i18n.gettext(user_lang, k, **kw)
+ url = subscription_mini_app_topup_url(settings, "premium")
+ if url:
+ btn = InlineKeyboardButton(
+ text=_("traffic_warn_btn_topup_webapp_premium"),
+ web_app=WebAppInfo(url=url),
+ )
+ else:
+ btn = InlineKeyboardButton(
+ text=_("traffic_warn_btn_topup_premium"),
+ callback_data="tariff_topup:list",
+ )
+ return InlineKeyboardMarkup(inline_keyboard=[[btn]])
+
+
+def _resolve_target_chat_id(settings: Settings) -> int:
+ raw = (os.environ.get("TELEGRAM_TEST_CHAT_ID") or "").strip()
+ if raw:
+ try:
+ return int(raw)
+ except ValueError as exc:
+ raise SystemExit(f"TELEGRAM_TEST_CHAT_ID must be an integer, got {raw!r}") from exc
+ admins = getattr(settings, "ADMIN_IDS", None) or []
+ if not admins:
+ raise SystemExit(
+ "Set TELEGRAM_TEST_CHAT_ID or configure ADMIN_IDS in the environment, "
+ "or pass a numeric Telegram user id as the first argument."
+ )
+ return int(admins[0])
+
+
+def _resolve_chat_id_and_mode(settings: Settings) -> tuple[int, str]:
+ args = [a.strip() for a in sys.argv[1:] if a.strip()]
+ if not args:
+ return _resolve_target_chat_id(settings), "almost"
+ if args[0] in ("almost", "depleted"):
+ return _resolve_target_chat_id(settings), args[0]
+ try:
+ chat_id = int(args[0])
+ except ValueError as exc:
+ raise SystemExit(
+ "First argument must be a Telegram user id (integer) or a mode: almost | depleted.\n"
+ "Examples:\n"
+ " python ... 123456789\n"
+ " python ... 123456789 depleted\n"
+ " python ... depleted\n"
+ " TELEGRAM_TEST_CHAT_ID=123456789 python ..."
+ ) from exc
+ mode = args[1] if len(args) > 1 else "almost"
+ if mode not in ("almost", "depleted"):
+ mode = "almost"
+ return chat_id, mode
+
+
+async def main() -> None:
+ settings = Settings()
+ chat_id, mode = _resolve_chat_id_and_mode(settings)
+ default = (settings.DEFAULT_LANGUAGE or "ru").split("-", 1)[0]
+ i18n = JsonI18n(path="locales", default=default)
+ lang = default if default in i18n.locales_data else "ru"
+ servers = "• EU-FR-01\n• EU-DE-02"
+ usage = {"used": "4.1 GB", "remaining": "0.9 GB", "limit_total": "5.0 GB"}
+ if mode == "depleted":
+ body = i18n.gettext(
+ lang,
+ "traffic_warning_premium_depleted",
+ tariff_name="Стандарт",
+ servers=servers,
+ **usage,
+ )
+ else:
+ body = i18n.gettext(
+ lang,
+ "traffic_warning_premium_almost",
+ tariff_name="Стандарт",
+ left_pct=10,
+ servers=servers,
+ **usage,
+ )
+ markup = _premium_topup_markup(settings, i18n, lang)
+ bot = Bot(token=settings.BOT_TOKEN)
+ try:
+ await bot.send_message(
+ chat_id,
+ body,
+ parse_mode="HTML",
+ reply_markup=markup,
+ )
+ finally:
+ await bot.session.close()
+
+
+if __name__ == "__main__":
+ asyncio.run(main())
diff --git a/tests/test_admin_traffic_grants.py b/tests/test_admin_traffic_grants.py
new file mode 100644
index 0000000..eeee356
--- /dev/null
+++ b/tests/test_admin_traffic_grants.py
@@ -0,0 +1,200 @@
+"""Unit tests for admin-driven traffic grants on SubscriptionService.
+
+These tests cover the new ``admin_grant_topup`` and ``admin_grant_premium_topup``
+methods that let admins credit GB to a user as if they had purchased a top-up.
+The DAL and panel-service interactions are mocked so the tests stay hermetic.
+"""
+
+import json
+import tempfile
+import unittest
+from datetime import datetime, timedelta, timezone
+from pathlib import Path
+from types import SimpleNamespace
+from unittest.mock import AsyncMock, patch
+
+from bot.services.panel_api_service import PanelApiService
+from bot.services.subscription_service import SubscriptionService
+from config.settings import Settings
+
+
+def _tariffs_config_payload(premium: bool = False) -> dict:
+ tariff = {
+ "key": "standard",
+ "names": {"ru": "Стандарт"},
+ "descriptions": {"ru": "Base"},
+ "squad_uuids": ["squad-1"],
+ "billing_model": "period",
+ "monthly_gb": 100,
+ "prices_rub": {"1": 150},
+ "prices_stars": {"1": 0},
+ "enabled_periods": [1],
+ "enabled": True,
+ }
+ if premium:
+ tariff["premium_squad_uuids"] = ["premium-squad"]
+ tariff["premium_monthly_gb"] = 25
+ return {"default_tariff": "standard", "tariffs": [tariff]}
+
+
+def _make_settings(payload: dict, tmpdir: str) -> Settings:
+ config_path = Path(tmpdir) / "tariffs.json"
+ config_path.write_text(json.dumps(payload), encoding="utf-8")
+ return Settings(
+ _env_file=None,
+ BOT_TOKEN="token",
+ POSTGRES_USER="app_user",
+ POSTGRES_PASSWORD="app_password",
+ TARIFFS_CONFIG_PATH=str(config_path),
+ )
+
+
+class AdminGrantTopupTests(unittest.IsolatedAsyncioTestCase):
+ async def test_regular_grant_increases_balance_and_panel_limit(self):
+ with tempfile.TemporaryDirectory() as tmpdir:
+ settings = _make_settings(_tariffs_config_payload(), tmpdir)
+ panel_service = AsyncMock(spec=PanelApiService)
+ panel_service.update_user_details_on_panel = AsyncMock(return_value={"response": {}})
+ service = SubscriptionService(settings, panel_service)
+
+ db_user = SimpleNamespace(
+ user_id=42,
+ first_name="Tester",
+ last_name=None,
+ username="tester",
+ language_code="ru",
+ panel_user_uuid="panel-uuid",
+ email=None,
+ telegram_id=42,
+ )
+ sub = SimpleNamespace(
+ subscription_id=7,
+ user_id=42,
+ panel_user_uuid="panel-uuid",
+ end_date=datetime.now(timezone.utc) + timedelta(days=10),
+ tariff_key="standard",
+ tier_baseline_bytes=100 * (1024**3),
+ topup_balance_bytes=5 * (1024**3),
+ traffic_limit_bytes=105 * (1024**3),
+ is_throttled=True,
+ hwid_device_limit=3,
+ extra_hwid_devices=0,
+ premium_is_limited=False,
+ )
+ updated_sub = SimpleNamespace(**vars(sub))
+ updated_sub.topup_balance_bytes = 55 * (1024**3)
+ updated_sub.traffic_limit_bytes = 155 * (1024**3)
+ updated_sub.is_throttled = False
+
+ with patch("bot.services.subscription_service.user_dal.get_user_by_id", new=AsyncMock(return_value=db_user)), \
+ patch("bot.services.subscription_service.subscription_dal.get_active_subscription_by_user_id", new=AsyncMock(return_value=sub)), \
+ patch("bot.services.subscription_service.subscription_dal.update_subscription", new=AsyncMock(return_value=updated_sub)) as upd, \
+ patch("bot.services.subscription_service.tariff_dal.create_traffic_topup", new=AsyncMock()) as topup_log:
+ result = await service.admin_grant_topup(AsyncMock(), 42, 50.0)
+
+ self.assertIsNotNone(result)
+ self.assertEqual(result["topup_balance_bytes"], 55 * (1024**3))
+ self.assertEqual(result["traffic_limit_bytes"], 155 * (1024**3))
+ self.assertEqual(result["granted_bytes"], 50 * (1024**3))
+
+ upd.assert_awaited_once()
+ sub_update_payload = upd.await_args.args[2]
+ self.assertEqual(sub_update_payload["topup_balance_bytes"], 55 * (1024**3))
+ self.assertEqual(sub_update_payload["traffic_limit_bytes"], 155 * (1024**3))
+ self.assertFalse(sub_update_payload["is_throttled"])
+
+ topup_log.assert_awaited_once()
+ self.assertEqual(topup_log.await_args.kwargs["kind"], "admin_topup")
+ self.assertIsNone(topup_log.await_args.kwargs["payment_id"])
+ self.assertEqual(topup_log.await_args.kwargs["purchased_bytes"], 50 * (1024**3))
+
+ panel_service.update_user_details_on_panel.assert_awaited_once()
+ panel_payload = panel_service.update_user_details_on_panel.await_args.args[1]
+ self.assertEqual(panel_payload["trafficLimitBytes"], 155 * (1024**3))
+
+ async def test_regular_grant_rejects_zero(self):
+ with tempfile.TemporaryDirectory() as tmpdir:
+ settings = _make_settings(_tariffs_config_payload(), tmpdir)
+ service = SubscriptionService(settings, AsyncMock(spec=PanelApiService))
+ self.assertIsNone(await service.admin_grant_topup(AsyncMock(), 1, 0))
+ self.assertIsNone(await service.admin_grant_topup(AsyncMock(), 1, -10))
+
+ async def test_premium_grant_clears_limited_state_when_balance_covers_overuse(self):
+ with tempfile.TemporaryDirectory() as tmpdir:
+ settings = _make_settings(_tariffs_config_payload(premium=True), tmpdir)
+ panel_service = AsyncMock(spec=PanelApiService)
+ panel_service.update_user_details_on_panel = AsyncMock(return_value={"response": {}})
+ service = SubscriptionService(settings, panel_service)
+
+ db_user = SimpleNamespace(
+ user_id=77,
+ first_name="Premium",
+ last_name=None,
+ username="premium",
+ language_code="ru",
+ panel_user_uuid="panel-uuid",
+ email=None,
+ telegram_id=77,
+ )
+ # User has used 30 GB on a 25 GB premium baseline → currently
+ # premium_is_limited=True. Admin grants 20 GB of premium top-up.
+ sub = SimpleNamespace(
+ subscription_id=9,
+ user_id=77,
+ panel_user_uuid="panel-uuid",
+ tariff_key="standard",
+ premium_baseline_bytes=25 * (1024**3),
+ premium_topup_balance_bytes=0,
+ premium_topup_used_bytes=0,
+ premium_used_bytes=30 * (1024**3),
+ premium_is_limited=True,
+ premium_period_start_at=datetime.now(timezone.utc).replace(
+ day=1, hour=0, minute=0, second=0, microsecond=0
+ ),
+ premium_unlimited_override=False,
+ premium_bonus_bytes=0,
+ )
+ updated_sub = SimpleNamespace(**vars(sub))
+
+ with patch("bot.services.subscription_service.user_dal.get_user_by_id", new=AsyncMock(return_value=db_user)), \
+ patch("bot.services.subscription_service.subscription_dal.get_active_subscription_by_user_id", new=AsyncMock(return_value=sub)), \
+ patch("bot.services.subscription_service.subscription_dal.update_subscription", new=AsyncMock(return_value=updated_sub)) as upd, \
+ patch("bot.services.subscription_service.tariff_dal.create_traffic_topup", new=AsyncMock()) as topup_log:
+ result = await service.admin_grant_premium_topup(AsyncMock(), 77, 20.0)
+
+ self.assertIsNotNone(result)
+ self.assertFalse(result["premium_is_limited"])
+ # 5 GB of overuse should be backfilled into premium_topup_used.
+ self.assertEqual(result["premium_topup_used_bytes"], 5 * (1024**3))
+ # Remaining balance after backfill: 15 GB.
+ self.assertEqual(result["premium_topup_balance_bytes"], 15 * (1024**3))
+ self.assertEqual(result["granted_bytes"], 20 * (1024**3))
+
+ topup_log.assert_awaited_once()
+ self.assertEqual(topup_log.await_args.kwargs["kind"], "admin_premium_topup")
+ self.assertIsNone(topup_log.await_args.kwargs["payment_id"])
+
+ panel_service.update_user_details_on_panel.assert_awaited_once()
+ panel_payload = panel_service.update_user_details_on_panel.await_args.args[1]
+ self.assertIn("premium-squad", panel_payload["activeInternalSquads"])
+
+ sub_update_payload = upd.await_args.args[2]
+ self.assertFalse(sub_update_payload["premium_is_limited"])
+
+ async def test_premium_grant_fails_when_tariff_has_no_premium_squads(self):
+ with tempfile.TemporaryDirectory() as tmpdir:
+ settings = _make_settings(_tariffs_config_payload(premium=False), tmpdir)
+ service = SubscriptionService(settings, AsyncMock(spec=PanelApiService))
+
+ db_user = SimpleNamespace(
+ user_id=11, panel_user_uuid="panel-uuid",
+ )
+ sub = SimpleNamespace(subscription_id=1, user_id=11, panel_user_uuid="panel-uuid", tariff_key="standard")
+
+ with patch("bot.services.subscription_service.user_dal.get_user_by_id", new=AsyncMock(return_value=db_user)), \
+ patch("bot.services.subscription_service.subscription_dal.get_active_subscription_by_user_id", new=AsyncMock(return_value=sub)):
+ self.assertIsNone(await service.admin_grant_premium_topup(AsyncMock(), 11, 10.0))
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/test_mini_app_url.py b/tests/test_mini_app_url.py
new file mode 100644
index 0000000..a6b4265
--- /dev/null
+++ b/tests/test_mini_app_url.py
@@ -0,0 +1,35 @@
+import unittest
+
+from bot.utils.mini_app_url import append_query_params, subscription_mini_app_topup_url
+from config.settings import Settings
+
+
+class MiniAppUrlTests(unittest.TestCase):
+ def test_append_query_params_adds_topup(self):
+ self.assertEqual(
+ append_query_params("https://app.example.com/home", {"topup": "regular"}),
+ "https://app.example.com/home?topup=regular",
+ )
+
+ def test_append_query_params_merges_existing(self):
+ self.assertEqual(
+ append_query_params("https://app.example.com/?lang=ru", {"topup": "premium"}),
+ "https://app.example.com/?lang=ru&topup=premium",
+ )
+
+ def test_subscription_mini_app_topup_url_none_when_unset(self):
+ s = Settings(_env_file=None, BOT_TOKEN="x", POSTGRES_USER="u", POSTGRES_PASSWORD="p", SUBSCRIPTION_MINI_APP_URL=None)
+ self.assertIsNone(subscription_mini_app_topup_url(s, "regular"))
+
+ def test_subscription_mini_app_topup_url(self):
+ s = Settings(
+ _env_file=None,
+ BOT_TOKEN="x",
+ POSTGRES_USER="u",
+ POSTGRES_PASSWORD="p",
+ SUBSCRIPTION_MINI_APP_URL="https://app.example.com/webapp",
+ )
+ self.assertEqual(
+ subscription_mini_app_topup_url(s, "regular"),
+ "https://app.example.com/webapp?topup=regular",
+ )
diff --git a/tests/test_tariff_worker.py b/tests/test_tariff_worker.py
index ba08b66..d77288c 100644
--- a/tests/test_tariff_worker.py
+++ b/tests/test_tariff_worker.py
@@ -265,3 +265,121 @@ class TariffWorkerTests(unittest.IsolatedAsyncioTestCase):
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))
+
+ async def test_premium_unlimited_override_never_throttles(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": [
+ {"username": "tg_42", "total": 50 * (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=42,
+ 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,
+ premium_unlimited_override=True,
+ premium_bonus_bytes=0,
+ )
+ tariff = settings.tariffs_config.require("standard")
+
+ await worker._sync_premium_squad_limit(
+ AsyncMock(), sub, tariff, datetime.now(timezone.utc), panel_username="tg_42"
+ )
+
+ self.assertFalse(sub.premium_is_limited)
+ self.assertEqual(int(sub.premium_used_bytes), 50 * (1024**3))
+ payload_sent = panel_service.update_user_details_on_panel.await_args.args[1]
+ self.assertIn("premium-squad", payload_sent["activeInternalSquads"])
+
+ async def test_premium_bonus_extends_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"}]
+ )
+ # Used 4 GB > tariff baseline 1 GB, but admin granted +10 GB bonus.
+ panel_service.get_node_users_bandwidth_stats = AsyncMock(
+ return_value={
+ "topUsers": [
+ {"username": "tg_77", "total": 4 * (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=77,
+ 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,
+ premium_unlimited_override=False,
+ premium_bonus_bytes=10 * (1024**3),
+ )
+ tariff = settings.tariffs_config.require("standard")
+
+ await worker._sync_premium_squad_limit(
+ AsyncMock(), sub, tariff, datetime.now(timezone.utc), panel_username="tg_77"
+ )
+
+ # 4 GB used vs 1 GB baseline + 10 GB bonus = 11 GB limit → not limited.
+ self.assertFalse(sub.premium_is_limited)
+ self.assertEqual(int(sub.premium_used_bytes), 4 * (1024**3))