Implement billing status notification and enhance user management integration
CI / Backend (build + test) (push) Successful in 1m30s
CI / Frontend (lint + typecheck + build) (push) Successful in 33s

- Added `NotifyBillingStatusChangedAsync` method to `IRealtimeNotifier` for notifying clients about changes in billing status.
- Updated `BillingConfigResumer` to call the new notification method after modifying billing configurations, ensuring users receive real-time updates.
- Enhanced `ListUsersQueryHandler` to include a `BillingPendingReview` property in `UserSummaryDto`, indicating if a user has a pending payment request awaiting confirmation.
- Refactored various command handlers to utilize `AdvisoryLock` for managing concurrent requests, preventing race conditions in billing operations.
- Updated tests to cover new notification behaviors and ensure proper functionality in billing status management.
This commit is contained in:
Leonid Pershin
2026-07-19 23:22:57 +03:00
parent b32756d5bc
commit e19860ba46
49 changed files with 1195 additions and 233 deletions
@@ -220,6 +220,7 @@ public class ConfirmPaymentRequestCommandHandlerTests
Arg.Is<DateTimeOffset?>(d => d == activeConfig.ExpiresAt),
Arg.Any<CancellationToken>()
);
await _notifier.Received(1).NotifyBillingStatusChangedAsync(userId, Arg.Any<CancellationToken>());
}
[Fact]
@@ -147,6 +147,8 @@ public class RejectPaymentRequestCommandHandlerTests
Arg.Any<DateTimeOffset?>(),
Arg.Any<CancellationToken>()
);
// Фронт (/billing) должен узнать о смене статуса сразу, не дожидаясь опроса.
await _notifier.Received(1).NotifyBillingStatusChangedAsync(userId, Arg.Any<CancellationToken>());
}
[Fact]
@@ -1,6 +1,7 @@
using NSubstitute;
using PnvPanel.Application.Admin.Support;
using PnvPanel.Application.Common.Interfaces;
using PnvPanel.Application.Support;
using PnvPanel.Application.Tests.TestSupport;
using PnvPanel.Domain.Support;
using Xunit;
@@ -65,4 +66,31 @@ public class CloseTicketCommandHandlerTests
Assert.True(result.IsSuccess);
Assert.Equal(TicketStatus.Closed, ticket.Status);
}
[Fact]
public async Task Handle_WhenExtensionRequest_ReturnsOnlyBugReportCanBeClosedDirectly()
{
using var dbContext = InMemoryDbContextFactory.Create();
var userId = Guid.NewGuid();
var ticket = SupportTicket.CreateExtensionRequest(userId, 30);
dbContext.SupportTickets.Add(ticket);
await dbContext.SaveChangesAsync(CancellationToken.None);
var currentUser = FakeCurrentUser.Authenticated(Guid.NewGuid(), "admin");
var handler = new CloseTicketCommandHandler(
dbContext,
_notifier,
_telegramNotifier,
currentUser
);
var result = await handler.Handle(
new CloseTicketCommand(ticket.Id),
CancellationToken.None
);
Assert.False(result.IsSuccess);
Assert.Equal(SupportErrors.OnlyBugReportCanBeClosedDirectly, result.Error);
Assert.Equal(TicketStatus.Open, ticket.Status);
}
}
@@ -1,6 +1,7 @@
using NSubstitute;
using PnvPanel.Application.Admin.Support;
using PnvPanel.Application.Common.Interfaces;
using PnvPanel.Application.Support;
using PnvPanel.Application.Tests.TestSupport;
using PnvPanel.Domain.Support;
using Xunit;
@@ -65,4 +66,34 @@ public class ResolveTicketCommandHandlerTests
Assert.True(result.IsSuccess);
Assert.Equal(TicketStatus.Resolved, ticket.Status);
}
[Fact]
public async Task Handle_WhenRoleRequest_ReturnsOnlyBugReportCanBeResolvedDirectly()
{
// Заявку на роль/продление можно решить только через Approve/Reject — Resolve обходил бы
// одобрение (роль/продление так и не назначились бы), см. CLAUDE.md.
using var dbContext = InMemoryDbContextFactory.Create();
var userId = Guid.NewGuid();
var roleId = Guid.NewGuid();
var ticket = SupportTicket.CreateRoleRequestForExistingRole(userId, roleId);
dbContext.SupportTickets.Add(ticket);
await dbContext.SaveChangesAsync(CancellationToken.None);
var currentUser = FakeCurrentUser.Authenticated(Guid.NewGuid(), "admin");
var handler = new ResolveTicketCommandHandler(
dbContext,
_notifier,
_telegramNotifier,
currentUser
);
var result = await handler.Handle(
new ResolveTicketCommand(ticket.Id),
CancellationToken.None
);
Assert.False(result.IsSuccess);
Assert.Equal(SupportErrors.OnlyBugReportCanBeResolvedDirectly, result.Error);
Assert.Equal(TicketStatus.Open, ticket.Status);
}
}
@@ -0,0 +1,81 @@
using NSubstitute;
using PnvPanel.Application.Admin.Users;
using PnvPanel.Application.Common.Interfaces;
using PnvPanel.Application.Common.Models;
using PnvPanel.Application.Tests.TestSupport;
using PnvPanel.Domain.Billing;
using Xunit;
namespace PnvPanel.Application.Tests.Admin.Users;
public class ListUsersQueryHandlerTests
{
private readonly IIdentityService _identityService = Substitute.For<IIdentityService>();
private static UserSummaryDto Summary(Guid id) =>
new(id, "alice", "premium", true, false, DateTimeOffset.UtcNow, true, DateTimeOffset.UtcNow.AddDays(-1));
[Fact]
public async Task Handle_WhenUserHasAwaitingConfirmationSubscriptionRequest_SetsBillingPendingReview()
{
using var dbContext = InMemoryDbContextFactory.Create();
var userId = Guid.NewGuid();
var request = PaymentRequest.Create(userId, PaymentPeriod.Quarter, 1000);
request.MarkPaymentSent();
dbContext.PaymentRequests.Add(request);
await dbContext.SaveChangesAsync(CancellationToken.None);
_identityService
.ListUsersAsync(1, 20, null, Arg.Any<CancellationToken>())
.Returns(new PagedList<UserSummaryDto>([Summary(userId)], 1, 1, 20));
var handler = new ListUsersQueryHandler(_identityService, dbContext);
var result = await handler.Handle(new ListUsersQuery(1, 20, null), CancellationToken.None);
Assert.True(result.IsSuccess);
Assert.True(result.Value.Items.Single().BillingPendingReview);
}
[Fact]
public async Task Handle_WhenNoPendingRequest_LeavesBillingPendingReviewFalse()
{
using var dbContext = InMemoryDbContextFactory.Create();
var userId = Guid.NewGuid();
_identityService
.ListUsersAsync(1, 20, null, Arg.Any<CancellationToken>())
.Returns(new PagedList<UserSummaryDto>([Summary(userId)], 1, 1, 20));
var handler = new ListUsersQueryHandler(_identityService, dbContext);
var result = await handler.Handle(new ListUsersQuery(1, 20, null), CancellationToken.None);
Assert.True(result.IsSuccess);
Assert.False(result.Value.Items.Single().BillingPendingReview);
}
[Fact]
public async Task Handle_WhenPendingRequestIsRoleChangeTopUp_LeavesBillingPendingReviewFalse()
{
// RoleChangeTopUp — доплата за смену роли, не подписка; не должна показывать пользователя
// как "оплата на проверке" в админке (см. CLAUDE.md про Kind).
using var dbContext = InMemoryDbContextFactory.Create();
var userId = Guid.NewGuid();
var request = PaymentRequest.CreateRoleChangeTopUp(userId, 500);
request.MarkPaymentSent();
dbContext.PaymentRequests.Add(request);
await dbContext.SaveChangesAsync(CancellationToken.None);
_identityService
.ListUsersAsync(1, 20, null, Arg.Any<CancellationToken>())
.Returns(new PagedList<UserSummaryDto>([Summary(userId)], 1, 1, 20));
var handler = new ListUsersQueryHandler(_identityService, dbContext);
var result = await handler.Handle(new ListUsersQuery(1, 20, null), CancellationToken.None);
Assert.True(result.IsSuccess);
Assert.False(result.Value.Items.Single().BillingPendingReview);
}
}
@@ -100,4 +100,41 @@ public class RefreshCommandHandlerTests
Assert.False(result.IsSuccess);
Assert.Equal(AuthErrors.InvalidRefreshToken, result.Error);
}
[Fact]
public async Task Handle_WhenUserBlocked_ReturnsUserBlocked()
{
var userId = Guid.NewGuid();
var profile = new CurrentUserProfile(
userId,
"alice",
Guid.NewGuid(),
"user",
IsActivated: true,
IsBlocked: true,
MaxConfigs: 3,
MaxIpLimit: RoleQuota.Unlimited,
SubscriptionToken: "sub-token",
BillingEnabled: false,
BillingPaidUntil: null,
BillingSuspended: false
);
var rotated = new RotatedRefreshToken(
userId,
"new-refresh-token",
DateTimeOffset.UtcNow.AddDays(30)
);
_refreshTokenService
.RotateAsync("old-token", Arg.Any<CancellationToken>())
.Returns(Result.Success(rotated));
_identityService.GetProfileAsync(userId, Arg.Any<CancellationToken>()).Returns(profile);
var result = await CreateHandler()
.Handle(new RefreshCommand("old-token"), CancellationToken.None);
Assert.False(result.IsSuccess);
Assert.Equal(AuthErrors.UserBlocked, result.Error);
_jwtTokenService.DidNotReceive().GenerateAccessToken(Arg.Any<AuthenticatedUser>());
}
}
@@ -150,6 +150,9 @@ public class CreatePaymentRequestCommandHandlerTests
{
using var dbContext = InMemoryDbContextFactory.Create();
var userId = Guid.NewGuid();
var pricing = PricingSettings.CreateDefault();
pricing.Update(500, 450, 400);
dbContext.PricingSettings.Add(pricing);
dbContext.PaymentRequests.Add(PaymentRequest.Create(userId, PaymentPeriod.Quarter, 1000));
await dbContext.SaveChangesAsync(CancellationToken.None);
@@ -16,13 +16,14 @@ public class MarkPaymentSentCommandHandlerTests
{
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<MarkPaymentSentCommandHandler> _logger = Substitute.For<
ILogger<MarkPaymentSentCommandHandler>
>();
private MarkPaymentSentCommandHandler CreateHandler(IAppDbContext dbContext, Guid userId) =>
new(dbContext, _identityService, _gateway, _telegramNotifier, FakeCurrentUser.Authenticated(userId, "alice"), _logger);
new(dbContext, _identityService, _gateway, _notifier, _telegramNotifier, FakeCurrentUser.Authenticated(userId, "alice"), _logger);
private static CurrentUserProfile Profile(Guid userId, DateTimeOffset? paidUntil) =>
new(
@@ -139,6 +140,7 @@ public class MarkPaymentSentCommandHandlerTests
Arg.Is<DateTimeOffset?>(d => d > DateTimeOffset.UtcNow),
Arg.Any<CancellationToken>()
);
await _notifier.Received(1).NotifyBillingStatusChangedAsync(userId, Arg.Any<CancellationToken>());
}
[Fact]
@@ -1,3 +1,5 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using NSubstitute;
using PnvPanel.Application.Auth;
using PnvPanel.Application.Common.Interfaces;
@@ -5,13 +7,53 @@ using PnvPanel.Application.Common.Models;
using PnvPanel.Application.Configs;
using PnvPanel.Application.Configs.Rotate;
using PnvPanel.Application.Tests.TestSupport;
using PnvPanel.Domain.Activation;
using PnvPanel.Domain.Apps;
using PnvPanel.Domain.Audit;
using PnvPanel.Domain.Billing;
using PnvPanel.Domain.Configs;
using PnvPanel.Domain.Inbounds;
using PnvPanel.Domain.Instructions;
using PnvPanel.Domain.News;
using PnvPanel.Domain.Nodes;
using PnvPanel.Domain.Pricing;
using PnvPanel.Domain.Support;
using PnvPanel.Domain.Telegram;
using PnvPanel.Infrastructure.Persistence;
using Xunit;
namespace PnvPanel.Application.Tests.Configs.Rotate;
/// <summary>Оборачивает реальный AppDbContext, но всегда фейлит SaveChangesAsync — симулирует сбой БД
/// после успешного создания клиента в панели (DbUpdateException — единственный тип, который ловит
/// RotateVpnConfigCommandHandler для компенсирующего отката).</summary>
public sealed class ThrowingSaveDbContext(AppDbContext inner) : IAppDbContext
{
public DbSet<ActivationRequest> ActivationRequests => inner.ActivationRequests;
public DbSet<AuditLog> AuditLogs => inner.AuditLogs;
public DbSet<TelegramLinkToken> TelegramLinkTokens => inner.TelegramLinkTokens;
public DbSet<TelegramLoginRequest> TelegramLoginRequests => inner.TelegramLoginRequests;
public DbSet<Node> Nodes => inner.Nodes;
public DbSet<Inbound> Inbounds => inner.Inbounds;
public DbSet<VpnConfig> VpnConfigs => inner.VpnConfigs;
public DbSet<TrafficSample> TrafficSamples => inner.TrafficSamples;
public DbSet<ClientApp> ClientApps => inner.ClientApps;
public DbSet<NewsPost> NewsPosts => inner.NewsPosts;
public DbSet<SupportTicket> SupportTickets => inner.SupportTickets;
public DbSet<TicketComment> TicketComments => inner.TicketComments;
public DbSet<TicketAttachment> TicketAttachments => inner.TicketAttachments;
public DbSet<InstructionIntro> InstructionIntros => inner.InstructionIntros;
public DbSet<InstructionTab> InstructionTabs => inner.InstructionTabs;
public DbSet<PricingSettings> PricingSettings => inner.PricingSettings;
public DbSet<PricingDiscountTier> PricingDiscountTiers => inner.PricingDiscountTiers;
public DbSet<BillingSettings> BillingSettings => inner.BillingSettings;
public DbSet<PaymentRequest> PaymentRequests => inner.PaymentRequests;
public DatabaseFacade Database => inner.Database;
public Task<int> SaveChangesAsync(CancellationToken cancellationToken) =>
throw new DbUpdateException("simulated failure");
}
public class RotateVpnConfigCommandHandlerTests
{
private readonly IXuiPanelGateway _gateway = Substitute.For<IXuiPanelGateway>();
@@ -231,4 +273,76 @@ public class RotateVpnConfigCommandHandlerTests
Assert.Equal(gatewayError, result.Error);
Assert.Equal("old-external-id", config.ClientExternalId);
}
[Fact]
public async Task Handle_WhenSaveChangesFailsAfterAddClient_RemovesOrphanedClientAndReturnsRotateFailed()
{
using var dbContext = InMemoryDbContextFactory.Create();
var userId = Guid.NewGuid();
_identityService
.GetProfileAsync(userId, Arg.Any<CancellationToken>())
.Returns(MakeProfile(userId));
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("old-external-id");
dbContext.Nodes.Add(node);
dbContext.Inbounds.Add(inbound);
dbContext.VpnConfigs.Add(config);
await dbContext.SaveChangesAsync(CancellationToken.None);
_gateway
.AddClientAsync(
Arg.Any<Node>(),
inbound.RemoteInboundId,
config.Protocol,
Arg.Any<string>(),
Arg.Any<string>(),
Arg.Any<int>(),
Arg.Any<DateTimeOffset?>(),
Arg.Any<CancellationToken>()
)
.Returns(Result.Success("new-external-id"));
var handler = new RotateVpnConfigCommandHandler(
new ThrowingSaveDbContext(dbContext),
_gateway,
_identityService,
FakeCurrentUser.Authenticated(userId)
);
var result = await handler.Handle(
new RotateVpnConfigCommand(config.Id),
CancellationToken.None
);
Assert.False(result.IsSuccess);
Assert.Equal(ConfigErrors.RotateFailed, result.Error);
// Осиротевший клиент, успевший создаться в панели до сбоя SaveChanges, откатывается.
await _gateway
.Received(1)
.RemoveClientAsync(
Arg.Any<Node>(),
inbound.RemoteInboundId,
"new-external-id",
config.Protocol,
Arg.Any<CancellationToken>()
);
await _gateway
.DidNotReceive()
.RemoveClientAsync(
Arg.Any<Node>(),
inbound.RemoteInboundId,
"old-external-id",
config.Protocol,
Arg.Any<CancellationToken>()
);
}
}
@@ -83,4 +83,29 @@ public class ReopenTicketCommandHandlerTests
Assert.False(result.IsSuccess);
Assert.Equal(SupportErrors.NotFound, result.Error);
}
[Fact]
public async Task Handle_WhenRoleRequestResolved_ReturnsOnlyBugReportCanBeReopened()
{
// approve/reject — единственный способ решить заявку на роль/продление (см. CLAUDE.md);
// Reopen для них запрещён даже если тикет каким-то образом оказался Resolved.
using var dbContext = InMemoryDbContextFactory.Create();
var userId = Guid.NewGuid();
var ticket = SupportTicket.CreateRoleRequestForExistingRole(userId, Guid.NewGuid());
ticket.Resolve();
dbContext.SupportTickets.Add(ticket);
await dbContext.SaveChangesAsync(CancellationToken.None);
var currentUser = FakeCurrentUser.Authenticated(userId);
var handler = new ReopenTicketCommandHandler(dbContext, _telegramNotifier, currentUser);
var result = await handler.Handle(
new ReopenTicketCommand(ticket.Id),
CancellationToken.None
);
Assert.False(result.IsSuccess);
Assert.Equal(SupportErrors.OnlyBugReportCanBeReopened, result.Error);
Assert.Equal(TicketStatus.Resolved, ticket.Status);
}
}
@@ -150,4 +150,74 @@ public class GetLoginRequestStatusQueryHandlerTests
Assert.False(result.IsSuccess);
Assert.Equal(AuthErrors.Unauthorized, result.Error);
}
[Fact]
public async Task Handle_WhenApprovedAndUserBlocked_ReturnsUserBlockedWithoutIssuingTokens()
{
using var dbContext = InMemoryDbContextFactory.Create();
var userId = Guid.NewGuid();
var request = TelegramLoginRequest.Create(TimeSpan.FromMinutes(5), null);
request.Approve(userId);
dbContext.TelegramLoginRequests.Add(request);
await dbContext.SaveChangesAsync(CancellationToken.None);
var profile = new CurrentUserProfile(
userId,
"alice",
Guid.NewGuid(),
"user",
true,
true,
3,
RoleQuota.Unlimited,
"sub-token",
false,
null,
false
);
_identityService.GetProfileAsync(userId, Arg.Any<CancellationToken>()).Returns(profile);
var handler = CreateHandler(dbContext);
var result = await handler.Handle(
new TelegramNs.GetLoginRequestStatusQuery(request.Id),
CancellationToken.None
);
Assert.False(result.IsSuccess);
Assert.Equal(AuthErrors.UserBlocked, result.Error);
// Уже потреблён предыдущим прогоном лока — токены выпустить не успели, но повторно claim'ить нельзя.
Assert.Equal(TelegramLoginStatus.Consumed, request.Status);
await _refreshTokenService
.DidNotReceive()
.IssueAsync(Arg.Any<Guid>(), Arg.Any<CancellationToken>());
}
[Fact]
public async Task Handle_WhenAlreadyConsumedByAnotherPoll_ReturnsConsumedWithoutTokens()
{
// Второй поллер той же вкладки после того, как первый уже забрал вход (Consume()) —
// короткий путь ДО AdvisoryLock (status != Approved), токены повторно не выпускаются.
using var dbContext = InMemoryDbContextFactory.Create();
var userId = Guid.NewGuid();
var request = TelegramLoginRequest.Create(TimeSpan.FromMinutes(5), null);
request.Approve(userId);
request.Consume();
dbContext.TelegramLoginRequests.Add(request);
await dbContext.SaveChangesAsync(CancellationToken.None);
var handler = CreateHandler(dbContext);
var result = await handler.Handle(
new TelegramNs.GetLoginRequestStatusQuery(request.Id),
CancellationToken.None
);
Assert.True(result.IsSuccess);
Assert.Equal(TelegramLoginStatus.Consumed, result.Value.Status);
Assert.Null(result.Value.Auth);
await _refreshTokenService
.DidNotReceive()
.IssueAsync(Arg.Any<Guid>(), Arg.Any<CancellationToken>());
}
}
@@ -80,4 +80,42 @@ public class InboundTests
Assert.False(inbound.IsAvailable);
Assert.False(inbound.IsPublished);
}
[Fact]
public void UpdateFromRemote_AfterMarkUnavailable_RestoresIsAvailable()
{
// Инбаунд пропадал с ноды, потом снова появился при следующей синхронизации — без сброса
// IsAvailable оставался бы недоступным навсегда, хотя реально снова опубликован на панели.
var inbound = Inbound.FromRemote(Guid.NewGuid(), "12", VpnProtocol.Vless, "Old", 443);
inbound.MarkUnavailable();
inbound.UpdateFromRemote(VpnProtocol.Vless, "New", 443);
Assert.True(inbound.IsAvailable);
}
[Fact]
public void Publish_AfterMarkUnavailable_RestoresIsAvailable()
{
var inbound = Inbound.FromRemote(Guid.NewGuid(), "12", VpnProtocol.Vless, "Germany", 443);
inbound.MarkUnavailable();
inbound.Publish("Germany", [Guid.NewGuid()]);
Assert.True(inbound.IsAvailable);
}
[Fact]
public void RemoveAllowedRole_RemovesOnlyMatchingRole()
{
var inbound = Inbound.FromRemote(Guid.NewGuid(), "12", VpnProtocol.Vless, "Germany", 443);
var roleId = Guid.NewGuid();
var otherRoleId = Guid.NewGuid();
inbound.Publish("Germany", [roleId, otherRoleId]);
inbound.RemoveAllowedRole(roleId);
Assert.DoesNotContain(roleId, inbound.AllowedRoleIds);
Assert.Contains(otherRoleId, inbound.AllowedRoleIds);
}
}