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`, тем самым молча снимая