From 29291b5decea948440528a4c8a3d0876fc6b4f71 Mon Sep 17 00:00:00 2001 From: Leonid Pershin Date: Wed, 22 Jul 2026 23:43:45 +0300 Subject: [PATCH] Refactor billing configuration management to centralize expiration handling - Updated `BillingConfigResumer` to manage billing expiration entirely on the application side, using `enable` as the sole control mechanism for client access. - Changed all relevant methods to pass `DateTimeOffset.UnixEpoch` for `expiresAt`, ensuring the panel does not enforce expiration independently of our application logic. - Modified `IXuiPanelGateway` interface to reflect the new expiration handling approach, clarifying the role of `expiresAt` in client management. - Adjusted command handlers for creating and rotating VPN configurations to set `expiresAt` to `null`, preventing unintended expiration enforcement by the panel. - Enhanced documentation to explain the new billing expiration management strategy and its implications for client configurations. --- .../Billing/BillingConfigResumer.cs | 56 ++++++++---------- .../Common/Interfaces/IXuiPanelGateway.cs | 23 +++++--- .../Create/CreateVpnConfigCommandHandler.cs | 6 +- .../Rotate/RotateVpnConfigCommandHandler.cs | 7 ++- ...onfirmPaymentRequestCommandHandlerTests.cs | 11 ++-- ...RejectPaymentRequestCommandHandlerTests.cs | 4 +- ...roveExtensionRequestCommandHandlerTests.cs | 14 +++++ .../MarkPaymentSentCommandHandlerTests.cs | 5 +- docs/domain-model.md | 58 +++++++++---------- 9 files changed, 102 insertions(+), 82 deletions(-) diff --git a/backend/src/PnvPanel.Application/Billing/BillingConfigResumer.cs b/backend/src/PnvPanel.Application/Billing/BillingConfigResumer.cs index b064c94..8b90c2a 100644 --- a/backend/src/PnvPanel.Application/Billing/BillingConfigResumer.cs +++ b/backend/src/PnvPanel.Application/Billing/BillingConfigResumer.cs @@ -1,7 +1,6 @@ using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; using PnvPanel.Application.Common.Interfaces; -using PnvPanel.Domain.Billing; using PnvPanel.Domain.Configs; namespace PnvPanel.Application.Billing; @@ -9,18 +8,23 @@ namespace PnvPanel.Application.Billing; /// /// Общая синхронизация панели 3x-ui со статусом оплаты: продление (ResumeConfigsAsync), временная /// защита на время рассмотрения заявки админом (ProtectPendingConfigsAsync) и приостановка за неуплату -/// (SuspendConfigsAsync). Xray проверяет expiryTime клиента сам, независимо от нашего локального -/// статуса — поэтому "просто не гасить, пока заявка на проверке" недостаточно: если реальный -/// BillingPaidUntil уже в прошлом, панель заблокирует клиента сама, пока мы явно не подвинем -/// expiryTime вперёд. По той же причине везде передаём enable И expiresAt вместе (см. -/// IXuiPanelGateway.UpdateClientAsync) — по факту эксплуатации одного enable недостаточно. +/// (SuspendConfigsAsync). Истечение по биллингу управляется полностью на нашей стороне — единственный +/// рычаг на панели это enable; expiresAt всегда передаётся как +/// (== 0 для 3x-ui/Xray, "без срока"), чтобы панель не могла сама enforce'ить доступ по дате +/// независимо от нашего локального статуса — это заодно сбрасывает унаследованный от старых версий +/// реальный expiryTime, если он когда-то был запушен (создание/ротация конфига тоже больше не +/// передают реальные даты — см. CreateVpnConfigCommandHandler/RotateVpnConfigCommandHandler). /// public static class BillingConfigResumer { - /// Подтверждённая оплата/продление/гифт — реальный новый срок. Пушится на панель для - /// ЛЮБОГО статуса конфига, не только Expired: пользователь мог доплатить/продлить заранее, пока - /// конфиг ещё Active — панель должна узнать новый срок сразу, а не только когда конфиг реально - /// просрочится. + /// 3x-ui/Xray трактует expiryTime == 0 как "без ограничения по сроку" — используем как + /// явное значение "сбросить срок", а не просто not-touch (null в UpdateClientAsync означает + /// "оставить как есть", что не годится для очистки унаследованных реальных дат). + private static readonly DateTimeOffset NoExpiry = DateTimeOffset.UnixEpoch; + + /// Подтверждённая оплата/продление/гифт. Локально фиксируем реальный новый срок + /// (SetBillingExpiry) для отображения/Subscription-Userinfo, но на панель уходит только + /// enable: true — панельный expiryTime истечением больше не управляет. public static async Task ResumeConfigsAsync( IAppDbContext dbContext, IXuiPanelGateway gateway, @@ -36,13 +40,12 @@ public static class BillingConfigResumer foreach (var config in configs) { var wasExpired = config.Status == ConfigStatus.Expired; - var pushed = await PushExpiryAsync( + var pushed = await PushEnableAsync( dbContext, gateway, logger, config, enable: true, - newPaidUntil, "extending billing", cancellationToken ); @@ -65,11 +68,10 @@ public static class BillingConfigResumer } /// Заявка на оплату ждёт решения админа (AwaitingConfirmation) — держим клиента рабочим - /// на панели на время рассмотрения (expiresAt = сейчас + BillingSettings.GraceDays), не трогая - /// Status/ExpiresAt в БД — это временная мера до Confirm (ResumeConfigsAsync проставит настоящий - /// срок) или Reject (SuspendConfigsAsync вернёт как было). Вызывается и сразу при отметке "я - /// оплатил" (MarkPaymentSentCommandHandler), и на каждом тике BillingService, пока заявка висит — - /// идемпотентно, каждый вызов просто продлевает грейс ещё на GraceDays от текущего момента. + /// на панели на время рассмотрения (enable: true), не трогая Status/ExpiresAt в БД — это временная + /// мера до Confirm (ResumeConfigsAsync проставит настоящий срок) или Reject (SuspendConfigsAsync + /// вернёт как было). Вызывается и сразу при отметке "я оплатил" (MarkPaymentSentCommandHandler), и + /// на каждом тике BillingService, пока заявка висит — идемпотентно. public static async Task ProtectPendingConfigsAsync( IAppDbContext dbContext, IXuiPanelGateway gateway, @@ -79,20 +81,16 @@ public static class BillingConfigResumer CancellationToken cancellationToken ) { - var settings = await dbContext.BillingSettings.AsNoTracking().FirstOrDefaultAsync(cancellationToken); - var graceUntil = DateTimeOffset.UtcNow.AddDays(settings?.GraceDays ?? BillingSettings.DefaultGraceDays); - var configs = await ActiveOrExpiredConfigsAsync(dbContext, userId, cancellationToken); foreach (var config in configs) { - await PushExpiryAsync( + await PushEnableAsync( dbContext, gateway, logger, config, enable: true, - graceUntil, "protecting pending payment for", cancellationToken ); @@ -104,9 +102,8 @@ public static class BillingConfigResumer /// Приостановка за неуплату — общая для фоновой джобы (BillingService) и немедленной /// реакции на отклонение заявки админом (RejectPaymentRequestCommandHandler), чтобы не ждать /// следующего часового тика. Проходит и по уже Expired конфигам — они могли быть временно - /// "защищены" ProtectPendingConfigsAsync (enable/expiresAt на панели уехали вперёд, а статус в БД - /// остался как был), и при отклонении заявки эту защиту нужно снять с панели тоже, а не только - /// локально. + /// "защищены" ProtectPendingConfigsAsync (enable на панели уехал в true, а статус в БД остался как + /// был), и при отклонении заявки эту защиту нужно снять с панели тоже, а не только локально. public static async Task SuspendConfigsAsync( IAppDbContext dbContext, IXuiPanelGateway gateway, @@ -116,19 +113,17 @@ public static class BillingConfigResumer CancellationToken cancellationToken ) { - var expiredAt = DateTimeOffset.UtcNow.AddDays(-1); var configs = await ActiveOrExpiredConfigsAsync(dbContext, userId, cancellationToken); foreach (var config in configs) { var wasActive = config.Status == ConfigStatus.Active; - var pushed = await PushExpiryAsync( + var pushed = await PushEnableAsync( dbContext, gateway, logger, config, enable: false, - expiredAt, "suspending for non-payment", cancellationToken ); @@ -165,13 +160,12 @@ public static class BillingConfigResumer /// (подхватится следующим циклом/событием). Если у конфига нет инбаунда/ноды вовсе — на панели /// нечего обновлять, но локальный статус менять можно (как и раньше в BillingService/ /// BillingConfigResumer). - private static async Task PushExpiryAsync( + private static async Task PushEnableAsync( IAppDbContext dbContext, IXuiPanelGateway gateway, ILogger logger, VpnConfig config, bool enable, - DateTimeOffset expiresAt, string action, CancellationToken cancellationToken ) @@ -195,7 +189,7 @@ public static class BillingConfigResumer config.Protocol, name: null, enable, - expiresAt, + NoExpiry, cancellationToken ); diff --git a/backend/src/PnvPanel.Application/Common/Interfaces/IXuiPanelGateway.cs b/backend/src/PnvPanel.Application/Common/Interfaces/IXuiPanelGateway.cs index 0c2282b..6b81f8c 100644 --- a/backend/src/PnvPanel.Application/Common/Interfaces/IXuiPanelGateway.cs +++ b/backend/src/PnvPanel.Application/Common/Interfaces/IXuiPanelGateway.cs @@ -36,8 +36,10 @@ public interface IXuiPanelGateway /// Возвращает ClientExternalId, присвоенный панелью (UUID для VLESS/VMess, пароль для Trojan/Shadowsocks). /// — лимит одновременных IP клиента (квота роли, см. AppRole.MaxIpLimit); /// -1 (RoleQuota.Unlimited) означает без лимита — гейтвей сам переводит его в нативное значение 3x-ui. - /// — дата, до которой клиент активен в самой панели (billing-роли — - /// AppUser.BillingPaidUntil на момент создания); null — без ограничения по сроку. + /// — дата, до которой клиент активен в самой панели; null — + /// без ограничения по сроку. Биллинговое истечение управляется полностью на нашей стороне через + /// (..., enable: false, ...) — сюда всегда передаётся null, + /// чтобы панель/Xray не enforce'ил срок независимо от нашего статуса (см. BillingConfigResumer). /// Task> AddClientAsync( Node node, @@ -70,13 +72,16 @@ public interface IXuiPanelGateway /// Единственный легитимный вызывающий с непустым name — EditVpnConfigCommandHandler. /// /// - /// — используется для приостановки/возврата за неуплату - /// (BillingService/BillingConfigResumer): дата в прошлом делает клиента просроченным для самой - /// панели/Xray независимо от (по факту тестирования — переключение - /// enable ненадёжно останавливает уже установленные соединения, а expiryTime — надёжно), дата в - /// будущем (новый AppUser.BillingPaidUntil) снимает приостановку. Блокировка/разблокировка - /// админом (BlockUserCommandHandler/UnblockUserCommandHandler) — отдельная ось, передаёт - /// expiresAt: null и управляет только . + /// — истечение по биллингу управляется полностью на нашей стороне + /// (локальный статус конфига + ), а не через панельный expiryTime: Xray + /// проверял бы его сам, независимо от , что даёт панели независимый от + /// нас источник enforcement и лишнюю точку отказа. Поэтому `BillingConfigResumer` (Resume/Protect/ + /// Suspend) всегда передаёт сюда фиксированный DateTimeOffset.UnixEpoch (== 0, "без срока" + /// для 3x-ui/Xray) — это же явно сбрасывает унаследованный от старых версий реальный expiryTime, + /// если он был запушен ранее. Блокировка/разблокировка админом + /// (BlockUserCommandHandler/UnblockUserCommandHandler) и переименование + /// (EditVpnConfigCommandHandler) — передают expiresAt: null ("не трогать"), так как не + /// относятся к биллингу. /// /// Task UpdateClientAsync( diff --git a/backend/src/PnvPanel.Application/Configs/Create/CreateVpnConfigCommandHandler.cs b/backend/src/PnvPanel.Application/Configs/Create/CreateVpnConfigCommandHandler.cs index a1f67d6..3b32984 100644 --- a/backend/src/PnvPanel.Application/Configs/Create/CreateVpnConfigCommandHandler.cs +++ b/backend/src/PnvPanel.Application/Configs/Create/CreateVpnConfigCommandHandler.cs @@ -73,8 +73,10 @@ public sealed class CreateVpnConfigCommandHandler( config.ClientEmail, config.Label ?? config.ClientEmail, profile.MaxIpLimit, - // BillingRequired-проверка выше гарантирует, что при BillingEnabled PaidUntil уже в будущем. - expiresAt: profile.BillingEnabled ? profile.BillingPaidUntil : null, + // Истечение по биллингу управляется полностью на нашей стороне (BillingConfigResumer, + // через enable), а не панельным expiryTime — сюда всегда null, чтобы не давать панели + // независимый от нас источник enforcement. + expiresAt: null, cancellationToken ); diff --git a/backend/src/PnvPanel.Application/Configs/Rotate/RotateVpnConfigCommandHandler.cs b/backend/src/PnvPanel.Application/Configs/Rotate/RotateVpnConfigCommandHandler.cs index 82b548e..4162028 100644 --- a/backend/src/PnvPanel.Application/Configs/Rotate/RotateVpnConfigCommandHandler.cs +++ b/backend/src/PnvPanel.Application/Configs/Rotate/RotateVpnConfigCommandHandler.cs @@ -56,9 +56,10 @@ public sealed class RotateVpnConfigCommandHandler( newClientEmail, config.Label ?? newClientEmail, profile.MaxIpLimit, - // Переносим уже действующий срок (billing paidUntil) на нового клиента — ротация не должна - // ни продлевать, ни сбрасывать оплаченный период. - expiresAt: config.ExpiresAt, + // Истечение по биллингу управляется полностью на нашей стороне (BillingConfigResumer, + // через enable), а не панельным expiryTime — сюда всегда null, ротация не должна давать + // панели независимый от нас источник enforcement. + expiresAt: null, cancellationToken ); diff --git a/backend/tests/PnvPanel.Application.Tests/Admin/Billing/ConfirmPaymentRequestCommandHandlerTests.cs b/backend/tests/PnvPanel.Application.Tests/Admin/Billing/ConfirmPaymentRequestCommandHandlerTests.cs index fed4915..fe4a7ee 100644 --- a/backend/tests/PnvPanel.Application.Tests/Admin/Billing/ConfirmPaymentRequestCommandHandlerTests.cs +++ b/backend/tests/PnvPanel.Application.Tests/Admin/Billing/ConfirmPaymentRequestCommandHandlerTests.cs @@ -193,9 +193,10 @@ public class ConfirmPaymentRequestCommandHandlerTests Assert.NotNull(expiredConfig.ExpiresAt); Assert.NotNull(activeConfig.ExpiresAt); Assert.Equal(expiredConfig.ExpiresAt, activeConfig.ExpiresAt); - // enable:true И expiresAt=новый newPaidUntil вместе (см. IXuiPanelGateway.UpdateClientAsync) — - // и для ранее Expired конфига (ext-1), и для уже Active (ext-2): пользователь мог доплатить - // заранее, панель должна узнать новый срок сразу, а не только когда конфиг реально просрочится. + // enable:true — истечение управляется полностью на нашей стороне, панельный expiresAt всегда + // сбрасывается в "без срока" (см. BillingConfigResumer.NoExpiry), и для ранее Expired конфига + // (ext-1), и для уже Active (ext-2): пользователь мог доплатить заранее, панель должна узнать + // об этом сразу, а не только когда конфиг реально просрочится. await _gateway .Received(1) .UpdateClientAsync( @@ -205,7 +206,7 @@ public class ConfirmPaymentRequestCommandHandlerTests VpnProtocol.Vless, Arg.Any(), true, - Arg.Is(d => d == expiredConfig.ExpiresAt), + Arg.Is(d => d == DateTimeOffset.UnixEpoch), Arg.Any() ); await _gateway @@ -217,7 +218,7 @@ public class ConfirmPaymentRequestCommandHandlerTests VpnProtocol.Vless, Arg.Any(), true, - Arg.Is(d => d == activeConfig.ExpiresAt), + Arg.Is(d => d == DateTimeOffset.UnixEpoch), Arg.Any() ); await _notifier.Received(1).NotifyBillingStatusChangedAsync(userId, Arg.Any()); diff --git a/backend/tests/PnvPanel.Application.Tests/Admin/Billing/RejectPaymentRequestCommandHandlerTests.cs b/backend/tests/PnvPanel.Application.Tests/Admin/Billing/RejectPaymentRequestCommandHandlerTests.cs index 4e43cf8..8d48f73 100644 --- a/backend/tests/PnvPanel.Application.Tests/Admin/Billing/RejectPaymentRequestCommandHandlerTests.cs +++ b/backend/tests/PnvPanel.Application.Tests/Admin/Billing/RejectPaymentRequestCommandHandlerTests.cs @@ -135,6 +135,8 @@ public class RejectPaymentRequestCommandHandlerTests Assert.True(result.IsSuccess); Assert.Equal(ConfigStatus.Expired, config.Status); + // enable:false — истечение управляется полностью на нашей стороне, панельный expiresAt + // сбрасывается в "без срока" (см. BillingConfigResumer.NoExpiry), не реальную просроченную дату. await _gateway .Received(1) .UpdateClientAsync( @@ -144,7 +146,7 @@ public class RejectPaymentRequestCommandHandlerTests VpnProtocol.Vless, Arg.Any(), false, - Arg.Any(), + Arg.Is(d => d == DateTimeOffset.UnixEpoch), Arg.Any() ); // Фронт (/billing) должен узнать о смене статуса сразу, не дожидаясь опроса. diff --git a/backend/tests/PnvPanel.Application.Tests/Admin/Support/ApproveExtensionRequestCommandHandlerTests.cs b/backend/tests/PnvPanel.Application.Tests/Admin/Support/ApproveExtensionRequestCommandHandlerTests.cs index 2b8ee0c..7677d8b 100644 --- a/backend/tests/PnvPanel.Application.Tests/Admin/Support/ApproveExtensionRequestCommandHandlerTests.cs +++ b/backend/tests/PnvPanel.Application.Tests/Admin/Support/ApproveExtensionRequestCommandHandlerTests.cs @@ -144,6 +144,20 @@ public class ApproveExtensionRequestCommandHandlerTests Assert.True(result.IsSuccess); Assert.Equal(ConfigStatus.Active, config.Status); Assert.NotNull(config.ExpiresAt); + // enable:true — истечение управляется полностью на нашей стороне, панельный expiresAt + // сбрасывается в "без срока" (см. BillingConfigResumer.NoExpiry), а не в реальный новый срок. + await _gateway + .Received(1) + .UpdateClientAsync( + Arg.Any(), + inbound.RemoteInboundId, + "ext-1", + VpnProtocol.Vless, + Arg.Any(), + true, + Arg.Is(d => d == DateTimeOffset.UnixEpoch), + Arg.Any() + ); } [Fact] diff --git a/backend/tests/PnvPanel.Application.Tests/Billing/MarkPaymentSent/MarkPaymentSentCommandHandlerTests.cs b/backend/tests/PnvPanel.Application.Tests/Billing/MarkPaymentSent/MarkPaymentSentCommandHandlerTests.cs index c2e7844..e25ba3a 100644 --- a/backend/tests/PnvPanel.Application.Tests/Billing/MarkPaymentSent/MarkPaymentSentCommandHandlerTests.cs +++ b/backend/tests/PnvPanel.Application.Tests/Billing/MarkPaymentSent/MarkPaymentSentCommandHandlerTests.cs @@ -127,7 +127,8 @@ public class MarkPaymentSentCommandHandlerTests .Handle(new MarkPaymentSentCommand(request.Id), CancellationToken.None); Assert.True(result.IsSuccess); - // enable:true + expiresAt в будущем — держим клиента рабочим на панели, пока заявка на проверке. + // enable:true — держим клиента рабочим на панели, пока заявка на проверке; expiresAt всегда + // сбрасывается в "без срока" (см. BillingConfigResumer.NoExpiry), истечение — только локально. await _gateway .Received(1) .UpdateClientAsync( @@ -137,7 +138,7 @@ public class MarkPaymentSentCommandHandlerTests VpnProtocol.Vless, Arg.Any(), true, - Arg.Is(d => d > DateTimeOffset.UtcNow), + Arg.Is(d => d == DateTimeOffset.UnixEpoch), Arg.Any() ); await _notifier.Received(1).NotifyBillingStatusChangedAsync(userId, Arg.Any()); diff --git a/docs/domain-model.md b/docs/domain-model.md index 1ccfbc3..103049e 100644 --- a/docs/domain-model.md +++ b/docs/domain-model.md @@ -544,32 +544,31 @@ Singleton (как `PricingSettings`) — реквизиты для оплаты `Application/Billing/BillingConfigResumer.cs` — общая точка для всей синхронизации панельного клиента с оплатой; используется и `BillingService` (Infrastructure, за счёт направления зависимостей -`Infrastructure → Application`), и Application-хендлерами напрямую. Три операции, все — через -`IXuiPanelGateway.UpdateClientAsync(..., enable: ..., expiresAt: ..., ...)`, и **всегда передают оба -параметра явно** (не полагаясь на один `enable` или один `expiryTime`) — по факту эксплуатации ни один -из двух по отдельности не даёт достаточной гарантии (переключение `enable` не всегда обрывает уже -установленные соединения на стороне Xray/панели, поэтому дублируем просроченным/будущим `expiryTime`): +`Infrastructure → Application`), и Application-хендлерами напрямую. Истечение по биллингу управляется +**полностью на нашей стороне** — единственный рычаг на панели это `enable`; `expiresAt` во всех трёх +операциях всегда передаётся как `DateTimeOffset.UnixEpoch` (панель/Xray трактует `expiryTime == 0` как +«без ограничения по сроку»), а не `null` — `UpdateClientAsync` с `expiresAt: null` означает «не +трогать», чего недостаточно, чтобы гарантированно снять унаследованный от старых версий реальный +`expiryTime`, если он когда-то был запушен. Три операции, все — через +`IXuiPanelGateway.UpdateClientAsync(..., enable: ..., expiresAt: ..., ...)`: -- **`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`, пока заявка висит (идемпотентно продлевает грейс). +- **`SuspendConfigsAsync`** — приостановка: `enable: false`. Проходит и по `Active`, и по уже + `Expired` конфигам (последние могли быть временно защищены `ProtectPendingConfigsAsync` — см. + ниже, — защиту с них тоже нужно снять при отклонении заявки, не только локальный статус). Вызывается + из `BillingService` (часовой тик) и из `RejectPaymentRequestCommandHandler` (немедленно при + отклонении — см. ниже). +- **`ResumeConfigsAsync`** — подтверждённая оплата/продление/гифт: `enable: true`. Реальный новый срок + фиксируется только локально (`config.SetBillingExpiry(newPaidUntil)`, денормализация + `AppUser.BillingPaidUntil` для отображения и `Subscription-Userinfo`) — панель им не управляет. + Пушится на панель для **любого** статуса конфига, не только `Expired` — пользователь мог + продлить/доплатить заранее, пока конфиг ещё `Active`, и панель должна снять возможную блокировку + сразу, а не только когда `BillingService` в следующий раз тронет конфиг. +- **`ProtectPendingConfigsAsync`** — заявка на оплату ждёт решения админа: `enable: true`. **Не + трогает** `Status`/`ExpiresAt` в БД — это провизорная мера на панели до `Confirm` + (`ResumeConfigsAsync`) или `Reject` (`SuspendConfigsAsync` вернёт как было). Вызывается сразу при + `MarkPaymentSentCommandHandler` (не ждём часовой тик — пользователь отметил оплату, конфиги должны + остаться рабочими немедленно) и повторно на каждом тике `BillingService`, пока заявка висит + (идемпотентно). Симметрично: если админ **отклоняет** заявку (`RejectPaymentRequestCommandHandler`) и период всё ещё просрочен, а других Subscription-заявок на проверке нет — `SuspendConfigsAsync` вызывается немедленно, @@ -596,10 +595,11 @@ Application-хендлере поверх результата `IIdentityService граница Identity/биллинг не нарушается). Прочие точки, не входящие в `BillingConfigResumer`: -- **Создание** (`CreateVpnConfigCommandHandler`) пушит `expiresAt = profile.BillingPaidUntil` при - `BillingEnabled` через `AddClientAsync` — свежий конфиг сразу несёт правильный срок. -- **Ротация** (`RotateVpnConfigCommandHandler`) переносит текущий `config.ExpiresAt` на нового клиента - — ротация не должна ни продлевать, ни сбрасывать оплаченный период. +- **Создание** (`CreateVpnConfigCommandHandler`) и **ротация** (`RotateVpnConfigCommandHandler`) + всегда передают `expiresAt: null` в `AddClientAsync` — клиент создаётся без ограничения по сроку на + панели (`null` → `expiryTime = 0`). Биллинговым истечением этого клиента далее управляет только + `BillingConfigResumer`/`BillingService` через `enable`; панель никогда не является источником правды + о сроке. - Блокировка/разблокировка админом (`Disable()`/`Enable()`) — отдельная ось, управляет только `enable`, `expiresAt: null` (не трогает срок оплаты). Переименование (`EditVpnConfigCommandHandler`) — `enable`/`expiresAt: null` (раньше по ошибке форсировало `enable:true`, тем самым молча снимая