Enhance billing request handling to support immediate config suspension and protection
- Updated the `RejectPaymentRequestCommandHandler` to immediately suspend user configs if a payment request is rejected and no other pending subscription requests exist. - Introduced `SuspendIfStillUnpaidAsync` method to handle the logic for suspending configs based on the user's billing status. - Enhanced the `MarkPaymentSentCommandHandler` to protect configs during the payment confirmation process, ensuring users remain active while awaiting admin approval. - Refactored `BillingConfigResumer` to include methods for protecting and suspending configs, improving the overall billing management flow. - Updated tests to cover new behaviors and ensure proper functionality in various scenarios related to payment requests and config management.
This commit is contained in:
+52
-26
@@ -488,33 +488,59 @@ Singleton (как `PricingSettings`) — реквизиты для оплаты
|
||||
### BillingService — приостановка за неуплату (фоновая джоба)
|
||||
`Infrastructure/BackgroundJobs/BillingService.cs`, раз в час (по образцу `TrafficSyncService`). Для
|
||||
каждого пользователя с billing-ролью, не заблокированного (`IsBlocked`):
|
||||
- есть `PaymentRequest` в статусе `AwaitingConfirmation` → **пропустить** — это и есть защита «заявка
|
||||
висит на подтверждении админом, а срок истёк» из требований: конфиги не гасятся, пока админ не
|
||||
решит (не по вине пользователя, что админ не успел проверить оплату);
|
||||
- `BillingPaidUntil` в прошлом (или `null`) и ещё не `BillingSuspended` → приостановить все `Active`
|
||||
конфиги (`Suspend()` → `Expired`, гейтвей `UpdateClientAsync(expiresAt: вчера)`, идемпотентно как в
|
||||
`BlockUserCommandHandler`), `AppUser.BillingSuspended = true`, Telegram-уведомление пользователю,
|
||||
`AuditLog` (`BillingSuspended`, источник `System`). На последующих тиках (уже suspended) — только
|
||||
идемпотентная досуспензия «зависших» `Active`-конфигов (самовосстановление после недоступности
|
||||
ноды), без повторных уведомлений;
|
||||
- есть **Subscription**-`PaymentRequest` в статусе `AwaitingConfirmation` → не гасить, а защитить
|
||||
(`BillingConfigResumer.ProtectPendingConfigsAsync`, см. ниже) и пропустить остальную обработку тика.
|
||||
`RoleChangeTopUp` в этот фильтр намеренно не входит — доплата за апгрейд роли не должна спасать от
|
||||
приостановки за реально просроченную подписку;
|
||||
- `BillingPaidUntil` в прошлом (или `null`) и ещё не `BillingSuspended` → приостановить
|
||||
(`BillingConfigResumer.SuspendConfigsAsync`), `AppUser.BillingSuspended = true`, Telegram-уведомление
|
||||
пользователю, `AuditLog` (`BillingSuspended`, источник `System`). На последующих тиках (уже
|
||||
suspended) — только идемпотентная досуспензия «зависших» конфигов (самовосстановление после
|
||||
недоступности ноды), без повторных уведомлений;
|
||||
- до истечения ≤ 3 дней и предупреждение для этого `PaidUntil` ещё не отправлено
|
||||
(`BillingLastWarnedForPaidUntil != PaidUntil`) → Telegram-предупреждение, отметка отправки.
|
||||
|
||||
#### Приостановка/возврат за неуплату — через expiresAt, не enable
|
||||
#### Синхронизация панели 3x-ui со статусом оплаты — `BillingConfigResumer`
|
||||
|
||||
Приостановка за неуплату (`BillingService`) и возврат (`BillingConfigResumer`) управляют панельным
|
||||
клиентом через `IXuiPanelGateway.UpdateClientAsync(..., expiresAt: ..., enable: null, ...)`, а не через
|
||||
`enable: false/true` — по факту эксплуатации переключение `enable` ненадёжно останавливает уже
|
||||
установленные соединения на стороне Xray/панели, а просроченный `expiryTime` — надёжно, и не зависит
|
||||
от `enable`. Конкретно:
|
||||
- **Приостановка**: `expiresAt = UtcNow.AddDays(-1)` — гарантированно просроченная дата, `enable` не
|
||||
трогаем (`null`, «оставить как есть» — см. ThreeXui.Net `UpdateClientRequest`).
|
||||
- **Возврат**: `expiresAt = newPaidUntil` (реальный новый срок оплаты, не «снять ограничение» на
|
||||
бесконечность) — так панель/Xray сама несёт актуальный срок: если `BillingService` вдруг пропустит
|
||||
тик, панель всё равно перестанет пускать по истечении этой даты независимо от приложения.
|
||||
- **Создание** (`CreateVpnConfigCommandHandler`) уже пушит `expiresAt = profile.BillingPaidUntil` при
|
||||
`BillingEnabled` через `AddClientAsync` — свежий конфиг сразу несёт правильный срок, не «без
|
||||
ограничения» до первого цикла `BillingService`.
|
||||
`Application/Billing/BillingConfigResumer.cs` — общая точка для всей синхронизации панельного клиента
|
||||
с оплатой; используется и `BillingService` (Infrastructure, за счёт направления зависимостей
|
||||
`Infrastructure → Application`), и Application-хендлерами напрямую. Три операции, все — через
|
||||
`IXuiPanelGateway.UpdateClientAsync(..., enable: ..., expiresAt: ..., ...)`, и **всегда передают оба
|
||||
параметра явно** (не полагаясь на один `enable` или один `expiryTime`) — по факту эксплуатации ни один
|
||||
из двух по отдельности не даёт достаточной гарантии (переключение `enable` не всегда обрывает уже
|
||||
установленные соединения на стороне Xray/панели, поэтому дублируем просроченным/будущим `expiryTime`):
|
||||
|
||||
- **`SuspendConfigsAsync`** — приостановка: `enable: false`, `expiresAt = UtcNow.AddDays(-1)`
|
||||
(гарантированно просроченная дата). Проходит и по `Active`, и по уже `Expired` конфигам (последние
|
||||
могли быть временно защищены `ProtectPendingConfigsAsync` — см. ниже, — защиту с них тоже нужно снять
|
||||
при отклонении заявки, не только локальный статус). Вызывается из `BillingService` (часовой тик) и из
|
||||
`RejectPaymentRequestCommandHandler` (немедленно при отклонении — см. ниже).
|
||||
- **`ResumeConfigsAsync`** — подтверждённая оплата/продление/гифт: `enable: true`, `expiresAt =
|
||||
newPaidUntil` (реальный новый срок, не «снять ограничение» на бесконечность — так панель/Xray сама
|
||||
несёт актуальный срок и продолжит блокировать по его истечении, даже если `BillingService` вдруг
|
||||
пропустит тик). Пушится на панель для **любого** статуса конфига, не только `Expired` — пользователь
|
||||
мог продлить/доплатить заранее, пока конфиг ещё `Active`, и панель должна узнать новый срок сразу, а
|
||||
не только когда конфиг реально просрочится и `BillingService` его тронет.
|
||||
- **`ProtectPendingConfigsAsync`** — заявка на оплату ждёт решения админа: `enable: true`, `expiresAt =
|
||||
UtcNow + BillingSettings.GraceDays` — временный грейс, пока админ проверяет. **Не трогает** `Status`/
|
||||
`ExpiresAt` в БД — это провизорная мера на панели до `Confirm` (`ResumeConfigsAsync` проставит
|
||||
настоящий срок) или `Reject` (`SuspendConfigsAsync` вернёт как было). Нужна отдельно от простого
|
||||
«пропустить приостановку», потому что Xray сам проверяет `expiryTime` клиента независимо от нашего
|
||||
локального статуса — если реальный `BillingPaidUntil` уже в прошлом, панель заблокирует клиента сама,
|
||||
даже если наше приложение ничего не приостанавливало. Вызывается сразу при `MarkPaymentSentCommandHandler`
|
||||
(не ждём часовой тик — пользователь отметил оплату, конфиги должны остаться рабочими немедленно) и
|
||||
повторно на каждом тике `BillingService`, пока заявка висит (идемпотентно продлевает грейс).
|
||||
|
||||
Симметрично: если админ **отклоняет** заявку (`RejectPaymentRequestCommandHandler`) и период всё ещё
|
||||
просрочен, а других Subscription-заявок на проверке нет — `SuspendConfigsAsync` вызывается немедленно,
|
||||
а не через до часа ожидания следующего тика `BillingService` (поиск «других заявок» явно исключает саму
|
||||
отклоняемую по Id: `request.Reject(...)` меняет статус только в трекере EF, до `SaveChangesAsync` в БД
|
||||
всё ещё лежит старое `AwaitingConfirmation` — без исключения по Id проверка ложно приняла бы её за ещё
|
||||
одну висящую заявку).
|
||||
|
||||
Прочие точки, не входящие в `BillingConfigResumer`:
|
||||
- **Создание** (`CreateVpnConfigCommandHandler`) пушит `expiresAt = profile.BillingPaidUntil` при
|
||||
`BillingEnabled` через `AddClientAsync` — свежий конфиг сразу несёт правильный срок.
|
||||
- **Ротация** (`RotateVpnConfigCommandHandler`) переносит текущий `config.ExpiresAt` на нового клиента
|
||||
— ротация не должна ни продлевать, ни сбрасывать оплаченный период.
|
||||
- Блокировка/разблокировка админом (`Disable()`/`Enable()`) — отдельная ось, управляет только
|
||||
@@ -531,10 +557,10 @@ Singleton (как `PricingSettings`) — реквизиты для оплаты
|
||||
`GET/POST /api/billing/*` — пользователь (статус, создание/отмена заявки, «я оплатил», отправка
|
||||
реквизитов в свой Telegram). `GET/PUT/POST /api/admin/billing/*` — админ (настройки, список заявок,
|
||||
подтверждение/отклонение, `POST /gift` — выдать N дней конкретному пользователю без заявки), только
|
||||
`admin`. Продление PaidUntil попадает в панель тремя путями — подтверждённая `PaymentRequest`,
|
||||
`admin`. Продление `PaidUntil` попадает в панель тремя путями — подтверждённая `PaymentRequest`,
|
||||
одобренная `SupportTicket(ExtensionRequest)` и прямой гифт от админа — все три используют один и тот
|
||||
же `BillingConfigResumer` (см. Application/Billing), различается только вычисление `newPaidUntil`
|
||||
(месяцы для оплаты, дни для продления/гифта) и триггер (пользователь vs админ).
|
||||
же `BillingConfigResumer.ResumeConfigsAsync`, различается только вычисление `newPaidUntil` (месяцы для
|
||||
оплаты, дни для продления/гифта) и триггер (пользователь vs админ).
|
||||
|
||||
### ActivationRequest — запрос активации
|
||||
Пользователь просит активацию у админа; админ одобряет/отклоняет на сайте или в Telegram.
|
||||
|
||||
Reference in New Issue
Block a user