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>()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user