Enhance billing request handling to support immediate config suspension and protection
CI / Backend (build + test) (push) Successful in 1m21s
CI / Frontend (lint + typecheck + build) (push) Successful in 33s

- 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:
Leonid Pershin
2026-07-19 19:25:33 +03:00
parent 979eddf72e
commit b32756d5bc
8 changed files with 617 additions and 200 deletions
+52 -26
View File
@@ -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.