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:
+50
-1
@@ -1,4 +1,5 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using PnvPanel.Application.Auth;
|
||||
using PnvPanel.Application.Billing;
|
||||
using PnvPanel.Application.Common.Interfaces;
|
||||
@@ -11,8 +12,12 @@ namespace PnvPanel.Application.Admin.Billing;
|
||||
|
||||
public sealed class RejectPaymentRequestCommandHandler(
|
||||
IAppDbContext dbContext,
|
||||
IIdentityService identityService,
|
||||
IXuiPanelGateway gateway,
|
||||
IRealtimeNotifier notifier,
|
||||
ITelegramNotifier telegramNotifier,
|
||||
ICurrentUser currentUser
|
||||
ICurrentUser currentUser,
|
||||
ILogger<RejectPaymentRequestCommandHandler> logger
|
||||
) : ICommandHandler<RejectPaymentRequestCommand, Result>
|
||||
{
|
||||
public async Task<Result> Handle(
|
||||
@@ -38,6 +43,13 @@ public sealed class RejectPaymentRequestCommandHandler(
|
||||
|
||||
request.Reject(adminId, command.Reason);
|
||||
|
||||
// Пока заявка висела на проверке, конфиги могли быть временно "защищены" на панели
|
||||
// (ProtectPendingConfigsAsync — enable/expiresAt подвинуты вперёд без изменения локального
|
||||
// статуса). Раз оплату отклонили и период всё ещё просрочен, а других Subscription-заявок на
|
||||
// проверке нет — снимаем защиту немедленно, не дожидаясь часового тика BillingService.
|
||||
if (request.Kind == PaymentRequestKind.Subscription)
|
||||
await SuspendIfStillUnpaidAsync(request.Id, request.UserId, cancellationToken);
|
||||
|
||||
dbContext.AuditLogs.Add(
|
||||
AuditLog.Create(
|
||||
adminId,
|
||||
@@ -61,4 +73,41 @@ public sealed class RejectPaymentRequestCommandHandler(
|
||||
|
||||
return Result.Success();
|
||||
}
|
||||
|
||||
private async Task SuspendIfStillUnpaidAsync(
|
||||
Guid rejectedRequestId,
|
||||
Guid userId,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var profile = await identityService.GetProfileAsync(userId, cancellationToken);
|
||||
if (profile is not { BillingEnabled: true })
|
||||
return;
|
||||
if (profile.BillingPaidUntil is { } paidUntil && paidUntil > DateTimeOffset.UtcNow)
|
||||
return;
|
||||
|
||||
// Саму отклоняемую заявку исключаем явно: request.Reject(...) уже поменял её статус на
|
||||
// Rejected в трекере EF, но до SaveChangesAsync (в конце пайплайна) в БД всё ещё лежит старое
|
||||
// значение AwaitingConfirmation — без Id-исключения запрос ниже ложно принял бы её за "ещё
|
||||
// одну" висящую заявку и никогда бы не приостанавливал конфиги.
|
||||
var hasOtherPending = await dbContext.PaymentRequests.AnyAsync(
|
||||
r =>
|
||||
r.Id != rejectedRequestId
|
||||
&& r.UserId == userId
|
||||
&& r.Kind == PaymentRequestKind.Subscription
|
||||
&& r.Status == PaymentRequestStatus.AwaitingConfirmation,
|
||||
cancellationToken
|
||||
);
|
||||
if (hasOtherPending)
|
||||
return;
|
||||
|
||||
await BillingConfigResumer.SuspendConfigsAsync(
|
||||
dbContext,
|
||||
gateway,
|
||||
notifier,
|
||||
logger,
|
||||
userId,
|
||||
cancellationToken
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,20 +1,26 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using PnvPanel.Application.Common.Interfaces;
|
||||
using PnvPanel.Domain.Billing;
|
||||
using PnvPanel.Domain.Configs;
|
||||
|
||||
namespace PnvPanel.Application.Billing;
|
||||
|
||||
/// <summary>
|
||||
/// Общая часть "продлить оплату" — возвращает в Active конфиги, приостановленные за неуплату
|
||||
/// (Expired), и синхронизирует ExpiresAt на все конфиги пользователя. Используется при подтверждении
|
||||
/// заявки на оплату, одобрении заявки на продление и выдаче гифт-дней админом — расчёт самого
|
||||
/// newPaidUntil (AddMonths для платежа, AddDays для продления/гифта) остаётся на вызывающей стороне,
|
||||
/// как и AppUser.BillingPaidUntil (см. IIdentityService.ExtendBillingPaidUntilAsync, зовётся отдельно
|
||||
/// до этого helper'а).
|
||||
/// Общая синхронизация панели 3x-ui со статусом оплаты: продление (ResumeConfigsAsync), временная
|
||||
/// защита на время рассмотрения заявки админом (ProtectPendingConfigsAsync) и приостановка за неуплату
|
||||
/// (SuspendConfigsAsync). Xray проверяет expiryTime клиента сам, независимо от нашего локального
|
||||
/// статуса — поэтому "просто не гасить, пока заявка на проверке" недостаточно: если реальный
|
||||
/// BillingPaidUntil уже в прошлом, панель заблокирует клиента сама, пока мы явно не подвинем
|
||||
/// expiryTime вперёд. По той же причине везде передаём enable И expiresAt вместе (см.
|
||||
/// IXuiPanelGateway.UpdateClientAsync) — по факту эксплуатации одного enable недостаточно.
|
||||
/// </summary>
|
||||
internal static class BillingConfigResumer
|
||||
public static class BillingConfigResumer
|
||||
{
|
||||
/// <summary>Подтверждённая оплата/продление/гифт — реальный новый срок. Пушится на панель для
|
||||
/// ЛЮБОГО статуса конфига, не только Expired: пользователь мог доплатить/продлить заранее, пока
|
||||
/// конфиг ещё Active — панель должна узнать новый срок сразу, а не только когда конфиг реально
|
||||
/// просрочится.</summary>
|
||||
public static async Task ResumeConfigsAsync(
|
||||
IAppDbContext dbContext,
|
||||
IXuiPanelGateway gateway,
|
||||
@@ -25,58 +31,24 @@ internal static class BillingConfigResumer
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var configs = await dbContext
|
||||
.VpnConfigs.Where(c =>
|
||||
c.UserId == userId
|
||||
&& (c.Status == ConfigStatus.Active || c.Status == ConfigStatus.Expired)
|
||||
)
|
||||
.ToListAsync(cancellationToken);
|
||||
var configs = await ActiveOrExpiredConfigsAsync(dbContext, userId, cancellationToken);
|
||||
|
||||
foreach (var config in configs)
|
||||
{
|
||||
if (config.Status == ConfigStatus.Expired)
|
||||
var wasExpired = config.Status == ConfigStatus.Expired;
|
||||
var pushed = await PushExpiryAsync(
|
||||
dbContext,
|
||||
gateway,
|
||||
logger,
|
||||
config,
|
||||
enable: true,
|
||||
newPaidUntil,
|
||||
"extending billing",
|
||||
cancellationToken
|
||||
);
|
||||
|
||||
if (wasExpired && pushed)
|
||||
{
|
||||
var inbound = await dbContext
|
||||
.Inbounds.AsNoTracking()
|
||||
.FirstOrDefaultAsync(i => i.Id == config.InboundId, cancellationToken);
|
||||
var node = inbound is null
|
||||
? null
|
||||
: await dbContext
|
||||
.Nodes.AsNoTracking()
|
||||
.FirstOrDefaultAsync(n => n.Id == inbound.NodeId, cancellationToken);
|
||||
|
||||
if (inbound is not null && node is not null)
|
||||
{
|
||||
// Возвращаем через expiryTime = новый newPaidUntil, а не enable:true — тот же
|
||||
// механизм приостановки, что и у BillingService (см. IXuiPanelGateway.UpdateClientAsync):
|
||||
// переписываем просроченную дату на настоящую, панель/Xray сами перестают считать
|
||||
// клиента истёкшим. name: null — не переименовываем клиента.
|
||||
var updateResult = await gateway.UpdateClientAsync(
|
||||
node,
|
||||
inbound.RemoteInboundId,
|
||||
config.ClientExternalId,
|
||||
config.Protocol,
|
||||
name: null,
|
||||
enable: null,
|
||||
expiresAt: newPaidUntil,
|
||||
cancellationToken
|
||||
);
|
||||
|
||||
if (!updateResult.IsSuccess)
|
||||
{
|
||||
// Нода недоступна — не трогаем локальный статус, подхватится следующим
|
||||
// продлением/циклом BillingService (идемпотентно).
|
||||
logger.LogWarning(
|
||||
"Failed to enable client for config {ConfigId} on node {NodeId} while extending billing for user {UserId}: {Error}",
|
||||
config.Id,
|
||||
node.Id,
|
||||
userId,
|
||||
updateResult.Error
|
||||
);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
config.Resume();
|
||||
await notifier.NotifyConfigStatusChangedAsync(
|
||||
config.UserId,
|
||||
@@ -89,4 +61,148 @@ internal static class BillingConfigResumer
|
||||
config.SetBillingExpiry(newPaidUntil);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Заявка на оплату ждёт решения админа (AwaitingConfirmation) — держим клиента рабочим
|
||||
/// на панели на время рассмотрения (expiresAt = сейчас + BillingSettings.GraceDays), не трогая
|
||||
/// Status/ExpiresAt в БД — это временная мера до Confirm (ResumeConfigsAsync проставит настоящий
|
||||
/// срок) или Reject (SuspendConfigsAsync вернёт как было). Вызывается и сразу при отметке "я
|
||||
/// оплатил" (MarkPaymentSentCommandHandler), и на каждом тике BillingService, пока заявка висит —
|
||||
/// идемпотентно, каждый вызов просто продлевает грейс ещё на GraceDays от текущего момента.</summary>
|
||||
public static async Task ProtectPendingConfigsAsync(
|
||||
IAppDbContext dbContext,
|
||||
IXuiPanelGateway gateway,
|
||||
ILogger logger,
|
||||
Guid userId,
|
||||
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(
|
||||
dbContext,
|
||||
gateway,
|
||||
logger,
|
||||
config,
|
||||
enable: true,
|
||||
graceUntil,
|
||||
"protecting pending payment for",
|
||||
cancellationToken
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Приостановка за неуплату — общая для фоновой джобы (BillingService) и немедленной
|
||||
/// реакции на отклонение заявки админом (RejectPaymentRequestCommandHandler), чтобы не ждать
|
||||
/// следующего часового тика. Проходит и по уже Expired конфигам — они могли быть временно
|
||||
/// "защищены" ProtectPendingConfigsAsync (enable/expiresAt на панели уехали вперёд, а статус в БД
|
||||
/// остался как был), и при отклонении заявки эту защиту нужно снять с панели тоже, а не только
|
||||
/// локально.</summary>
|
||||
public static async Task SuspendConfigsAsync(
|
||||
IAppDbContext dbContext,
|
||||
IXuiPanelGateway gateway,
|
||||
IRealtimeNotifier notifier,
|
||||
ILogger logger,
|
||||
Guid userId,
|
||||
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(
|
||||
dbContext,
|
||||
gateway,
|
||||
logger,
|
||||
config,
|
||||
enable: false,
|
||||
expiredAt,
|
||||
"suspending for non-payment",
|
||||
cancellationToken
|
||||
);
|
||||
|
||||
if (!wasActive || !pushed)
|
||||
continue;
|
||||
|
||||
config.Suspend();
|
||||
await notifier.NotifyConfigStatusChangedAsync(
|
||||
config.UserId,
|
||||
config.Id,
|
||||
config.Status,
|
||||
cancellationToken
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private static Task<List<VpnConfig>> ActiveOrExpiredConfigsAsync(
|
||||
IAppDbContext dbContext,
|
||||
Guid userId,
|
||||
CancellationToken cancellationToken
|
||||
) =>
|
||||
dbContext
|
||||
.VpnConfigs.Where(c =>
|
||||
c.UserId == userId
|
||||
&& (c.Status == ConfigStatus.Active || c.Status == ConfigStatus.Expired)
|
||||
)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
/// <summary>Возвращает false только если панель реально отвергла обновление (нода недоступна и
|
||||
/// т.п.) — вызывающий тогда пропускает смену локального статуса, чтобы не разойтись с панелью
|
||||
/// (подхватится следующим циклом/событием). Если у конфига нет инбаунда/ноды вовсе — на панели
|
||||
/// нечего обновлять, но локальный статус менять можно (как и раньше в BillingService/
|
||||
/// BillingConfigResumer).</summary>
|
||||
private static async Task<bool> PushExpiryAsync(
|
||||
IAppDbContext dbContext,
|
||||
IXuiPanelGateway gateway,
|
||||
ILogger logger,
|
||||
VpnConfig config,
|
||||
bool enable,
|
||||
DateTimeOffset expiresAt,
|
||||
string action,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var inbound = await dbContext
|
||||
.Inbounds.AsNoTracking()
|
||||
.FirstOrDefaultAsync(i => i.Id == config.InboundId, cancellationToken);
|
||||
var node = inbound is null
|
||||
? null
|
||||
: await dbContext
|
||||
.Nodes.AsNoTracking()
|
||||
.FirstOrDefaultAsync(n => n.Id == inbound.NodeId, cancellationToken);
|
||||
|
||||
if (inbound is null || node is null)
|
||||
return true;
|
||||
|
||||
var result = await gateway.UpdateClientAsync(
|
||||
node,
|
||||
inbound.RemoteInboundId,
|
||||
config.ClientExternalId,
|
||||
config.Protocol,
|
||||
name: null,
|
||||
enable,
|
||||
expiresAt,
|
||||
cancellationToken
|
||||
);
|
||||
|
||||
if (result.IsSuccess)
|
||||
return true;
|
||||
|
||||
logger.LogWarning(
|
||||
"Failed to update client for config {ConfigId} on node {NodeId} while {Action} user {UserId}: {Error}",
|
||||
config.Id,
|
||||
node.Id,
|
||||
action,
|
||||
config.UserId,
|
||||
result.Error
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
+23
-1
@@ -1,4 +1,5 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using PnvPanel.Application.Auth;
|
||||
using PnvPanel.Application.Common.Interfaces;
|
||||
using PnvPanel.Application.Common.Messaging;
|
||||
@@ -10,8 +11,10 @@ namespace PnvPanel.Application.Billing.MarkPaymentSent;
|
||||
public sealed class MarkPaymentSentCommandHandler(
|
||||
IAppDbContext dbContext,
|
||||
IIdentityService identityService,
|
||||
IXuiPanelGateway gateway,
|
||||
ITelegramNotifier telegramNotifier,
|
||||
ICurrentUser currentUser
|
||||
ICurrentUser currentUser,
|
||||
ILogger<MarkPaymentSentCommandHandler> logger
|
||||
) : ICommandHandler<MarkPaymentSentCommand, Result>
|
||||
{
|
||||
public async Task<Result> Handle(MarkPaymentSentCommand command, CancellationToken cancellationToken)
|
||||
@@ -32,6 +35,25 @@ public sealed class MarkPaymentSentCommandHandler(
|
||||
request.MarkPaymentSent();
|
||||
|
||||
var profile = await identityService.GetProfileAsync(userId, cancellationToken);
|
||||
|
||||
// Оплаченный период уже мог истечь, пока пользователь собирался заплатить — не ждём часовой
|
||||
// тик BillingService, сразу держим клиента рабочим на панели на время рассмотрения заявки
|
||||
// (см. BillingConfigResumer.ProtectPendingConfigsAsync). Только для Subscription — доплата за
|
||||
// смену роли (RoleChangeTopUp) на приостановку не влияет.
|
||||
if (
|
||||
request.Kind == PaymentRequestKind.Subscription
|
||||
&& (profile?.BillingPaidUntil is null || profile.BillingPaidUntil < DateTimeOffset.UtcNow)
|
||||
)
|
||||
{
|
||||
await BillingConfigResumer.ProtectPendingConfigsAsync(
|
||||
dbContext,
|
||||
gateway,
|
||||
logger,
|
||||
userId,
|
||||
cancellationToken
|
||||
);
|
||||
}
|
||||
|
||||
await telegramNotifier.NotifyAdminsPaymentRequestedAsync(
|
||||
request.Id,
|
||||
profile?.UserName ?? userId.ToString(),
|
||||
|
||||
@@ -2,10 +2,10 @@ using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using PnvPanel.Application.Billing;
|
||||
using PnvPanel.Application.Common.Interfaces;
|
||||
using PnvPanel.Domain.Audit;
|
||||
using PnvPanel.Domain.Billing;
|
||||
using PnvPanel.Domain.Configs;
|
||||
using PnvPanel.Infrastructure.Persistence;
|
||||
|
||||
namespace PnvPanel.Infrastructure.BackgroundJobs;
|
||||
@@ -13,9 +13,10 @@ namespace PnvPanel.Infrastructure.BackgroundJobs;
|
||||
/// <summary>
|
||||
/// Обходит пользователей с billing-ролью: гасит конфиги, у кого истёк оплаченный период (Active →
|
||||
/// Expired, отдельно от блокировки админом — см. VpnConfig.Suspend), и шлёт предупреждение за 3 дня
|
||||
/// до истечения. Пользователь с заявкой на оплату в AwaitingConfirmation не трогается вообще — пока
|
||||
/// админ не подтвердит/отклонит, приостановка не наступает (не по вине пользователя, что админ не
|
||||
/// успел проверить оплату).
|
||||
/// до истечения. Пользователь с Subscription-заявкой на оплату в AwaitingConfirmation не гасится —
|
||||
/// вместо этого на каждом тике "защищается" на панели (BillingConfigResumer.ProtectPendingConfigsAsync,
|
||||
/// см. её XML-doc для причины) до тех пор, пока админ не подтвердит/отклонит (не по вине пользователя,
|
||||
/// что админ не успел проверить оплату).
|
||||
/// </summary>
|
||||
public sealed class BillingService(
|
||||
IServiceScopeFactory scopeFactory,
|
||||
@@ -55,20 +56,44 @@ public sealed class BillingService(
|
||||
|
||||
foreach (var user in billingUsers)
|
||||
{
|
||||
// Заявка ждёт решения админа — не гасим конфиги, пока он не подтвердит/отклонит (см.
|
||||
// ConfirmPaymentRequestCommandHandler/RejectPaymentRequestCommandHandler).
|
||||
// Subscription-заявка ждёт решения админа — не гасим конфиги, пока он не подтвердит/
|
||||
// отклонит, но и не просто "ничего не делаем": держим клиента рабочим на панели (Xray
|
||||
// проверяет expiryTime сам, независимо от нашего статуса) — см.
|
||||
// ConfirmPaymentRequestCommandHandler/RejectPaymentRequestCommandHandler и
|
||||
// BillingConfigResumer.ProtectPendingConfigsAsync. RoleChangeTopUp намеренно не учитывается
|
||||
// здесь — это доплата за апгрейд роли, а не оплата подписки, её ожидание не должно
|
||||
// спасать от приостановки за реально просроченную подписку.
|
||||
var hasAwaitingConfirmation = await dbContext.PaymentRequests.AnyAsync(
|
||||
r => r.UserId == user.UserId && r.Status == PaymentRequestStatus.AwaitingConfirmation,
|
||||
r =>
|
||||
r.UserId == user.UserId
|
||||
&& r.Kind == PaymentRequestKind.Subscription
|
||||
&& r.Status == PaymentRequestStatus.AwaitingConfirmation,
|
||||
cancellationToken
|
||||
);
|
||||
if (hasAwaitingConfirmation)
|
||||
{
|
||||
await BillingConfigResumer.ProtectPendingConfigsAsync(
|
||||
dbContext,
|
||||
gateway,
|
||||
logger,
|
||||
user.UserId,
|
||||
cancellationToken
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (user.PaidUntil is not { } paidUntil || paidUntil <= now)
|
||||
{
|
||||
// Идемпотентно гасим конфиги на каждом тике (самовосстановление после временной
|
||||
// недоступности ноды), но уведомление/аудит/флаг — только один раз, при первом обнаружении.
|
||||
await DisableActiveConfigsAsync(user.UserId, dbContext, gateway, notifier, cancellationToken);
|
||||
await BillingConfigResumer.SuspendConfigsAsync(
|
||||
dbContext,
|
||||
gateway,
|
||||
notifier,
|
||||
logger,
|
||||
user.UserId,
|
||||
cancellationToken
|
||||
);
|
||||
|
||||
if (!user.Suspended)
|
||||
{
|
||||
@@ -113,68 +138,4 @@ public sealed class BillingService(
|
||||
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
private async Task DisableActiveConfigsAsync(
|
||||
Guid userId,
|
||||
AppDbContext dbContext,
|
||||
IXuiPanelGateway gateway,
|
||||
IRealtimeNotifier notifier,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var configs = await dbContext
|
||||
.VpnConfigs.Where(c => c.UserId == userId && c.Status == ConfigStatus.Active)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
foreach (var config in configs)
|
||||
{
|
||||
var inbound = await dbContext
|
||||
.Inbounds.AsNoTracking()
|
||||
.FirstOrDefaultAsync(i => i.Id == config.InboundId, cancellationToken);
|
||||
var node = inbound is null
|
||||
? null
|
||||
: await dbContext
|
||||
.Nodes.AsNoTracking()
|
||||
.FirstOrDefaultAsync(n => n.Id == inbound.NodeId, cancellationToken);
|
||||
|
||||
if (inbound is not null && node is not null)
|
||||
{
|
||||
// Приостанавливаем через expiryTime в прошлом, а не enable:false — переключение enable
|
||||
// ненадёжно останавливает уже установленные соединения на стороне Xray/панели (см.
|
||||
// IXuiPanelGateway.UpdateClientAsync), просроченный expiryTime — надёжно и независимо
|
||||
// от enable. name: null — не переименовываем клиента.
|
||||
var updateResult = await gateway.UpdateClientAsync(
|
||||
node,
|
||||
inbound.RemoteInboundId,
|
||||
config.ClientExternalId,
|
||||
config.Protocol,
|
||||
name: null,
|
||||
enable: null,
|
||||
expiresAt: DateTimeOffset.UtcNow.AddDays(-1),
|
||||
cancellationToken
|
||||
);
|
||||
|
||||
if (!updateResult.IsSuccess)
|
||||
{
|
||||
// Нода недоступна — конфиг остаётся Active, подхватится следующим циклом.
|
||||
logger.LogWarning(
|
||||
"Failed to disable client for config {ConfigId} on node {NodeId} while suspending user {UserId} for non-payment: {Error}",
|
||||
config.Id,
|
||||
node.Id,
|
||||
userId,
|
||||
updateResult.Error
|
||||
);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
config.Suspend();
|
||||
await notifier.NotifyConfigStatusChangedAsync(
|
||||
config.UserId,
|
||||
config.Id,
|
||||
config.Status,
|
||||
cancellationToken
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+16
-3
@@ -193,8 +193,9 @@ public class ConfirmPaymentRequestCommandHandlerTests
|
||||
Assert.NotNull(expiredConfig.ExpiresAt);
|
||||
Assert.NotNull(activeConfig.ExpiresAt);
|
||||
Assert.Equal(expiredConfig.ExpiresAt, activeConfig.ExpiresAt);
|
||||
// enable: null — возврат из приостановки теперь идёт через expiresAt (новый newPaidUntil),
|
||||
// а не через переключение enable (см. IXuiPanelGateway.UpdateClientAsync).
|
||||
// enable:true И expiresAt=новый newPaidUntil вместе (см. IXuiPanelGateway.UpdateClientAsync) —
|
||||
// и для ранее Expired конфига (ext-1), и для уже Active (ext-2): пользователь мог доплатить
|
||||
// заранее, панель должна узнать новый срок сразу, а не только когда конфиг реально просрочится.
|
||||
await _gateway
|
||||
.Received(1)
|
||||
.UpdateClientAsync(
|
||||
@@ -203,10 +204,22 @@ public class ConfirmPaymentRequestCommandHandlerTests
|
||||
"ext-1",
|
||||
VpnProtocol.Vless,
|
||||
Arg.Any<string>(),
|
||||
null,
|
||||
true,
|
||||
Arg.Is<DateTimeOffset?>(d => d == expiredConfig.ExpiresAt),
|
||||
Arg.Any<CancellationToken>()
|
||||
);
|
||||
await _gateway
|
||||
.Received(1)
|
||||
.UpdateClientAsync(
|
||||
Arg.Any<Node>(),
|
||||
inbound.RemoteInboundId,
|
||||
"ext-2",
|
||||
VpnProtocol.Vless,
|
||||
Arg.Any<string>(),
|
||||
true,
|
||||
Arg.Is<DateTimeOffset?>(d => d == activeConfig.ExpiresAt),
|
||||
Arg.Any<CancellationToken>()
|
||||
);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
+152
-20
@@ -1,16 +1,54 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using NSubstitute;
|
||||
using PnvPanel.Application.Admin.Billing;
|
||||
using PnvPanel.Application.Billing;
|
||||
using PnvPanel.Application.Common.Interfaces;
|
||||
using PnvPanel.Application.Common.Models;
|
||||
using PnvPanel.Application.Tests.TestSupport;
|
||||
using PnvPanel.Domain.Billing;
|
||||
using PnvPanel.Domain.Configs;
|
||||
using PnvPanel.Domain.Inbounds;
|
||||
using PnvPanel.Domain.Nodes;
|
||||
using Xunit;
|
||||
|
||||
namespace PnvPanel.Application.Tests.Admin.Billing;
|
||||
|
||||
public class RejectPaymentRequestCommandHandlerTests
|
||||
{
|
||||
private readonly IIdentityService _identityService = Substitute.For<IIdentityService>();
|
||||
private readonly IXuiPanelGateway _gateway = Substitute.For<IXuiPanelGateway>();
|
||||
private readonly IRealtimeNotifier _notifier = Substitute.For<IRealtimeNotifier>();
|
||||
private readonly ITelegramNotifier _telegramNotifier = Substitute.For<ITelegramNotifier>();
|
||||
private readonly ILogger<RejectPaymentRequestCommandHandler> _logger = Substitute.For<
|
||||
ILogger<RejectPaymentRequestCommandHandler>
|
||||
>();
|
||||
|
||||
private RejectPaymentRequestCommandHandler CreateHandler(IAppDbContext dbContext, Guid adminId) =>
|
||||
new(
|
||||
dbContext,
|
||||
_identityService,
|
||||
_gateway,
|
||||
_notifier,
|
||||
_telegramNotifier,
|
||||
FakeCurrentUser.Authenticated(adminId, "admin"),
|
||||
_logger
|
||||
);
|
||||
|
||||
private static CurrentUserProfile Profile(Guid userId, DateTimeOffset? paidUntil) =>
|
||||
new(
|
||||
userId,
|
||||
"alice",
|
||||
Guid.NewGuid(),
|
||||
"premium",
|
||||
IsActivated: true,
|
||||
IsBlocked: false,
|
||||
MaxConfigs: 5,
|
||||
MaxIpLimit: 3,
|
||||
SubscriptionToken: "sub-token",
|
||||
BillingEnabled: true,
|
||||
BillingPaidUntil: paidUntil,
|
||||
BillingSuspended: false
|
||||
);
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_WhenAwaitingConfirmation_RejectsAndNotifiesUser()
|
||||
@@ -23,16 +61,8 @@ public class RejectPaymentRequestCommandHandlerTests
|
||||
dbContext.PaymentRequests.Add(request);
|
||||
await dbContext.SaveChangesAsync(CancellationToken.None);
|
||||
|
||||
var handler = new RejectPaymentRequestCommandHandler(
|
||||
dbContext,
|
||||
_telegramNotifier,
|
||||
FakeCurrentUser.Authenticated(adminId, "admin")
|
||||
);
|
||||
|
||||
var result = await handler.Handle(
|
||||
new RejectPaymentRequestCommand(request.Id, "Платёж не найден"),
|
||||
CancellationToken.None
|
||||
);
|
||||
var result = await CreateHandler(dbContext, adminId)
|
||||
.Handle(new RejectPaymentRequestCommand(request.Id, "Платёж не найден"), CancellationToken.None);
|
||||
|
||||
Assert.True(result.IsSuccess);
|
||||
Assert.Equal(PaymentRequestStatus.Rejected, request.Status);
|
||||
@@ -52,18 +82,120 @@ public class RejectPaymentRequestCommandHandlerTests
|
||||
{
|
||||
using var dbContext = InMemoryDbContextFactory.Create();
|
||||
|
||||
var handler = new RejectPaymentRequestCommandHandler(
|
||||
dbContext,
|
||||
_telegramNotifier,
|
||||
FakeCurrentUser.Authenticated(Guid.NewGuid(), "admin")
|
||||
);
|
||||
|
||||
var result = await handler.Handle(
|
||||
new RejectPaymentRequestCommand(Guid.NewGuid(), null),
|
||||
CancellationToken.None
|
||||
);
|
||||
var result = await CreateHandler(dbContext, Guid.NewGuid())
|
||||
.Handle(new RejectPaymentRequestCommand(Guid.NewGuid(), null), CancellationToken.None);
|
||||
|
||||
Assert.False(result.IsSuccess);
|
||||
Assert.Equal(BillingErrors.RequestNotFound, result.Error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_WhenStillUnpaidAndNoOtherPendingRequest_SuspendsConfigsImmediately()
|
||||
{
|
||||
using var dbContext = InMemoryDbContextFactory.Create();
|
||||
var adminId = Guid.NewGuid();
|
||||
var userId = Guid.NewGuid();
|
||||
|
||||
var node = Node.Register(
|
||||
"node-1",
|
||||
new Uri("https://node1.example.com"),
|
||||
new NodeCredentials("admin", "protected"),
|
||||
null
|
||||
);
|
||||
var inbound = Inbound.FromRemote(node.Id, "1", VpnProtocol.Vless, "remark", 443);
|
||||
var config = VpnConfig.Create(userId, inbound.Id, VpnProtocol.Vless, null);
|
||||
config.AssignRemoteClient("ext-1");
|
||||
dbContext.Nodes.Add(node);
|
||||
dbContext.Inbounds.Add(inbound);
|
||||
dbContext.VpnConfigs.Add(config);
|
||||
|
||||
var request = PaymentRequest.Create(userId, PaymentPeriod.Quarter, 1500);
|
||||
request.MarkPaymentSent();
|
||||
dbContext.PaymentRequests.Add(request);
|
||||
await dbContext.SaveChangesAsync(CancellationToken.None);
|
||||
|
||||
_identityService
|
||||
.GetProfileAsync(userId, Arg.Any<CancellationToken>())
|
||||
.Returns(Profile(userId, paidUntil: DateTimeOffset.UtcNow.AddDays(-1)));
|
||||
_gateway
|
||||
.UpdateClientAsync(
|
||||
Arg.Any<Node>(),
|
||||
Arg.Any<string>(),
|
||||
Arg.Any<string>(),
|
||||
Arg.Any<VpnProtocol>(),
|
||||
Arg.Any<string>(),
|
||||
Arg.Any<bool?>(),
|
||||
Arg.Any<DateTimeOffset?>(),
|
||||
Arg.Any<CancellationToken>()
|
||||
)
|
||||
.Returns(Result.Success());
|
||||
|
||||
var result = await CreateHandler(dbContext, adminId)
|
||||
.Handle(new RejectPaymentRequestCommand(request.Id, null), CancellationToken.None);
|
||||
|
||||
Assert.True(result.IsSuccess);
|
||||
Assert.Equal(ConfigStatus.Expired, config.Status);
|
||||
await _gateway
|
||||
.Received(1)
|
||||
.UpdateClientAsync(
|
||||
Arg.Any<Node>(),
|
||||
inbound.RemoteInboundId,
|
||||
"ext-1",
|
||||
VpnProtocol.Vless,
|
||||
Arg.Any<string>(),
|
||||
false,
|
||||
Arg.Any<DateTimeOffset?>(),
|
||||
Arg.Any<CancellationToken>()
|
||||
);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_WhenAnotherSubscriptionRequestStillPending_DoesNotSuspend()
|
||||
{
|
||||
using var dbContext = InMemoryDbContextFactory.Create();
|
||||
var adminId = Guid.NewGuid();
|
||||
var userId = Guid.NewGuid();
|
||||
|
||||
var node = Node.Register(
|
||||
"node-1",
|
||||
new Uri("https://node1.example.com"),
|
||||
new NodeCredentials("admin", "protected"),
|
||||
null
|
||||
);
|
||||
var inbound = Inbound.FromRemote(node.Id, "1", VpnProtocol.Vless, "remark", 443);
|
||||
var config = VpnConfig.Create(userId, inbound.Id, VpnProtocol.Vless, null);
|
||||
config.AssignRemoteClient("ext-1");
|
||||
dbContext.Nodes.Add(node);
|
||||
dbContext.Inbounds.Add(inbound);
|
||||
dbContext.VpnConfigs.Add(config);
|
||||
|
||||
var request = PaymentRequest.Create(userId, PaymentPeriod.Quarter, 1500);
|
||||
request.MarkPaymentSent();
|
||||
var otherRequest = PaymentRequest.Create(userId, PaymentPeriod.Quarter, 1500);
|
||||
otherRequest.MarkPaymentSent();
|
||||
dbContext.PaymentRequests.AddRange(request, otherRequest);
|
||||
await dbContext.SaveChangesAsync(CancellationToken.None);
|
||||
|
||||
_identityService
|
||||
.GetProfileAsync(userId, Arg.Any<CancellationToken>())
|
||||
.Returns(Profile(userId, paidUntil: DateTimeOffset.UtcNow.AddDays(-1)));
|
||||
|
||||
var result = await CreateHandler(dbContext, adminId)
|
||||
.Handle(new RejectPaymentRequestCommand(request.Id, null), CancellationToken.None);
|
||||
|
||||
Assert.True(result.IsSuccess);
|
||||
Assert.Equal(ConfigStatus.Active, config.Status);
|
||||
await _gateway
|
||||
.DidNotReceive()
|
||||
.UpdateClientAsync(
|
||||
Arg.Any<Node>(),
|
||||
Arg.Any<string>(),
|
||||
Arg.Any<string>(),
|
||||
Arg.Any<VpnProtocol>(),
|
||||
Arg.Any<string>(),
|
||||
Arg.Any<bool?>(),
|
||||
Arg.Any<DateTimeOffset?>(),
|
||||
Arg.Any<CancellationToken>()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+120
-22
@@ -1,9 +1,13 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using NSubstitute;
|
||||
using PnvPanel.Application.Billing;
|
||||
using PnvPanel.Application.Billing.MarkPaymentSent;
|
||||
using PnvPanel.Application.Common.Interfaces;
|
||||
using PnvPanel.Application.Common.Models;
|
||||
using PnvPanel.Application.Tests.TestSupport;
|
||||
using PnvPanel.Domain.Billing;
|
||||
using PnvPanel.Domain.Inbounds;
|
||||
using PnvPanel.Domain.Nodes;
|
||||
using Xunit;
|
||||
|
||||
namespace PnvPanel.Application.Tests.Billing.MarkPaymentSent;
|
||||
@@ -11,7 +15,30 @@ namespace PnvPanel.Application.Tests.Billing.MarkPaymentSent;
|
||||
public class MarkPaymentSentCommandHandlerTests
|
||||
{
|
||||
private readonly IIdentityService _identityService = Substitute.For<IIdentityService>();
|
||||
private readonly IXuiPanelGateway _gateway = Substitute.For<IXuiPanelGateway>();
|
||||
private readonly ITelegramNotifier _telegramNotifier = Substitute.For<ITelegramNotifier>();
|
||||
private readonly ILogger<MarkPaymentSentCommandHandler> _logger = Substitute.For<
|
||||
ILogger<MarkPaymentSentCommandHandler>
|
||||
>();
|
||||
|
||||
private MarkPaymentSentCommandHandler CreateHandler(IAppDbContext dbContext, Guid userId) =>
|
||||
new(dbContext, _identityService, _gateway, _telegramNotifier, FakeCurrentUser.Authenticated(userId, "alice"), _logger);
|
||||
|
||||
private static CurrentUserProfile Profile(Guid userId, DateTimeOffset? paidUntil) =>
|
||||
new(
|
||||
userId,
|
||||
"alice",
|
||||
Guid.NewGuid(),
|
||||
"premium",
|
||||
IsActivated: true,
|
||||
IsBlocked: false,
|
||||
MaxConfigs: 5,
|
||||
MaxIpLimit: 3,
|
||||
SubscriptionToken: "sub-token",
|
||||
BillingEnabled: true,
|
||||
BillingPaidUntil: paidUntil,
|
||||
BillingSuspended: false
|
||||
);
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_WhenAwaitingPayment_MovesToAwaitingConfirmationAndNotifiesAdmins()
|
||||
@@ -22,17 +49,8 @@ public class MarkPaymentSentCommandHandlerTests
|
||||
dbContext.PaymentRequests.Add(request);
|
||||
await dbContext.SaveChangesAsync(CancellationToken.None);
|
||||
|
||||
var handler = new MarkPaymentSentCommandHandler(
|
||||
dbContext,
|
||||
_identityService,
|
||||
_telegramNotifier,
|
||||
FakeCurrentUser.Authenticated(userId, "alice")
|
||||
);
|
||||
|
||||
var result = await handler.Handle(
|
||||
new MarkPaymentSentCommand(request.Id),
|
||||
CancellationToken.None
|
||||
);
|
||||
var result = await CreateHandler(dbContext, userId)
|
||||
.Handle(new MarkPaymentSentCommand(request.Id), CancellationToken.None);
|
||||
|
||||
Assert.True(result.IsSuccess);
|
||||
Assert.Equal(PaymentRequestStatus.AwaitingConfirmation, request.Status);
|
||||
@@ -58,19 +76,99 @@ public class MarkPaymentSentCommandHandlerTests
|
||||
dbContext.PaymentRequests.Add(request);
|
||||
await dbContext.SaveChangesAsync(CancellationToken.None);
|
||||
|
||||
var handler = new MarkPaymentSentCommandHandler(
|
||||
dbContext,
|
||||
_identityService,
|
||||
_telegramNotifier,
|
||||
FakeCurrentUser.Authenticated(userId)
|
||||
);
|
||||
|
||||
var result = await handler.Handle(
|
||||
new MarkPaymentSentCommand(request.Id),
|
||||
CancellationToken.None
|
||||
);
|
||||
var result = await CreateHandler(dbContext, userId)
|
||||
.Handle(new MarkPaymentSentCommand(request.Id), CancellationToken.None);
|
||||
|
||||
Assert.False(result.IsSuccess);
|
||||
Assert.Equal(BillingErrors.RequestNotAwaitingPayment, result.Error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_WhenPaidUntilAlreadyExpired_ProtectsConfigsOnPanel()
|
||||
{
|
||||
using var dbContext = InMemoryDbContextFactory.Create();
|
||||
var userId = Guid.NewGuid();
|
||||
|
||||
var node = Node.Register(
|
||||
"node-1",
|
||||
new Uri("https://node1.example.com"),
|
||||
new NodeCredentials("admin", "protected"),
|
||||
null
|
||||
);
|
||||
var inbound = Inbound.FromRemote(node.Id, "1", VpnProtocol.Vless, "remark", 443);
|
||||
var config = Domain.Configs.VpnConfig.Create(userId, inbound.Id, VpnProtocol.Vless, null);
|
||||
config.AssignRemoteClient("ext-1");
|
||||
dbContext.Nodes.Add(node);
|
||||
dbContext.Inbounds.Add(inbound);
|
||||
dbContext.VpnConfigs.Add(config);
|
||||
|
||||
var request = PaymentRequest.Create(userId, PaymentPeriod.Quarter, 1500);
|
||||
dbContext.PaymentRequests.Add(request);
|
||||
await dbContext.SaveChangesAsync(CancellationToken.None);
|
||||
|
||||
_identityService
|
||||
.GetProfileAsync(userId, Arg.Any<CancellationToken>())
|
||||
.Returns(Profile(userId, paidUntil: DateTimeOffset.UtcNow.AddDays(-1)));
|
||||
_gateway
|
||||
.UpdateClientAsync(
|
||||
Arg.Any<Node>(),
|
||||
Arg.Any<string>(),
|
||||
Arg.Any<string>(),
|
||||
Arg.Any<VpnProtocol>(),
|
||||
Arg.Any<string>(),
|
||||
Arg.Any<bool?>(),
|
||||
Arg.Any<DateTimeOffset?>(),
|
||||
Arg.Any<CancellationToken>()
|
||||
)
|
||||
.Returns(Result.Success());
|
||||
|
||||
var result = await CreateHandler(dbContext, userId)
|
||||
.Handle(new MarkPaymentSentCommand(request.Id), CancellationToken.None);
|
||||
|
||||
Assert.True(result.IsSuccess);
|
||||
// enable:true + expiresAt в будущем — держим клиента рабочим на панели, пока заявка на проверке.
|
||||
await _gateway
|
||||
.Received(1)
|
||||
.UpdateClientAsync(
|
||||
Arg.Any<Node>(),
|
||||
inbound.RemoteInboundId,
|
||||
"ext-1",
|
||||
VpnProtocol.Vless,
|
||||
Arg.Any<string>(),
|
||||
true,
|
||||
Arg.Is<DateTimeOffset?>(d => d > DateTimeOffset.UtcNow),
|
||||
Arg.Any<CancellationToken>()
|
||||
);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_WhenPaidUntilStillInFuture_DoesNotTouchGateway()
|
||||
{
|
||||
using var dbContext = InMemoryDbContextFactory.Create();
|
||||
var userId = Guid.NewGuid();
|
||||
var request = PaymentRequest.Create(userId, PaymentPeriod.Quarter, 1500);
|
||||
dbContext.PaymentRequests.Add(request);
|
||||
await dbContext.SaveChangesAsync(CancellationToken.None);
|
||||
|
||||
_identityService
|
||||
.GetProfileAsync(userId, Arg.Any<CancellationToken>())
|
||||
.Returns(Profile(userId, paidUntil: DateTimeOffset.UtcNow.AddDays(10)));
|
||||
|
||||
var result = await CreateHandler(dbContext, userId)
|
||||
.Handle(new MarkPaymentSentCommand(request.Id), CancellationToken.None);
|
||||
|
||||
Assert.True(result.IsSuccess);
|
||||
await _gateway
|
||||
.DidNotReceive()
|
||||
.UpdateClientAsync(
|
||||
Arg.Any<Node>(),
|
||||
Arg.Any<string>(),
|
||||
Arg.Any<string>(),
|
||||
Arg.Any<VpnProtocol>(),
|
||||
Arg.Any<string>(),
|
||||
Arg.Any<bool?>(),
|
||||
Arg.Any<DateTimeOffset?>(),
|
||||
Arg.Any<CancellationToken>()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+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