Enhance user plan management and update related endpoints
CI / Backend (build + test) (push) Failing after 1m23s
CI / Frontend (lint + typecheck + build) (push) Successful in 34s

- Added new configuration options for user plans in `.env.example`, including `Plans__MaxCustomConfigCount` and `Plans__MinCustomConfigCount`.
- Introduced `MapPlanEndpoints` in `Program.cs` to handle plan-related API routes.
- Implemented `SetUserPlan` endpoint in `RoleEndpoints` to allow admins to assign plans to users.
- Removed deprecated role request approval endpoints from `AdminSupportEndpoints`.
- Updated `ITelegramNotifier` and related classes to reflect changes in role request handling and payment notifications.
- Refactored role management commands to remove `MaxConfigs` and focus on `MaxIpLimit` and billing settings.
- Enhanced billing request handling to accommodate plan changes instead of role changes.
- Updated various interfaces and command handlers to support new plan management features.
This commit is contained in:
Leonid Pershin
2026-07-23 22:52:20 +03:00
parent 2c5b730500
commit fad03c2834
152 changed files with 4060 additions and 2240 deletions
+205 -142
View File
@@ -4,10 +4,11 @@
`AppUser`/`AppRole` — часть Identity (живут в `Infrastructure`, т.к. расширяют `IdentityUser<Guid>`/
`IdentityRole<Guid>`); чистый `PnvPanel.Domain` ссылается на пользователя/роль только по `Guid`.
Лимиты трафика на конфиг (`TrafficLimit`) не реализованы — квота на число активных конфигов —
только через `AppRole.MaxConfigs`. Есть глобальная справочная цена за один конфиг (`PricingSettings`;
редактирует только `admin`, но справочно видна и активированным пользователям в заявке на роль) —
используется и биллингом (см. ниже) для расчёта суммы заявки на оплату.
Лимиты трафика на конфиг (`TrafficLimit`) не реализованы — квота на число активных конфигов — через
`AppUser.ConfigQuota` (самообслуживание, см. `Plan` ниже). Есть глобальная справочная цена за один
конфиг (`PricingSettings`; редактирует только `admin`, но справочно видна и активированным
пользователям на странице смены тарифа) — используется и биллингом (см. ниже) для расчёта суммы
заявки на оплату.
**Биллинг (подписка по сроку) реализован, но опционален и включается per-роль**
(`AppRole.BillingEnabled`, недоступен для `admin`) — см. [Billing](#billing--подписка-по-сроку).
@@ -16,8 +17,9 @@
## Диаграмма связей
```
AppUser (Identity) [+ IsActivated, IsBlocked, TelegramUserId, SubscriptionToken]
├─*───1─ AppRole (ровно одна роль; роль несёт квоту MaxConfigs)
AppUser (Identity) [+ IsActivated, IsBlocked, TelegramUserId, SubscriptionToken, ConfigQuota, PlanId]
├─*───1─ AppRole (ровно одна роль; роль несёт лимит устройств MaxIpLimit)
├─0..1─ Plan (последний выбранный тариф; квота — на AppUser, не live-linked)
├─1───*─ VpnConfig
│ └─1─ Inbound ─*─1─ Node
│ └─*───*─ AppRole (какие роли могут создавать конфиги в инбаунде)
@@ -29,7 +31,7 @@ AuditLog (append-only журнал действий; сс
ClientApp (каталог приложений-клиентов; группируется по OperatingSystem)
NewsPost (лента новостей; публикуется админом, видна всем аутентифицированным пользователям)
AppUser
└─0..*─ SupportTicket (баг-репорт/предложение либо заявка на роль)
└─0..*─ SupportTicket (баг-репорт/предложение либо заявка на продление)
└─1───*─ TicketComment (переписка; первое сообщение = описание/обоснование)
└─0..*─ TicketAttachment (изображения, диск-хранилище)
```
@@ -83,7 +85,7 @@ AppUser
Инварианты: конфиг можно создать только если `IsPublished && Node.IsEnabled`, и **роль пользователя
входит в `AllowedRoles`**. Публикация инбаунда админом включает выбор `AllowedRoles` (напр.
«Германия (Trojan)» → роли `user`, `vip`). Лимита числа клиентов на инбаунд нет — квота
ограничивается только на уровне пользователя (`AppRole.MaxConfigs`).
ограничивается только на уровне пользователя (`AppUser.ConfigQuota`).
**Синхронизация и пропажа инбаунда с панели** (`SyncNodeCommandHandler`, кнопка «Синхронизировать»):
инбаунд, не пришедший в очередном ответе 3x-ui, считается пропавшим. Если по нему нет ни одного
@@ -141,13 +143,15 @@ AppUser
`IXuiPanelGateway.AddClientAsync`, затем `AssignRemoteClient(id)` и сохраняет — при сбое БД после
успешного создания в панели хендлер удаляет клиента в 3x-ui (компенсация).
- **Проверка квоты выполняется под `pg_advisory_xact_lock(hashtext(userId))`** в транзакции создания
(`CreateVpnConfigCommandHandler`) — иначе два параллельных запроса могли бы пробить лимит роли. Тот
же паттерн обобщён в `Application/Common/Concurrency/AdvisoryLock.cs` (`AdvisoryLock.RunAsync`) и
используется во всех "проверил статус — потом изменил" хендлерах одобрения/отклонения заявок и
оплат (`Confirm`/`RejectPaymentRequestCommandHandler`, `Approve`/`RejectRoleRequestCommandHandler`,
`Approve`/`RejectExtensionRequestCommandHandler`, `Create`/`GetLoginRequestStatusQueryHandler`,
`Create...RequestTicket`/`CreatePaymentRequestCommandHandler`) — без него параллельное одобрение той
же заявки с сайта и из Telegram могло бы оба пройти проверку статуса и оба начислить дни/роль/оплату.
(`CreateVpnConfigCommandHandler`) — иначе два параллельных запроса могли бы пробить квоту. Тот же
ключ (`userId`) использует и `ChangePlanCommandHandler` — самостоятельная смена тарифа не может
гоняться с параллельным созданием конфига. Тот же паттерн обобщён в
`Application/Common/Concurrency/AdvisoryLock.cs` (`AdvisoryLock.RunAsync`) и используется во всех
"проверил статус — потом изменил" хендлерах одобрения/отклонения заявок и оплат
(`Confirm`/`RejectPaymentRequestCommandHandler`, `Approve`/`RejectExtensionRequestCommandHandler`,
`Create`/`GetLoginRequestStatusQueryHandler`, `Create...RequestTicket`/
`CreatePaymentRequestCommandHandler`) — без него параллельное одобрение той же заявки с сайта и из
Telegram могло бы оба пройти проверку статуса и оба начислить дни/оплату.
На нерелационном EF-провайдере (InMemory в `PnvPanel.Application.Tests`) лок автоматически
пропускается — сериализующее поведение проверяется только в `PnvPanel.IntegrationTests` (реальный
Postgres).
@@ -165,7 +169,7 @@ AppUser
- `Rename(label)` → юзер меняет метку (синкается в 3x-ui как имя клиента).
- Лимит одновременных IP (`limitIp` в 3x-ui) выставляется при создании клиента (`Create`/`Rotate`) по
квоте роли пользователя (`AppRole.MaxIpLimit`; -1 = без лимита) — панель не даёт настраивать его
per-конфиг. Как и `MaxConfigs`, лимит применяется только к **новым** клиентам: смена роли/квоты не
per-конфиг. Как и `ConfigQuota`, лимит применяется только к **новым** клиентам: смена роли/тарифа не
трогает уже созданных клиентов в 3x-ui (см. `IXuiPanelGateway.UpdateClientAsync`, где `LimitIp`
всегда `null` — «не менять»).
- `UpdateTraffic(up, down)` → пишет `TrafficSyncService` при периодической синхронизации, только для отображения.
@@ -181,13 +185,14 @@ AppUser
просмотр списка, редактирование, ротация, отзыв, получение ссылки/подписки на свои конфиги, а также
чтение новостей и каталога приложений — единая проверка в `RequireActivationBehavior` (pipeline
behavior, маркер `IRequiresActivation` на команде/запросе), а не разбросанные проверки в хендлерах.
- Число активных конфигов пользователя не может превышать **квоту его роли** (`AppRole.MaxConfigs`;
роль `admin` — без лимита). У пользователя ровно одна роль. См. `AppRole` ниже.
- Число активных конфигов пользователя не может превышать **его квоту** (`AppUser.ConfigQuota`;
`-1` — без лимита, только для `admin`). Квота задаётся тарифом (`Plan`, самообслуживание, см.
ниже), не ролью. См. `Plan` ниже.
- Инбаунд должен быть доступен роли пользователя (`Inbound.AllowedRoles`).
- Разрешено несколько конфигов в одном инбаунде (ограничение — только общая квота роли).
- Разрешено несколько конфигов в одном инбаунде (ограничение — только общая квота пользователя).
> Лимиты трафика не реализованы. `ConfigStatus.LimitReached` в значении enum есть, но код в него
> никогда не переводит конфиг. Квота на число конфигов реализована через `AppRole.MaxConfigs` (см.
> никогда не переводит конфиг. Квота на число конфигов реализована через `AppUser.ConfigQuota` (см.
> [tech-stack.md](tech-stack.md)). Истечение срока — только для billing-ролей, см. Billing ниже.
### TrafficSample — история трафика (для графиков)
@@ -315,6 +320,8 @@ AppUser
| `TelegramUserId` | `long?` | Id пользователя Telegram; **уникальный**; null до привязки |
| `TelegramUsername` | `string?` | @username на момент привязки (для отображения) |
| `TelegramLinkedAt` | `DateTimeOffset?` | Когда привязан |
| `ConfigQuota` | `int` | Фактическая квота активных конфигов (замена бывшего `AppRole.MaxConfigs`); `-1` = без лимита. Меняется через `ChangePlanCommand` (самообслуживание) или `AdminSetUserPlanCommand` |
| `PlanId` | `Guid?` | Какой каталожный `Plan` выбран последним; обычная колонка без FK (см. `Inbound.AllowedRoleIds`); `null`, если квота задана вручную (кастомное число) или прямым оверрайдом админа |
Инварианты: один `TelegramUserId` ↔ один аккаунт (повторная привязка требует `/unlink`);
неактивированный пользователь не имеет доступа к конфигам, новостям и каталогу приложений (см. выше);
@@ -322,39 +329,41 @@ AppUser
**Блокировка** (`IsBlocked = true`) переводит все конфиги в `Disabled` (отключение клиентов в 3x-ui);
разблокировка включает их обратно. У пользователя ровно одна роль.
**`ConfigQuota = -1` (безлимит) зарезервирован за ролью `admin`** — самостоятельная смена тарифа
(`ChangePlanCommand`) не может выставить безлимит (валидатор требует конечное число в диапазоне
`Plans__MinCustomConfigCount`..`Plans__MaxCustomConfigCount`), и админский оверрайд
(`AdminSetUserPlanCommand`) тоже отказывает не-admin'у (`PlanErrors.UnlimitedOnlyForAdmin`).
`RoleService.ChangeUserRoleAsync` выставляет `ConfigQuota = -1` автоматически при назначении роли
`admin` и сбрасывает её на `Roles__DefaultUserMaxConfigs` при уходе с `admin` (если она была
безлимитной) — иначе бывший админ остался бы с безлимитом навсегда.
**Восстановление пароля**: только через привязанный Telegram (passwordless-вход → смена пароля в
настройках, либо reset-флоу в боте). Если Telegram не привязан — пароль сбрасывает **админ**
(`ResetUserPasswordCommand`). Пока Telegram не привязан,
UI **настойчиво напоминает** привязать его (единственный self-service способ восстановления).
### AppRole — роль с квотой (Identity, динамическая)
Расширяет `IdentityRole<Guid>`. Роли **создаёт админ** и назначает пользователям; роль несёт квоту
на число конфигов и лимит одновременных IP на клиента в 3x-ui.
### AppRole — роль (Identity, динамическая)
Расширяет `IdentityRole<Guid>`. Роли **создаёт админ** и назначает пользователям; роль несёт лимит
одновременных IP на клиента в 3x-ui, доступ к инбаундам (`Inbound.AllowedRoles`) и флаг биллинга.
**Квота конфигов на роли больше не хранится** — она у пользователя (`AppUser.ConfigQuota`, см. выше),
управляется самостоятельной сменой тарифа (`Plan`, см. ниже), не ролью.
| Поле | Тип | Заметки |
| ------------ | -------- | --------------------------------------------------------------- |
| `Id` | `Guid` | PK |
| `Name` | `string` | Напр. `admin`, `user`, `vip` |
| `MaxConfigs` | `int` | Квота активных конфигов (-1 = без лимита; для `admin` — без лимита) |
| `MaxIpLimit` | `int` | Лимит одновременных IP на клиента (`limitIp` в 3x-ui; -1 = без лимита; для `admin` — без лимита) |
| `IsSystem` | `bool` | Системная (`admin`, `user`) — нельзя удалить/переименовать |
| `BillingEnabled` | `bool` | Включает биллинг для пользователей с этой ролью; нельзя включить для `admin` (см. Billing) |
Сидируются: `admin` (оба лимита без ограничения) и `user` (`MaxConfigs` = `Roles__DefaultUserMaxConfigs`,
по умолчанию 3; `MaxIpLimit` = `Roles__DefaultUserMaxIpLimit`, по умолчанию 2).
**У пользователя ровно одна роль**; его квоты = `MaxConfigs`/`MaxIpLimit` этой роли (`admin` → без лимита).
**Понижение роли (грандфазеринг)**: смену роли на роль с меньшей квотой разрешаем даже если текущих
конфигов больше новой квоты — существующие конфиги сохраняются, но **создание новых блокируется**,
пока число активных не станет меньше квоты. Форс-отзыв лишних не делаем.
Сидируются: `admin` (без лимита IP) и `user` (`MaxIpLimit` = `Roles__DefaultUserMaxIpLimit`,
по умолчанию 2). **У пользователя ровно одна роль.**
**Нельзя снять `admin` с последнего администратора**: `IRoleService.ChangeUserRoleAsync` перед сменой
роли проверяет — если у пользователя сейчас `admin`, а новая роль другая, и админов в системе ровно
один — `RoleErrors.CannotRemoveLastAdmin` (409), смены не происходит. Единая точка защиты — работает
и при прямой смене роли из `/admin/users`, и при одобрении заявки на роль через `SupportTicket`
(`ApproveRoleRequestCommandHandler` вызывает тот же `ChangeUserRoleAsync`), в том числе когда админ
одобряет заявку на понижение самому себе — этот путь специально не блокируется отдельно, чтобы не
плодить тикеты, которые некому обработать, если админ единственный.
один — `RoleErrors.CannotRemoveLastAdmin` (409), смены не происходит. Тот же метод — единственная
точка смены роли (прямая смена из `/admin/users`, самостоятельной заявки на роль больше нет, см.
`SupportTicket` ниже).
**Удаление роли** (`IRoleService.DeleteRoleAsync`): запрещено для системных ролей и пока есть живые
пользователи с этой ролью (`RoleErrors.RoleInUse`). Живых пользователей нет — но `Inbound.AllowedRoleIds`
@@ -362,6 +371,63 @@ UI **настойчиво напоминает** привязать его (ед
хендлер подчищает такие ссылки (`Inbound.RemoveAllowedRole`), иначе "мёртвый" Id молча оставался бы
висеть в массиве — не пуская никого нового, но и не давая понять почему.
### Plan — тариф (самообслуживание, квота конфигов)
Каталог квот конфигов, из которого пользователь выбирает себе тариф **сам, без подтверждения
админа** — заменяет прежнюю схему «квота = квота роли». Роль (выше) продолжает управлять только
лимитом устройств, доступом к инбаундам и флагом биллинга.
| Поле | Тип | Заметки |
| ------------ | -------- | ----------------------------------------------------------------- |
| `Id` | `Guid` | PK |
| `Name` | `string` | Напр. «Стандарт», «Плюс», «Про» |
| `ConfigCount`| `int` | Количество конфигов; **≥ 1** — каталожные тарифы не поддерживают безлимит (тот доступен только `admin`, см. `AppUser.ConfigQuota`) |
| `SortOrder` | `int` | Порядок в списке (админка и страница `/plan`) |
| `IsEnabled` | `bool` | Показывать ли тариф пользователям для выбора |
Сидируются 3 тарифа при первом старте (`IPlanSeeder`, если таблица пуста): «Стандарт» (3),
«Плюс» (6), «Про» (9) — то же соглашение, что у `PricingSettingsSeeder`. Полный CRUD только у
админа (`/api/admin/plans`); `GET /api/plans` (только `IsEnabled`, активированным) — для страницы
`/plan`.
**Выбор тарифа не привязан жёстко к каталогу** — пользователь может вместо этого ввести
произвольное количество конфигов (`CustomConfigCount`), ограниченное настройками
`Plans__MinCustomConfigCount` (по умолчанию 3) и `Plans__MaxCustomConfigCount` (по умолчанию 50);
в этом случае `AppUser.PlanId` остаётся `null` — это не "тариф", просто число. Квота
(`AppUser.ConfigQuota`) — снапшот на момент выбора, не live-ссылка на `Plan`: последующее изменение
`Plan.ConfigCount` админом не трогает уже выбравших его пользователей (симметрично тому, как
`PaymentRequest.AmountSnapshot` не меняется при правке `PricingSettings` задним числом). Поэтому
удаление тарифа (`DeletePlanCommandHandler`) не проверяет "используется ли он ещё" — `PlanId`
пользователя может молча указывать на удалённую запись, как `Inbound.AllowedRoleIds`.
**`ChangePlanCommand`** (`POST /api/plans/change`, `IRequiresActivation`) — самостоятельная смена,
без подтверждения админа:
- Ровно одно из `PlanId`/`CustomConfigCount` в запросе.
- Квота меняется **сразу**. Если новая квота **больше** текущей и у роли пользователя включён
биллинг с активным `BillingPaidUntil` — создаётся доплата (`PlanChangeTopUp`, см. Billing ниже) за
разницу в цене на оставшийся оплаченный срок — тот же принцип, что раньше был у смены роли.
- Если новая квота **меньше** текущего числа конфигов, всё ещё занимающих квоту (`Active` **и**
`Expired`), — самостоятельная смена требует явно указать, какие именно конфиги отозвать
(`ConfigIdsToRevoke`, ровно `(активные+приостановленные) − новая_квота` штук; иначе
`Plans.MustSelectConfigsToRevoke`). Это осознанное отличие от грандфазеринга при понижении:
пользователь меняет тариф сам, в реальном времени, поэтому можно и нужно спросить его сразу, а не
оставлять лишние конфиги висеть молча. `Expired` (приостановленные за неуплату) считаются наравне с
`Active` — иначе пользователь мог бы обойти пикер, понизив тариф именно во время приостановки (все
конфиги временно не `Active`), а затем оплатить: `BillingConfigResumer.ResumeConfigsAsync`
возвращает в `Active` **все** приостановленные конфиги разом и квоту не проверяет — без этого
правила старое (большее) количество тихо вернулось бы в обход новой квоты.
- Проверка/резервирование — под `AdvisoryLock` (по `UserId`, тот же ключ, что и у
`CreateVpnConfigCommandHandler`) — не даёт гонки с параллельным созданием конфига. Сам отзыв
конфигов (вызов гейтвея `RemoveClientAsync`) — вне лока, после коммита квоты, тем же общим шагом,
что и у `RevokeVpnConfigCommandHandler` (`VpnConfigRevocation.RevokeAsync` — не звонит в гейтвей,
если инбаунд уже `!IsAvailable`, не помечает `Revoked`, если гейтвей упал).
**`AdminSetUserPlanCommand`** (`PATCH /api/admin/users/{id}/plan`) — прямой оверрайд админом,
рядом с прямой сменой роли: **без** пикера конфигов при понижении (грандфазеринг — как раньше при
понижении роли: лишние конфиги не трогаются, новые блокируются, пока не войдёт в квоту) и **без**
доплаты (тот же принцип, что и у прямой смены роли — осознанный инструмент админа, может быть
использован как поощрение). `CustomConfigCount = -1` (безлимит) допустим только если целевой
пользователь уже в роли `admin` (`Plans.UnlimitedOnlyForAdmin` иначе).
### PricingSettings — глобальная справочная цена конфига
Единственная строка в таблице (singleton) — цена за один конфиг, редактируется админом. Не привязана
к роли: одна цена на весь сервис. Не биллинг — без статусов оплаты, дат окончания, интеграций с
@@ -376,15 +442,17 @@ UI **настойчиво напоминает** привязать его (ед
| `UpdatedAt` | `DateTimeOffset` | |
Все три поля — ставка **за месяц**, не за весь период целиком. Итог за период = `ставка ×
число_месяцев × AppRole.MaxConfigs`, считается на фронте (таблица ролей в админке), нигде не
число_месяцев × количество_конфигов` (тарифа `Plan` или ручного ввода — см. `Plan` выше), считается
на фронте (страница тарифов в админке `/admin/plans` и страница смены тарифа `/plan`), нигде не
хранится:
- 3 месяца = `PricePerConfigPerQuarter × 3 × MaxConfigs`
- полгода = `PricePerConfigPerHalfYear × 6 × MaxConfigs`
- год = `PricePerConfigPerYear × 12 × MaxConfigs`
- 3 месяца = `PricePerConfigPerQuarter × 3 × ConfigCount`
- полгода = `PricePerConfigPerHalfYear × 6 × ConfigCount`
- год = `PricePerConfigPerYear × 12 × ConfigCount`
Например, `user` с `MaxConfigs=3` и одинаковой ставкой 200₽/мес на всех трёх тарифах → 600₽/3мес,
1200₽/полгода, 2400₽/год (линейный рост, скидки за тариф нет). Для ролей с `MaxConfigs = -1`
(unlimited, в т.ч. `admin`) итог не считается — отображается как «не задано».
Например, тариф с `ConfigCount=3` и одинаковой ставкой 200₽/мес на всех трёх периодах → 600₽/3мес,
1200₽/полгода, 2400₽/год (линейный рост, скидки за период нет — скидка за объём отдельная, см.
`PricingDiscountTier` ниже). Для `ConfigQuota = -1` (unlimited, только `admin`) итог не считается —
отображается как «не задано».
**Инвариант**: `UpdatePricingSettingsCommandValidator` не даёт сохранить более длинный тариф настолько
дешёвым, что его итог окажется дешевле итога более короткого — иначе выгоднее купить длинный тариф и
@@ -394,46 +462,47 @@ PricePerConfigPerQuarter × 3` и `PricePerConfigPerYear × 12 ≥ PricePerConfi
PricePerConfigPerQuarter × 3`).
`GET/PUT /api/admin/pricing` — только `admin` (редактирование). `GET /api/support/pricing` — то же
чтение, но доступно любому активированному пользователю (не `admin`-эндпоинт) — используется в
диалоге заявки на роль, чтобы показать ориентировочную стоимость выбранной/предлагаемой роли, с
пометкой, что цены пока ознакомительные. Это два разных Query (`GetPricingSettingsQuery` в
`Admin/Pricing`, `GetSupportPricingQuery` в `Support`) над одним и тем же общим `PricingSettingsDto`
(`Common/Interfaces`) — по аналогии с `ListRolesQuery`/`ListSelectableRolesQuery` для ролей. В отличие
от `RoleDto`, у `PricingSettingsDto` нет чувствительных per-роль данных, поэтому шарить DTO между
admin- и user-facing путями безопасно. Сидируется пустой строкой при старте (`IPricingSettingsSeeder`,
если таблица пуста) и заново после полного сброса панели (см. «Полный сброс панели» выше).
чтение, но доступно любому активированному пользователю (не `admin`-эндпоинт) — используется
страницей смены тарифа (`/plan`), чтобы показать ориентировочную стоимость каждого варианта, с
пометкой, что цены пока ознакомительные (эндпоинт исторически называется `support/pricing` — раньше
использовался диалогом заявки на роль, сейчас переиспользован страницей `/plan`, переименовывать не
стали). Это два разных Query (`GetPricingSettingsQuery` в `Admin/Pricing`, `GetSupportPricingQuery` в
`Support`) над одним и тем же общим `PricingSettingsDto` (`Common/Interfaces`). У `PricingSettingsDto`
нет чувствительных данных, поэтому шарить DTO между admin- и user-facing путями безопасно.
Сидируется пустой строкой при старте (`IPricingSettingsSeeder`, если таблица пуста) и заново после
полного сброса панели (см. «Полный сброс панели» выше).
#### PricingDiscountTier — скидка за объём (лесенка порогов)
Стимул брать роль с бОльшей квотой конфигов разом: плоская таблица (не навигационная коллекция —
см. конвенцию проекта на TicketComment) с FK на `PricingSettingsId`, глобальная, не привязана к
конкретной роли — как и сам `PricingSettings`.
Стимул брать тариф с бОльшим количеством конфигов разом: плоская таблица (не навигационная
коллекция — см. конвенцию проекта на TicketComment) с FK на `PricingSettingsId`, глобальная, не
привязана к конкретному тарифу — как и сам `PricingSettings`.
| Поле | Тип | Заметки |
| ------------------- | -------- | ------------------------------------------------------------------ |
| `Id` | `Guid` | PK |
| `PricingSettingsId` | `Guid` | FK → PricingSettings |
| `MinConfigs` | `int` | Порог: скидка действует при `AppRole.MaxConfigs >= MinConfigs` |
| `MinConfigs` | `int` | Порог: скидка действует при `количество_конфигов >= MinConfigs` |
| `DiscountPercent` | `int` | Скидка в процентах от итоговой цены периода, 1–99 |
Действует **наивысший подходящий порог** (не суммируется с другими) — `PricingDiscount.ResolvePercent`
(`Domain/Pricing`): из тиров с `MinConfigs <= MaxConfigs` берётся тот, у которого `MinConfigs`
максимален. Например, при порогах `3+ → 5%` и `6+ → 10%` роль с `MaxConfigs=8` получает 10%, а не 15%.
(`Domain/Pricing`): из тиров с `MinConfigs <= количество_конфигов` берётся тот, у которого `MinConfigs`
максимален. Например, при порогах `3+ → 5%` и `6+ → 10%` тариф на 8 конфигов получает 10%, а не 15%.
Скидка применяется к уже посчитанному итогу периода: `PricingDiscount.Apply(итог, процент)`, округление
до целого рубля (`MidpointRounding.AwayFromZero`). Роли с `MaxConfigs = -1` (unlimited) скидку не
получают — как и обычный расчёт цены, для них итог не считается.
до целого рубля (`MidpointRounding.AwayFromZero`). `ConfigQuota = -1` (unlimited, только `admin`) скидку
не получает — как и обычный расчёт цены, для него итог не считается.
**Инвариант**: `UpdatePricingSettingsCommandValidator` требует уникальности порогов и прогрессивности
лесенки — на более высоком пороге скидка не может быть меньше, чем на более низком (иначе взять роль с
бОльшей квотой может оказаться менее выгодно, что противоречит смыслу скидки за объём).
лесенки — на более высоком пороге скидка не может быть меньше, чем на более низком (иначе взять
бОльшее количество конфигов может оказаться менее выгодно, что противоречит смыслу скидки за объём).
Применяется в двух местах, зеркалящих друг друга: реальная оплата (`CreatePaymentRequestCommandHandler`
`AmountSnapshot` уже с учётом скидки) и ознакомительная оценка (`PricingSettingsDto.DiscountTiers` +
`resolveDiscountPercent`/`applyDiscount` на фронте, `frontend/src/shared/lib/pricing.ts`) — используется
и в списке ролей в админке (`admin/roles.tsx`), и в оценке стоимости при смене роли тикетом
(`CreateRoleRequestDialog.tsx`). `UpdatePricingSettingsCommand` при сохранении полностью заменяет набор
тиров (удаляет старые, вставляет новые) — операция редкая (правит только `admin`), сложность
инкрементального diff не оправдана.
и в списке тарифов в админке (`admin/plans.tsx`), и на странице смены тарифа (`/plan`).
`UpdatePricingSettingsCommand` при сохранении полностью заменяет набор тиров (удаляет старые,
вставляет новые) — операция редкая (правит только `admin`), сложность инкрементального diff не
оправдана.
### Billing — подписка по сроку
@@ -467,79 +536,82 @@ Singleton (как `PricingSettings`) — реквизиты для оплаты
Пользователь оформляет заявку на период (3/6/12 мес); решает админ на сайте или в Telegram. Не более
одной активной (`AwaitingPayment`/`AwaitingConfirmation`) заявки **`Kind.Subscription`** на
пользователя — инвариант проверяется в `CreatePaymentRequestCommandHandler` и не распространяется на
`Kind.RoleChangeTopUp` (см. ниже) — доплата не должна мешать оформить/продлить обычную подписку.
`Kind.PlanChangeTopUp` (см. ниже) — доплата не должна мешать оформить/продлить обычную подписку.
| Поле | Тип | Заметки |
| ------------------ | ----------------------- | ------------------------------------------------------------ |
| `Id` | `Guid` | PK |
| `UserId` | `Guid` | FK → AppUser (заявитель) |
| `Kind` | `PaymentRequestKind` | `Subscription` (оплата за период) / `RoleChangeTopUp` (доплата за апгрейд роли, см. ниже) |
| `Period` | `PaymentPeriod?` | `Quarter` (3 мес) / `HalfYear` (6 мес) / `Year` (12 мес). `null` для `Kind.RoleChangeTopUp` — доплата не привязана к тарифному периоду |
| `AmountSnapshot` | `int` | Сумма, замороженная на момент создания. Для `Subscription`: `ставка PricingSettings за период × MaxConfigs роли × число месяцев`, затем скидка по лесенке `PricingDiscountTier` (см. выше), если применима. Для `RoleChangeTopUp`: см. `RoleChangeTopUp.Compute` ниже. Последующее изменение прайса/лесенки админом не меняет уже созданные заявки |
| `Kind` | `PaymentRequestKind` | `Subscription` (оплата за период) / `PlanChangeTopUp` (доплата за увеличение тарифа, см. ниже) |
| `Period` | `PaymentPeriod?` | `Quarter` (3 мес) / `HalfYear` (6 мес) / `Year` (12 мес). `null` для `Kind.PlanChangeTopUp` — доплата не привязана к тарифному периоду |
| `AmountSnapshot` | `int` | Сумма, замороженная на момент создания. Для `Subscription`: `ставка PricingSettings за период × ConfigQuota пользователя × число месяцев`, затем скидка по лесенке `PricingDiscountTier` (см. выше), если применима. Для `PlanChangeTopUp`: см. `PlanChangeTopUp.Compute` ниже. Последующее изменение прайса/лесенки админом не меняет уже созданные заявки |
| `Status` | `PaymentRequestStatus` | `AwaitingPayment``AwaitingConfirmation``Confirmed`/`Rejected`, либо `Cancelled` из `AwaitingPayment` |
| `DecidedBy`/`DecidedAt`/`RejectionReason` | | Кто/когда решил, причина отказа (опционально) |
| `CreatedAt` | `DateTimeOffset` | |
Роль с `MaxConfigs = -1` (unlimited) не поддерживает биллинг по формуле —
`ConfigQuota = -1` (unlimited, только `admin`) не поддерживает биллинг по формуле —
`CreatePaymentRequestCommandHandler` отдаёт `BillingErrors.UnlimitedRoleNotSupported`; та же логика в
`RoleChangeTopUp.Compute` (`null`, доплата не считается).
`PlanChangeTopUp.Compute` (`null`, доплата не считается) — впрочем, для `admin` биллинг и не
применяется (`AppRole.BillingEnabled` для него запрещён в принципе).
**Переходы** (`backend/src/PnvPanel.Domain/Billing/PaymentRequest.cs`):
- `Create(userId, period, amount)` (`Kind.Subscription`) / `CreateRoleChangeTopUp(userId, amount)`
(`Kind.RoleChangeTopUp`) → `AwaitingPayment`, показываются реквизиты `BillingSettings`. Пользователь
- `Create(userId, period, amount)` (`Kind.Subscription`) / `CreatePlanChangeTopUp(userId, amount)`
(`Kind.PlanChangeTopUp`) → `AwaitingPayment`, показываются реквизиты `BillingSettings`. Пользователь
может `Cancel()` (только из `AwaitingPayment`) или дождаться проверки.
- `MarkPaymentSent()` → пользователь нажал «Я оплатил»; `AwaitingPayment → AwaitingConfirmation`,
админам уходит Telegram-уведомление с инлайн-кнопками `pay:approve:{id}`/`pay:reject:{id}` (текст
уведомления зависит от `Kind` — период или «доплата за смену роли», см. `TelegramNotifier`).
уведомления зависит от `Kind` — период или «доплата за смену тарифа», см. `TelegramNotifier`).
- `Confirm(adminId)`/`Reject(adminId, reason)` → допустимы из **обоих** `AwaitingPayment` и
`AwaitingConfirmation` (админ мог заметить оплату раньше, чем пользователь нажал кнопку).
Для `Kind.Subscription` `Confirm` продлевает `AppUser.BillingPaidUntil = max(текущий, сейчас) +
период` (не теряет уже оплаченный остаток при досрочной оплате), возвращает в `Active` конфиги,
приостановленные за неуплату (`Suspend()`/`Resume()` на `VpnConfig`, статус `Expired`), обновляет
`ExpiresAt` на всех конфигах пользователя. Для `Kind.RoleChangeTopUp` `Confirm` **только** переводит
`ExpiresAt` на всех конфигах пользователя. Для `Kind.PlanChangeTopUp` `Confirm` **только** переводит
заявку в `Confirmed``BillingPaidUntil` не трогает (это не покупка времени, а закрытие долга за уже
выданный апгрейд) — см. `ConfirmPaymentRequestCommandHandler`.
выданное увеличение квоты) — см. `ConfirmPaymentRequestCommandHandler`.
#### RoleChangeTopUp — доплата при апгрейде роли с активным периодом
#### PlanChangeTopUp — доплата при увеличении тарифа с активным периодом
Пользователь с активным `BillingPaidUntil` меняет роль (тикетом `SupportTicket.RoleRequest`,
`ApproveRoleRequestCommandHandler`) на более дорогую — по-хорошему должен доплатить разницу, а не
доиграть апгрейд бесплатно до конца уже оплаченного срока. **Роль меняется сразу** (не блокируется
ожиданием оплаты); доплата решается отдельно через обычный флоу `PaymentRequest`
(`Kind.RoleChangeTopUp`) — тем же путём, что и обычная оплата: сайт (`billing.tsx`,
`PaymentRequestPanel`) или Telegram (`pay:approve`/`pay:reject`).
Пользователь с активным `BillingPaidUntil` увеличивает тариф (`ChangePlanCommand`, самостоятельно,
без подтверждения админа) — по-хорошему должен доплатить разницу, а не доиграть увеличенную квоту
бесплатно до конца уже оплаченного срока. **Квота меняется сразу** (не блокируется ожиданием
оплаты); доплата решается отдельно через обычный флоу `PaymentRequest` (`Kind.PlanChangeTopUp`) —
тем же путём, что и обычная оплата: сайт (`billing.tsx`, `PaymentRequestPanel`) или Telegram
(`pay:approve`/`pay:reject`).
Сумма — `RoleChangeTopUp.Compute` (`backend/src/PnvPanel.Domain/Billing/RoleChangeTopUp.cs`), чистая
Сумма — `PlanChangeTopUp.Compute` (`backend/src/PnvPanel.Domain/Billing/PlanChangeTopUp.cs`), чистая
функция без I/O:
1. Месячная стоимость роли = `PricingSettings.PricePerConfigPerQuarter × MaxConfigs`, затем скидка по
лесенке `PricingDiscountTier` (`PricingDiscount.ResolvePercent`/`Apply`) — та же формула и тот же
базовый (квартальный/минимальный) тариф, что у обычной оплаты, независимо от того, за какой период
пользователь платил на самом деле — упрощение, чтобы не вводить отдельное понятие «дневная ставка
по фактическому тарифу».
2. Разница месячных стоимостей новой и старой роли, поделённая на 30 (условный «месяц» для
1. Месячная стоимость тарифа = `PricingSettings.PricePerConfigPerQuarter × количество_конфигов`,
затем скидка по лесенке `PricingDiscountTier` (`PricingDiscount.ResolvePercent`/`Apply`) — та же
формула и тот же базовый (квартальный/минимальный) тариф, что у обычной оплаты, независимо от
того, за какой период пользователь платил на самом деле — упрощение, чтобы не вводить отдельное
понятие «дневная ставка по фактическому тарифу».
2. Разница месячных стоимостей новой и старой квоты, поделённая на 30 (условный «месяц» для
проратирования) и умноженная на число оставшихся до `BillingPaidUntil` дней — округление до целого
рубля (`MidpointRounding.AwayFromZero`).
3. `null` (доплата не создаётся), если: новая роль не дороже старой (в т.ч. понижение — остаётся
грандфазеринг, без доплаты и без возврата), оплаченный период уже истёк, либо старая/новая роль без
лимита конфигов (`MaxConfigs = -1`, цена не считается).
3. `null` (доплата не создаётся), если: новая квота не больше старой (понижение — см. `ChangePlanCommand`
выше, требует пикера конфигов, а не доплаты), оплаченный период уже истёк, либо старая/новая квота
без лимита (`ConfigQuota = -1`, цена не считается — на практике не встречается вне `admin`, для
которого биллинг вообще не применяется).
Применяется только к самостоятельной заявке на роль (`ApproveRoleRequestCommandHandler`) — админская
прямая смена роли (`PATCH /api/admin/users/{id}/role`, `UserManageDialog`) доплату не создаёт: это
осознанный инструмент админа, который может быть применён как поощрение.
Применяется только к самостоятельной смене тарифа (`ChangePlanCommandHandler`) — админский прямой
оверрайд (`PATCH /api/admin/users/{id}/plan`, `AdminSetUserPlanCommandHandler`) доплату не создаёт:
это осознанный инструмент админа, который может быть применён как поощрение (тот же принцип, что и
у прямой смены роли).
**Известное ограничение**: `GetMyBillingStatusQueryHandler` отдаёт только одну `activeRequest`
если у пользователя одновременно есть активная `Subscription`-заявка и `RoleChangeTopUp` (редкий
случай: роль сменили, пока уже шла обычная оплата), на странице `/billing` будет видна только одна из
них (обе видны в админке и обе решаемы через Telegram). Не устранено — узкий edge case, не блокирует
основной сценарий.
если у пользователя одновременно есть активная `Subscription`-заявка и `PlanChangeTopUp` (редкий
случай: тариф сменили, пока уже шла обычная оплата), на странице `/billing` будет видна только одна
из них (обе видны в админке и обе решаемы через Telegram). Не устранено — узкий edge case, не
блокирует основной сценарий.
### BillingService — приостановка за неуплату (фоновая джоба)
`Infrastructure/BackgroundJobs/BillingService.cs`, раз в час (по образцу `TrafficSyncService`). Для
каждого пользователя с billing-ролью, не заблокированного (`IsBlocked`):
- есть **Subscription**-`PaymentRequest` в статусе `AwaitingConfirmation` → не гасить, а защитить
(`BillingConfigResumer.ProtectPendingConfigsAsync`, см. ниже) и пропустить остальную обработку тика.
`RoleChangeTopUp` в этот фильтр намеренно не входит — доплата за апгрейд роли не должна спасать от
приостановки за реально просроченную подписку;
`PlanChangeTopUp` в этот фильтр намеренно не входит — доплата за увеличение тарифа не должна спасать
от приостановки за реально просроченную подписку;
- `BillingPaidUntil` в прошлом (или `null`) и ещё не `BillingSuspended` → приостановить
(`BillingConfigResumer.SuspendConfigsAsync`), `AppUser.BillingSuspended = true`, Telegram-уведомление
пользователю, `AuditLog` (`BillingSuspended`, источник `System`). На последующих тиках (уже
@@ -679,49 +751,43 @@ Application-хендлере поверх результата `IIdentityService
refresh-токена — иначе блокировка обходилась бы passwordless-входом/уже выданным refresh-токеном.
### SupportTicket — обращение в поддержку
Три вида: `BugReport` (свободная форма, с вложениями), `RoleRequest` (запрос существующей роли —
кроме `admin` — либо параметров новой) и `ExtensionRequest` (продление оплаченного периода на N
дней — только для billing-ролей, см. Billing выше). Текст/обоснование не хранится отдельным полем
это первое сообщение в переписке (`TicketComment`), созданное вместе с тикетом в одной операции.
Два вида: `BugReport` (свободная форма, с вложениями) и `ExtensionRequest` (продление оплаченного
периода на N дней — только для billing-ролей, см. Billing выше). Текст/обоснование не хранится
отдельным полемэто первое сообщение в переписке (`TicketComment`), созданное вместе с тикетом в
одной операции.
> До введения `Plan` (см. выше) существовал третий вид — `RoleRequest` (самостоятельная заявка на
> роль/квоту, с одобрением админом). С переходом квоты конфигов на `AppUser.ConfigQuota` и
> самостоятельной сменой тарифа без подтверждения (`ChangePlanCommand`) необходимость в этом виде
> отпала — роль меняет только админ напрямую (`PATCH /api/admin/users/{id}/role`).
| Поле | Тип | Заметки |
| ------------------- | ----------------- | ---------------------------------------------------------------- |
| `Id` | `Guid` | PK |
| `UserId` | `Guid` | FK → AppUser (автор) |
| `Type` | `TicketType` | `BugReport` / `RoleRequest` / `ExtensionRequest` |
| `Type` | `TicketType` | `BugReport` / `ExtensionRequest` |
| `Status` | `TicketStatus` | `Open` / `Resolved` / `Closed` |
| `RequestedRoleId` | `Guid?` | Заполнено для `RoleRequest` при выборе существующей роли |
| `ProposedRoleName` | `string?` | Заполнено для `RoleRequest` при запросе новой роли |
| `ProposedMaxConfigs`| `int?` | Параметры новой роли (см. `AppRole.MaxConfigs`) |
| `ProposedMaxIpLimit`| `int?` | Параметры новой роли (см. `AppRole.MaxIpLimit`) |
| `RequestedDays` | `int?` | Заполнено для `ExtensionRequest` — сколько дней просит пользователь (1–365) |
| `CreatedAt` | `DateTimeOffset` | |
Инварианты и переходы (`backend/src/PnvPanel.Domain/Support/SupportTicket.cs`): `RequestedRoleId`
и `Proposed*` никогда не заполнены одновременно — гарантируется отдельными фабриками
(`CreateRoleRequestForExistingRole`/`CreateRoleRequestForNewRole`), а не runtime-проверкой. Аналогично
`RequestedDays` заполняется только фабрикой `CreateExtensionRequest`.
- `Resolve()` — только из `Open`. Для `RoleRequest` одобрение — оркестрация в Application
(`ApproveRoleRequestCommandHandler`): при новой роли сначала `IRoleService.CreateRoleAsync`, затем
в любом случае `ChangeUserRoleAsync` пользователю, и только потом `ticket.Resolve()`. Для
`ExtensionRequest``ApproveExtensionRequestCommandHandler` продлевает `AppUser.BillingPaidUntil`
на `RequestedDays` (от `max(текущий, сейчас)`, как и у `PaymentRequest`) и возвращает в `Active`
конфиги, приостановленные за неуплату (`BillingConfigResumer`, тот же helper, что и у подтверждения
оплаты и гифт-дней от админа).
- `Close()` — из `Open` или `Resolved`, **финал** (обратного пути нет). Для `RoleRequest`/
`ExtensionRequest` — отклонение.
Инварианты и переходы (`backend/src/PnvPanel.Domain/Support/SupportTicket.cs`): `RequestedDays`
заполняется только фабрикой `CreateExtensionRequest`.
- `Resolve()` — только из `Open`. Для `ExtensionRequest``ApproveExtensionRequestCommandHandler`
продлевает `AppUser.BillingPaidUntil` на `RequestedDays` (от `max(текущий, сейчас)`, как и у
`PaymentRequest`) и возвращает в `Active` конфиги, приостановленные за неуплату
(`BillingConfigResumer`, тот же helper, что и у подтверждения оплаты и гифт-дней от админа).
- `Close()` — из `Open` или `Resolved`, **финал** (обратного пути нет). Для `ExtensionRequest`
отклонение.
- `Reopen()` — только из `Resolved` (владелец тикета); `Closed` не переоткрывается.
- **`approve`/`reject` — единственный путь решить `RoleRequest`/`ExtensionRequest`.** Общие
`/admin/support/tickets/{id}/resolve|close` (для произвольного `BugReport`) на этих двух типах
- **`approve-extension`/`reject-extension` — единственный путь решить `ExtensionRequest`.** Общие
`/admin/support/tickets/{id}/resolve|close` (для произвольного `BugReport`) на этом типе
возвращают `OnlyBugReportCanBeResolvedDirectly`/`OnlyBugReportCanBeClosedDirectly` без изменения
статуса — иначе `resolve` обходил бы `ApproveRoleRequestCommandHandler`/
`ApproveExtensionRequestCommandHandler` и переводил тикет в `Resolved`, так и не выдав роль/дни.
По той же причине `Reopen()` на `RoleRequest`/`ExtensionRequest` тоже запрещён
(`OnlyBugReportCanBeReopened`) — переоткрытие уже решённой заявки на роль не имеет осмысленного
действия (роль/дни уже выданы, откатывать их не пытаемся).
- Одновременно не более одной **открытой** заявки на роль (`Type == RoleRequest && Status == Open`)
и отдельно не более одной открытой заявки на продление (`Type == ExtensionRequest && Status == Open`)
на пользователя — проверяется в Application, аналогично `ActivationRequest.AlreadyPending`.
статуса — иначе `resolve` обходил бы `ApproveExtensionRequestCommandHandler` и переводил тикет в
`Resolved`, так и не начислив дни. По той же причине `Reopen()` на `ExtensionRequest` тоже
запрещён (`OnlyBugReportCanBeReopened`) — переоткрытие уже решённой заявки на продление не имеет
осмысленного действия (дни уже выданы, откатывать их не пытаемся).
- Не более одной открытой заявки на продление (`Type == ExtensionRequest && Status == Open`) на
пользователя — проверяется в Application, аналогично `ActivationRequest.AlreadyPending`.
Баг-репорты такого ограничения не имеют.
- Создать `ExtensionRequest` может только пользователь с billing-ролью
(`CurrentUserProfile.BillingEnabled`) — иначе `Billing.NotEnabled`.
@@ -730,10 +796,9 @@ refresh-токена — иначе блокировка обходилась б
ролевой проверкой, без завязки на активацию.
- Resolve/close/reject **собственного** тикета админом разрешены — они не трогают роль, риска нет
(запрет ломал бы самообслуживание: тикет единственного админа застревал бы в `Open` навсегда, убрать
некому). Единственное действие с реальным риском — approve заявки на роль, потому что оно меняет
роль заявителя; его самостоятельная защита не нужна — она уже есть на уровень ниже, см. `AppRole`
(`RoleErrors.CannotRemoveLastAdmin`), и одинаково работает что для approve своей заявки, что для
прямой смены роли через `/admin/users`.
некому). Смена роли остаётся отдельным доверенным действием (`PATCH /admin/users/{id}/role`), не
связанным с тикетами, — её единственная защита (`RoleErrors.CannotRemoveLastAdmin`) живёт на уровне
`AppRole` и не пересекается с этим флоу.
- `Closed`-тикеты не удаляются автоматически — админ может подчистить их вручную (вкладка
«Обслуживание», `DELETE /api/admin/maintenance/tickets/closed`), это удаляет и `TicketComment`/
`TicketAttachment` (+ файлы на диске), необратимо.
@@ -790,7 +855,7 @@ enum TelegramLoginStatus { Pending, Approved, Rejected, Expired, Consumed }
enum ActivationStatus { Pending, Approved, Rejected }
enum AuditSource { Web, Telegram, System }
enum OsPlatform { IOS, Android, Windows, MacOS, Linux }
enum TicketType { BugReport, RoleRequest, ExtensionRequest }
enum TicketType { BugReport, ExtensionRequest }
enum TicketStatus { Open, Resolved, Closed }
```
@@ -818,10 +883,8 @@ enum TicketStatus { Open, Resolved, Closed }
| `NodeHealthCheckService` (фон) | Обновляет `NodeStatus`; realtime `nodeStatusChanged` группе `admins` |
| `TrafficSyncService` (фон) | `UpdateTraffic(...)`; realtime `configTrafficUpdated` владельцу |
| `CreateBugReportTicketCommandHandler` | Realtime `ticketCreated` группе `admins`; Telegram админам — превью текста + кнопка-ссылка на сайт |
| `CreateRoleRequestTicketCommandHandler` | Realtime `ticketCreated` группе `admins`; Telegram админам — инлайн-кнопки «Одобрить/Отклонить» |
| `AddTicketCommentCommandHandler` | Realtime `ticketUpdated` владельцу, только если комментирует не он сам |
| `ApproveRoleRequestCommandHandler` | Создаёт роль (если новая) + назначает пользователю; `AuditLog` (`RoleRequestApproved`); Telegram-DM владельцу |
| `RejectRoleRequestCommandHandler` | `AuditLog` (`RoleRequestRejected`); Telegram-DM владельцу |
| `ChangePlanCommandHandler` | Меняет `AppUser.ConfigQuota`/`PlanId`, при понижении отзывает выбранные конфиги в 3x-ui; `AuditLog` (`PlanChanged`); при доплате — создаёт `PaymentRequest` (`PlanChangeTopUp`) + Telegram-DM владельцу |
| `CreateExtensionRequestTicketCommandHandler` | Realtime `ticketCreated` группе `admins`; Telegram админам — инлайн-кнопки «Одобрить/Отклонить» |
| `ApproveExtensionRequestCommandHandler` | Продлевает `BillingPaidUntil`, возвращает приостановленные конфиги; `AuditLog` (`ExtensionRequestApproved`); Telegram-DM владельцу |
| `RejectExtensionRequestCommandHandler` | `AuditLog` (`ExtensionRequestRejected`); Telegram-DM владельцу |