Enhance billing request handling to support immediate config suspension and protection
CI / Backend (build + test) (push) Successful in 1m21s
CI / Frontend (lint + typecheck + build) (push) Successful in 33s

- 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:
Leonid Pershin
2026-07-19 19:25:33 +03:00
parent 979eddf72e
commit b32756d5bc
8 changed files with 617 additions and 200 deletions
@@ -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;
}
}
@@ -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
);
}
}
}
@@ -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]
@@ -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>()
);
}
}
@@ -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
View File
@@ -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.