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.
This commit is contained in:
@@ -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;
|
||||
/// <summary>
|
||||
/// Общая синхронизация панели 3x-ui со статусом оплаты: продление (ResumeConfigsAsync), временная
|
||||
/// защита на время рассмотрения заявки админом (ProtectPendingConfigsAsync) и приостановка за неуплату
|
||||
/// (SuspendConfigsAsync). Xray проверяет expiryTime клиента сам, независимо от нашего локального
|
||||
/// статуса — поэтому "просто не гасить, пока заявка на проверке" недостаточно: если реальный
|
||||
/// BillingPaidUntil уже в прошлом, панель заблокирует клиента сама, пока мы явно не подвинем
|
||||
/// expiryTime вперёд. По той же причине везде передаём enable И expiresAt вместе (см.
|
||||
/// IXuiPanelGateway.UpdateClientAsync) — по факту эксплуатации одного enable недостаточно.
|
||||
/// (SuspendConfigsAsync). Истечение по биллингу управляется полностью на нашей стороне — единственный
|
||||
/// рычаг на панели это <c>enable</c>; <c>expiresAt</c> всегда передаётся как <see cref="NoExpiry"/>
|
||||
/// (== 0 для 3x-ui/Xray, "без срока"), чтобы панель не могла сама enforce'ить доступ по дате
|
||||
/// независимо от нашего локального статуса — это заодно сбрасывает унаследованный от старых версий
|
||||
/// реальный expiryTime, если он когда-то был запушен (создание/ротация конфига тоже больше не
|
||||
/// передают реальные даты — см. CreateVpnConfigCommandHandler/RotateVpnConfigCommandHandler).
|
||||
/// </summary>
|
||||
public static class BillingConfigResumer
|
||||
{
|
||||
/// <summary>Подтверждённая оплата/продление/гифт — реальный новый срок. Пушится на панель для
|
||||
/// ЛЮБОГО статуса конфига, не только Expired: пользователь мог доплатить/продлить заранее, пока
|
||||
/// конфиг ещё Active — панель должна узнать новый срок сразу, а не только когда конфиг реально
|
||||
/// просрочится.</summary>
|
||||
/// <summary>3x-ui/Xray трактует expiryTime == 0 как "без ограничения по сроку" — используем как
|
||||
/// явное значение "сбросить срок", а не просто not-touch (<c>null</c> в UpdateClientAsync означает
|
||||
/// "оставить как есть", что не годится для очистки унаследованных реальных дат).</summary>
|
||||
private static readonly DateTimeOffset NoExpiry = DateTimeOffset.UnixEpoch;
|
||||
|
||||
/// <summary>Подтверждённая оплата/продление/гифт. Локально фиксируем реальный новый срок
|
||||
/// (SetBillingExpiry) для отображения/Subscription-Userinfo, но на панель уходит только
|
||||
/// enable: true — панельный expiryTime истечением больше не управляет.</summary>
|
||||
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
|
||||
}
|
||||
|
||||
/// <summary>Заявка на оплату ждёт решения админа (AwaitingConfirmation) — держим клиента рабочим
|
||||
/// на панели на время рассмотрения (expiresAt = сейчас + BillingSettings.GraceDays), не трогая
|
||||
/// Status/ExpiresAt в БД — это временная мера до Confirm (ResumeConfigsAsync проставит настоящий
|
||||
/// срок) или Reject (SuspendConfigsAsync вернёт как было). Вызывается и сразу при отметке "я
|
||||
/// оплатил" (MarkPaymentSentCommandHandler), и на каждом тике BillingService, пока заявка висит —
|
||||
/// идемпотентно, каждый вызов просто продлевает грейс ещё на GraceDays от текущего момента.</summary>
|
||||
/// на панели на время рассмотрения (enable: true), не трогая Status/ExpiresAt в БД — это временная
|
||||
/// мера до Confirm (ResumeConfigsAsync проставит настоящий срок) или Reject (SuspendConfigsAsync
|
||||
/// вернёт как было). Вызывается и сразу при отметке "я оплатил" (MarkPaymentSentCommandHandler), и
|
||||
/// на каждом тике BillingService, пока заявка висит — идемпотентно.</summary>
|
||||
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
|
||||
/// <summary>Приостановка за неуплату — общая для фоновой джобы (BillingService) и немедленной
|
||||
/// реакции на отклонение заявки админом (RejectPaymentRequestCommandHandler), чтобы не ждать
|
||||
/// следующего часового тика. Проходит и по уже Expired конфигам — они могли быть временно
|
||||
/// "защищены" ProtectPendingConfigsAsync (enable/expiresAt на панели уехали вперёд, а статус в БД
|
||||
/// остался как был), и при отклонении заявки эту защиту нужно снять с панели тоже, а не только
|
||||
/// локально.</summary>
|
||||
/// "защищены" ProtectPendingConfigsAsync (enable на панели уехал в true, а статус в БД остался как
|
||||
/// был), и при отклонении заявки эту защиту нужно снять с панели тоже, а не только локально.</summary>
|
||||
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).</summary>
|
||||
private static async Task<bool> PushExpiryAsync(
|
||||
private static async Task<bool> 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
|
||||
);
|
||||
|
||||
|
||||
@@ -36,8 +36,10 @@ public interface IXuiPanelGateway
|
||||
/// Возвращает ClientExternalId, присвоенный панелью (UUID для VLESS/VMess, пароль для Trojan/Shadowsocks).
|
||||
/// <paramref name="limitIp"/> — лимит одновременных IP клиента (квота роли, см. AppRole.MaxIpLimit);
|
||||
/// -1 (RoleQuota.Unlimited) означает без лимита — гейтвей сам переводит его в нативное значение 3x-ui.
|
||||
/// <paramref name="expiresAt"/> — дата, до которой клиент активен в самой панели (billing-роли —
|
||||
/// AppUser.BillingPaidUntil на момент создания); <c>null</c> — без ограничения по сроку.
|
||||
/// <paramref name="expiresAt"/> — дата, до которой клиент активен в самой панели; <c>null</c> —
|
||||
/// без ограничения по сроку. Биллинговое истечение управляется полностью на нашей стороне через
|
||||
/// <see cref="UpdateClientAsync"/>(..., enable: false, ...) — сюда всегда передаётся <c>null</c>,
|
||||
/// чтобы панель/Xray не enforce'ил срок независимо от нашего статуса (см. BillingConfigResumer).
|
||||
/// </summary>
|
||||
Task<Result<string>> AddClientAsync(
|
||||
Node node,
|
||||
@@ -70,13 +72,16 @@ public interface IXuiPanelGateway
|
||||
/// Единственный легитимный вызывающий с непустым name — EditVpnConfigCommandHandler.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <paramref name="expiresAt"/> — используется для приостановки/возврата за неуплату
|
||||
/// (BillingService/BillingConfigResumer): дата в прошлом делает клиента просроченным для самой
|
||||
/// панели/Xray независимо от <paramref name="enable"/> (по факту тестирования — переключение
|
||||
/// enable ненадёжно останавливает уже установленные соединения, а expiryTime — надёжно), дата в
|
||||
/// будущем (новый AppUser.BillingPaidUntil) снимает приостановку. Блокировка/разблокировка
|
||||
/// админом (BlockUserCommandHandler/UnblockUserCommandHandler) — отдельная ось, передаёт
|
||||
/// <c>expiresAt: null</c> и управляет только <paramref name="enable"/>.
|
||||
/// <paramref name="expiresAt"/> — истечение по биллингу управляется полностью на нашей стороне
|
||||
/// (локальный статус конфига + <paramref name="enable"/>), а не через панельный expiryTime: Xray
|
||||
/// проверял бы его сам, независимо от <paramref name="enable"/>, что даёт панели независимый от
|
||||
/// нас источник enforcement и лишнюю точку отказа. Поэтому `BillingConfigResumer` (Resume/Protect/
|
||||
/// Suspend) всегда передаёт сюда фиксированный <c>DateTimeOffset.UnixEpoch</c> (== 0, "без срока"
|
||||
/// для 3x-ui/Xray) — это же явно сбрасывает унаследованный от старых версий реальный expiryTime,
|
||||
/// если он был запушен ранее. Блокировка/разблокировка админом
|
||||
/// (BlockUserCommandHandler/UnblockUserCommandHandler) и переименование
|
||||
/// (EditVpnConfigCommandHandler) — передают <c>expiresAt: null</c> ("не трогать"), так как не
|
||||
/// относятся к биллингу.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
Task<Result> UpdateClientAsync(
|
||||
|
||||
@@ -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
|
||||
);
|
||||
|
||||
|
||||
@@ -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
|
||||
);
|
||||
|
||||
|
||||
+6
-5
@@ -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<string>(),
|
||||
true,
|
||||
Arg.Is<DateTimeOffset?>(d => d == expiredConfig.ExpiresAt),
|
||||
Arg.Is<DateTimeOffset?>(d => d == DateTimeOffset.UnixEpoch),
|
||||
Arg.Any<CancellationToken>()
|
||||
);
|
||||
await _gateway
|
||||
@@ -217,7 +218,7 @@ public class ConfirmPaymentRequestCommandHandlerTests
|
||||
VpnProtocol.Vless,
|
||||
Arg.Any<string>(),
|
||||
true,
|
||||
Arg.Is<DateTimeOffset?>(d => d == activeConfig.ExpiresAt),
|
||||
Arg.Is<DateTimeOffset?>(d => d == DateTimeOffset.UnixEpoch),
|
||||
Arg.Any<CancellationToken>()
|
||||
);
|
||||
await _notifier.Received(1).NotifyBillingStatusChangedAsync(userId, Arg.Any<CancellationToken>());
|
||||
|
||||
+3
-1
@@ -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<string>(),
|
||||
false,
|
||||
Arg.Any<DateTimeOffset?>(),
|
||||
Arg.Is<DateTimeOffset?>(d => d == DateTimeOffset.UnixEpoch),
|
||||
Arg.Any<CancellationToken>()
|
||||
);
|
||||
// Фронт (/billing) должен узнать о смене статуса сразу, не дожидаясь опроса.
|
||||
|
||||
+14
@@ -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<Node>(),
|
||||
inbound.RemoteInboundId,
|
||||
"ext-1",
|
||||
VpnProtocol.Vless,
|
||||
Arg.Any<string>(),
|
||||
true,
|
||||
Arg.Is<DateTimeOffset?>(d => d == DateTimeOffset.UnixEpoch),
|
||||
Arg.Any<CancellationToken>()
|
||||
);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
+3
-2
@@ -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<string>(),
|
||||
true,
|
||||
Arg.Is<DateTimeOffset?>(d => d > DateTimeOffset.UtcNow),
|
||||
Arg.Is<DateTimeOffset?>(d => d == DateTimeOffset.UnixEpoch),
|
||||
Arg.Any<CancellationToken>()
|
||||
);
|
||||
await _notifier.Received(1).NotifyBillingStatusChangedAsync(userId, Arg.Any<CancellationToken>());
|
||||
|
||||
+29
-29
@@ -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`, тем самым молча снимая
|
||||
|
||||
Reference in New Issue
Block a user