Implement billing functionality and enhance role management
CI / Backend (build + test) (push) Successful in 1m22s
CI / Frontend (lint + typecheck + build) (push) Successful in 33s

- Introduced billing capabilities, allowing users to request payments for subscription periods (3/6/12 months) with admin approval via Telegram.
- Updated role management to include a `BillingEnabled` property, preventing billing for admin roles.
- Enhanced the `CreateRoleCommand` and `UpdateRoleCommand` to accept billing parameters, ensuring proper handling during role creation and updates.
- Added new endpoints for billing management and integrated billing checks into VPN config creation to enforce payment requirements.
- Updated related services, models, and tests to support the new billing features, ensuring comprehensive coverage and functionality.
- Enhanced documentation to reflect the new billing processes and role management changes.
This commit is contained in:
Leonid Pershin
2026-07-19 01:38:16 +03:00
parent b980dc6cef
commit b2ae358250
106 changed files with 6018 additions and 66 deletions
@@ -0,0 +1,237 @@
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 ConfirmPaymentRequestCommandHandlerTests
{
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<ConfirmPaymentRequestCommandHandler> _logger = Substitute.For<
ILogger<ConfirmPaymentRequestCommandHandler>
>();
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: paidUntil is null
);
[Fact]
public async Task Handle_WhenNoPriorPayment_ExtendsFromNow()
{
using var dbContext = InMemoryDbContextFactory.Create();
var adminId = Guid.NewGuid();
var userId = Guid.NewGuid();
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: null));
_identityService
.ExtendBillingPaidUntilAsync(userId, Arg.Any<DateTimeOffset>(), Arg.Any<CancellationToken>())
.Returns(Result.Success());
var handler = new ConfirmPaymentRequestCommandHandler(
dbContext,
_identityService,
_gateway,
_notifier,
_telegramNotifier,
FakeCurrentUser.Authenticated(adminId, "admin"),
_logger
);
var before = DateTimeOffset.UtcNow;
var result = await handler.Handle(
new ConfirmPaymentRequestCommand(request.Id),
CancellationToken.None
);
Assert.True(result.IsSuccess);
Assert.Equal(PaymentRequestStatus.Confirmed, request.Status);
await _identityService
.Received(1)
.ExtendBillingPaidUntilAsync(
userId,
Arg.Is<DateTimeOffset>(d => d >= before.AddMonths(3).AddMinutes(-1)),
Arg.Any<CancellationToken>()
);
}
[Fact]
public async Task Handle_WhenPaidUntilInFuture_ExtendsFromExistingPaidUntil()
{
using var dbContext = InMemoryDbContextFactory.Create();
var adminId = Guid.NewGuid();
var userId = Guid.NewGuid();
var existingPaidUntil = DateTimeOffset.UtcNow.AddDays(20);
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: existingPaidUntil));
_identityService
.ExtendBillingPaidUntilAsync(userId, Arg.Any<DateTimeOffset>(), Arg.Any<CancellationToken>())
.Returns(Result.Success());
var handler = new ConfirmPaymentRequestCommandHandler(
dbContext,
_identityService,
_gateway,
_notifier,
_telegramNotifier,
FakeCurrentUser.Authenticated(adminId, "admin"),
_logger
);
await handler.Handle(new ConfirmPaymentRequestCommand(request.Id), CancellationToken.None);
var expected = existingPaidUntil.AddMonths(3);
await _identityService
.Received(1)
.ExtendBillingPaidUntilAsync(
userId,
Arg.Is<DateTimeOffset>(d => Math.Abs((d - expected).TotalSeconds) < 5),
Arg.Any<CancellationToken>()
);
}
[Fact]
public async Task Handle_ResumesExpiredConfigsAndSyncsExpiry()
{
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 expiredConfig = VpnConfig.Create(userId, inbound.Id, VpnProtocol.Vless, null);
expiredConfig.AssignRemoteClient("ext-1");
expiredConfig.Suspend();
var activeConfig = VpnConfig.Create(userId, inbound.Id, VpnProtocol.Vless, null);
activeConfig.AssignRemoteClient("ext-2");
dbContext.Nodes.Add(node);
dbContext.Inbounds.Add(inbound);
dbContext.VpnConfigs.AddRange(expiredConfig, activeConfig);
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: null));
_identityService
.ExtendBillingPaidUntilAsync(userId, Arg.Any<DateTimeOffset>(), Arg.Any<CancellationToken>())
.Returns(Result.Success());
_gateway
.UpdateClientAsync(
Arg.Any<Node>(),
Arg.Any<string>(),
Arg.Any<string>(),
Arg.Any<VpnProtocol>(),
Arg.Any<string>(),
enable: true,
Arg.Any<CancellationToken>()
)
.Returns(Result.Success());
var handler = new ConfirmPaymentRequestCommandHandler(
dbContext,
_identityService,
_gateway,
_notifier,
_telegramNotifier,
FakeCurrentUser.Authenticated(adminId, "admin"),
_logger
);
var result = await handler.Handle(
new ConfirmPaymentRequestCommand(request.Id),
CancellationToken.None
);
Assert.True(result.IsSuccess);
Assert.Equal(ConfigStatus.Active, expiredConfig.Status);
Assert.NotNull(expiredConfig.ExpiresAt);
Assert.NotNull(activeConfig.ExpiresAt);
Assert.Equal(expiredConfig.ExpiresAt, activeConfig.ExpiresAt);
await _gateway
.Received(1)
.UpdateClientAsync(
Arg.Any<Node>(),
inbound.RemoteInboundId,
"ext-1",
VpnProtocol.Vless,
Arg.Any<string>(),
enable: true,
Arg.Any<CancellationToken>()
);
}
[Fact]
public async Task Handle_WhenRequestAlreadyDecided_ReturnsNotDecidable()
{
using var dbContext = InMemoryDbContextFactory.Create();
var adminId = Guid.NewGuid();
var userId = Guid.NewGuid();
var request = PaymentRequest.Create(userId, PaymentPeriod.Quarter, 1500);
request.Cancel();
dbContext.PaymentRequests.Add(request);
await dbContext.SaveChangesAsync(CancellationToken.None);
var handler = new ConfirmPaymentRequestCommandHandler(
dbContext,
_identityService,
_gateway,
_notifier,
_telegramNotifier,
FakeCurrentUser.Authenticated(adminId, "admin"),
_logger
);
var result = await handler.Handle(
new ConfirmPaymentRequestCommand(request.Id),
CancellationToken.None
);
Assert.False(result.IsSuccess);
Assert.Equal(BillingErrors.RequestNotDecidable, result.Error);
}
}
@@ -0,0 +1,69 @@
using NSubstitute;
using PnvPanel.Application.Admin.Billing;
using PnvPanel.Application.Billing;
using PnvPanel.Application.Common.Interfaces;
using PnvPanel.Application.Tests.TestSupport;
using PnvPanel.Domain.Billing;
using Xunit;
namespace PnvPanel.Application.Tests.Admin.Billing;
public class RejectPaymentRequestCommandHandlerTests
{
private readonly ITelegramNotifier _telegramNotifier = Substitute.For<ITelegramNotifier>();
[Fact]
public async Task Handle_WhenAwaitingConfirmation_RejectsAndNotifiesUser()
{
using var dbContext = InMemoryDbContextFactory.Create();
var adminId = Guid.NewGuid();
var userId = Guid.NewGuid();
var request = PaymentRequest.Create(userId, PaymentPeriod.Quarter, 1500);
request.MarkPaymentSent();
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
);
Assert.True(result.IsSuccess);
Assert.Equal(PaymentRequestStatus.Rejected, request.Status);
Assert.Equal("Платёж не найден", request.RejectionReason);
await _telegramNotifier
.Received(1)
.NotifyUserAsync(
userId,
Arg.Is<string>(m => m.Contains("Платёж не найден")),
Arg.Any<string?>(),
Arg.Any<CancellationToken>()
);
}
[Fact]
public async Task Handle_WhenRequestNotFound_ReturnsRequestNotFound()
{
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
);
Assert.False(result.IsSuccess);
Assert.Equal(BillingErrors.RequestNotFound, result.Error);
}
}
@@ -72,8 +72,8 @@ public class FactoryResetCommandHandlerTests
.Returns(
new List<RoleDto>
{
new(adminRoleId, "admin", -1, -1, IsSystem: true),
new(customRoleId, "premium", 10, 5, IsSystem: false),
new(adminRoleId, "admin", -1, -1, IsSystem: true, BillingEnabled: false),
new(customRoleId, "premium", 10, 5, IsSystem: false, BillingEnabled: false),
}
);
_roleService
@@ -27,8 +27,8 @@ public class ApproveRoleRequestCommandHandlerTests
var newRoleId = Guid.NewGuid();
_roleService
.CreateRoleAsync("premium", 10, 5, Arg.Any<CancellationToken>())
.Returns(Result.Success(new RoleDto(newRoleId, "premium", 10, 5, false)));
.CreateRoleAsync("premium", 10, 5, false, Arg.Any<CancellationToken>())
.Returns(Result.Success(new RoleDto(newRoleId, "premium", 10, 5, false, false)));
_roleService
.ChangeUserRoleAsync(userId, newRoleId, Arg.Any<CancellationToken>())
.Returns(Result.Success());
@@ -51,7 +51,7 @@ public class ApproveRoleRequestCommandHandlerTests
Assert.Equal(TicketStatus.Resolved, ticket.Status);
await _roleService
.Received(1)
.CreateRoleAsync("premium", 10, 5, Arg.Any<CancellationToken>());
.CreateRoleAsync("premium", 10, 5, false, Arg.Any<CancellationToken>());
await _roleService
.Received(1)
.ChangeUserRoleAsync(userId, newRoleId, Arg.Any<CancellationToken>());
@@ -100,6 +100,7 @@ public class ApproveRoleRequestCommandHandlerTests
Arg.Any<string>(),
Arg.Any<int>(),
Arg.Any<int>(),
Arg.Any<bool>(),
Arg.Any<CancellationToken>()
);
}
@@ -55,7 +55,10 @@ public class GetCurrentUserQueryHandlerTests
false,
3,
RoleQuota.Unlimited,
"sub-token"
"sub-token",
false,
null,
false
);
_identityService.GetProfileAsync(userId, Arg.Any<CancellationToken>()).Returns(profile);
_identityService
@@ -31,7 +31,10 @@ public class LoginCommandHandlerTests
IsBlocked: false,
MaxConfigs: 3,
MaxIpLimit: RoleQuota.Unlimited,
SubscriptionToken: "sub-token"
SubscriptionToken: "sub-token",
BillingEnabled: false,
BillingPaidUntil: null,
BillingSuspended: false
);
_identityService
@@ -30,7 +30,10 @@ public class RefreshCommandHandlerTests
IsBlocked: false,
MaxConfigs: 3,
MaxIpLimit: RoleQuota.Unlimited,
SubscriptionToken: "sub-token"
SubscriptionToken: "sub-token",
BillingEnabled: false,
BillingPaidUntil: null,
BillingSuspended: false
);
var rotated = new RotatedRefreshToken(
userId,
@@ -0,0 +1,79 @@
using PnvPanel.Application.Billing;
using PnvPanel.Application.Billing.CancelPaymentRequest;
using PnvPanel.Application.Tests.TestSupport;
using PnvPanel.Domain.Billing;
using Xunit;
namespace PnvPanel.Application.Tests.Billing.CancelPaymentRequest;
public class CancelPaymentRequestCommandHandlerTests
{
[Fact]
public async Task Handle_WhenAwaitingPayment_Cancels()
{
using var dbContext = InMemoryDbContextFactory.Create();
var userId = Guid.NewGuid();
var request = PaymentRequest.Create(userId, PaymentPeriod.Quarter, 1000);
dbContext.PaymentRequests.Add(request);
await dbContext.SaveChangesAsync(CancellationToken.None);
var handler = new CancelPaymentRequestCommandHandler(
dbContext,
FakeCurrentUser.Authenticated(userId)
);
var result = await handler.Handle(
new CancelPaymentRequestCommand(request.Id),
CancellationToken.None
);
Assert.True(result.IsSuccess);
Assert.Equal(PaymentRequestStatus.Cancelled, request.Status);
}
[Fact]
public async Task Handle_WhenAwaitingConfirmation_ReturnsNotCancellable()
{
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);
var handler = new CancelPaymentRequestCommandHandler(
dbContext,
FakeCurrentUser.Authenticated(userId)
);
var result = await handler.Handle(
new CancelPaymentRequestCommand(request.Id),
CancellationToken.None
);
Assert.False(result.IsSuccess);
Assert.Equal(BillingErrors.RequestNotCancellable, result.Error);
}
[Fact]
public async Task Handle_WhenOwnedByAnotherUser_ReturnsNotFound()
{
using var dbContext = InMemoryDbContextFactory.Create();
var request = PaymentRequest.Create(Guid.NewGuid(), PaymentPeriod.Quarter, 1000);
dbContext.PaymentRequests.Add(request);
await dbContext.SaveChangesAsync(CancellationToken.None);
var handler = new CancelPaymentRequestCommandHandler(
dbContext,
FakeCurrentUser.Authenticated(Guid.NewGuid())
);
var result = await handler.Handle(
new CancelPaymentRequestCommand(request.Id),
CancellationToken.None
);
Assert.False(result.IsSuccess);
Assert.Equal(BillingErrors.RequestNotFound, result.Error);
}
}
@@ -0,0 +1,166 @@
using NSubstitute;
using PnvPanel.Application.Billing;
using PnvPanel.Application.Billing.CreatePaymentRequest;
using PnvPanel.Application.Common.Interfaces;
using PnvPanel.Application.Common.Models;
using PnvPanel.Application.Tests.TestSupport;
using PnvPanel.Domain.Billing;
using PnvPanel.Domain.Pricing;
using Xunit;
namespace PnvPanel.Application.Tests.Billing.CreatePaymentRequest;
public class CreatePaymentRequestCommandHandlerTests
{
private readonly IIdentityService _identityService = Substitute.For<IIdentityService>();
private static CurrentUserProfile Profile(
Guid userId,
bool billingEnabled = true,
int maxConfigs = 5,
DateTimeOffset? paidUntil = null
) =>
new(
userId,
"alice",
Guid.NewGuid(),
"premium",
IsActivated: true,
IsBlocked: false,
MaxConfigs: maxConfigs,
MaxIpLimit: 3,
SubscriptionToken: "sub-token",
BillingEnabled: billingEnabled,
BillingPaidUntil: paidUntil,
BillingSuspended: false
);
[Fact]
public async Task Handle_WithConfiguredPricing_ComputesAmountAndCreatesRequest()
{
using var dbContext = InMemoryDbContextFactory.Create();
var userId = Guid.NewGuid();
var pricing = PricingSettings.CreateDefault();
pricing.Update(500, 450, 400);
dbContext.PricingSettings.Add(pricing);
await dbContext.SaveChangesAsync(CancellationToken.None);
_identityService
.GetProfileAsync(userId, Arg.Any<CancellationToken>())
.Returns(Profile(userId, maxConfigs: 5));
var handler = new CreatePaymentRequestCommandHandler(
dbContext,
_identityService,
FakeCurrentUser.Authenticated(userId)
);
var result = await handler.Handle(
new CreatePaymentRequestCommand(PaymentPeriod.Quarter),
CancellationToken.None
);
Assert.True(result.IsSuccess);
Assert.Equal(500 * 5 * 3, result.Value.AmountSnapshot);
Assert.Equal(PaymentRequestStatus.AwaitingPayment, result.Value.Status);
}
[Fact]
public async Task Handle_WhenBillingNotEnabled_ReturnsNotEnabled()
{
using var dbContext = InMemoryDbContextFactory.Create();
var userId = Guid.NewGuid();
_identityService
.GetProfileAsync(userId, Arg.Any<CancellationToken>())
.Returns(Profile(userId, billingEnabled: false));
var handler = new CreatePaymentRequestCommandHandler(
dbContext,
_identityService,
FakeCurrentUser.Authenticated(userId)
);
var result = await handler.Handle(
new CreatePaymentRequestCommand(PaymentPeriod.Quarter),
CancellationToken.None
);
Assert.False(result.IsSuccess);
Assert.Equal(BillingErrors.NotEnabled, result.Error);
}
[Fact]
public async Task Handle_WhenRoleHasUnlimitedConfigs_ReturnsUnlimitedRoleNotSupported()
{
using var dbContext = InMemoryDbContextFactory.Create();
var userId = Guid.NewGuid();
_identityService
.GetProfileAsync(userId, Arg.Any<CancellationToken>())
.Returns(Profile(userId, maxConfigs: RoleQuota.Unlimited));
var handler = new CreatePaymentRequestCommandHandler(
dbContext,
_identityService,
FakeCurrentUser.Authenticated(userId)
);
var result = await handler.Handle(
new CreatePaymentRequestCommand(PaymentPeriod.Quarter),
CancellationToken.None
);
Assert.False(result.IsSuccess);
Assert.Equal(BillingErrors.UnlimitedRoleNotSupported, result.Error);
}
[Fact]
public async Task Handle_WhenActiveRequestExists_ReturnsActiveRequestExists()
{
using var dbContext = InMemoryDbContextFactory.Create();
var userId = Guid.NewGuid();
dbContext.PaymentRequests.Add(PaymentRequest.Create(userId, PaymentPeriod.Quarter, 1000));
await dbContext.SaveChangesAsync(CancellationToken.None);
_identityService
.GetProfileAsync(userId, Arg.Any<CancellationToken>())
.Returns(Profile(userId));
var handler = new CreatePaymentRequestCommandHandler(
dbContext,
_identityService,
FakeCurrentUser.Authenticated(userId)
);
var result = await handler.Handle(
new CreatePaymentRequestCommand(PaymentPeriod.Quarter),
CancellationToken.None
);
Assert.False(result.IsSuccess);
Assert.Equal(BillingErrors.ActiveRequestExists, result.Error);
}
[Fact]
public async Task Handle_WhenPricingNotConfigured_ReturnsPricingNotConfigured()
{
using var dbContext = InMemoryDbContextFactory.Create();
var userId = Guid.NewGuid();
_identityService
.GetProfileAsync(userId, Arg.Any<CancellationToken>())
.Returns(Profile(userId));
var handler = new CreatePaymentRequestCommandHandler(
dbContext,
_identityService,
FakeCurrentUser.Authenticated(userId)
);
var result = await handler.Handle(
new CreatePaymentRequestCommand(PaymentPeriod.Quarter),
CancellationToken.None
);
Assert.False(result.IsSuccess);
Assert.Equal(BillingErrors.PricingNotConfigured, result.Error);
}
}
@@ -0,0 +1,85 @@
using NSubstitute;
using PnvPanel.Application.Billing.GetMyBillingStatus;
using PnvPanel.Application.Common.Interfaces;
using PnvPanel.Application.Tests.TestSupport;
using PnvPanel.Domain.Billing;
using Xunit;
namespace PnvPanel.Application.Tests.Billing.GetMyBillingStatus;
public class GetMyBillingStatusQueryHandlerTests
{
private readonly IIdentityService _identityService = Substitute.For<IIdentityService>();
private static CurrentUserProfile Profile(
Guid userId,
bool billingEnabled,
DateTimeOffset? paidUntil = null,
bool suspended = false
) =>
new(
userId,
"alice",
Guid.NewGuid(),
"premium",
IsActivated: true,
IsBlocked: false,
MaxConfigs: 5,
MaxIpLimit: 3,
SubscriptionToken: "sub-token",
BillingEnabled: billingEnabled,
BillingPaidUntil: paidUntil,
BillingSuspended: suspended
);
[Fact]
public async Task Handle_WhenBillingNotEnabled_ReturnsDisabledStatusWithoutQueryingRequests()
{
using var dbContext = InMemoryDbContextFactory.Create();
var userId = Guid.NewGuid();
_identityService
.GetProfileAsync(userId, Arg.Any<CancellationToken>())
.Returns(Profile(userId, billingEnabled: false));
var handler = new GetMyBillingStatusQueryHandler(
dbContext,
_identityService,
FakeCurrentUser.Authenticated(userId)
);
var result = await handler.Handle(new GetMyBillingStatusQuery(), CancellationToken.None);
Assert.True(result.IsSuccess);
Assert.False(result.Value.BillingEnabled);
Assert.Null(result.Value.ActiveRequest);
}
[Fact]
public async Task Handle_WhenActiveRequestExists_IncludesItInStatus()
{
using var dbContext = InMemoryDbContextFactory.Create();
var userId = Guid.NewGuid();
var paidUntil = DateTimeOffset.UtcNow.AddDays(10);
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, billingEnabled: true, paidUntil: paidUntil));
var handler = new GetMyBillingStatusQueryHandler(
dbContext,
_identityService,
FakeCurrentUser.Authenticated(userId)
);
var result = await handler.Handle(new GetMyBillingStatusQuery(), CancellationToken.None);
Assert.True(result.IsSuccess);
Assert.True(result.Value.BillingEnabled);
Assert.Equal(paidUntil, result.Value.PaidUntil);
Assert.NotNull(result.Value.ActiveRequest);
Assert.Equal(request.Id, result.Value.ActiveRequest!.Id);
}
}
@@ -0,0 +1,75 @@
using NSubstitute;
using PnvPanel.Application.Billing;
using PnvPanel.Application.Billing.MarkPaymentSent;
using PnvPanel.Application.Common.Interfaces;
using PnvPanel.Application.Tests.TestSupport;
using PnvPanel.Domain.Billing;
using Xunit;
namespace PnvPanel.Application.Tests.Billing.MarkPaymentSent;
public class MarkPaymentSentCommandHandlerTests
{
private readonly IIdentityService _identityService = Substitute.For<IIdentityService>();
private readonly ITelegramNotifier _telegramNotifier = Substitute.For<ITelegramNotifier>();
[Fact]
public async Task Handle_WhenAwaitingPayment_MovesToAwaitingConfirmationAndNotifiesAdmins()
{
using var dbContext = InMemoryDbContextFactory.Create();
var userId = Guid.NewGuid();
var request = PaymentRequest.Create(userId, PaymentPeriod.Year, 6000);
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
);
Assert.True(result.IsSuccess);
Assert.Equal(PaymentRequestStatus.AwaitingConfirmation, request.Status);
await _telegramNotifier
.Received(1)
.NotifyAdminsPaymentRequestedAsync(
request.Id,
Arg.Any<string>(),
PaymentPeriod.Year,
6000,
Arg.Any<CancellationToken>()
);
}
[Fact]
public async Task Handle_WhenAlreadyAwaitingConfirmation_ReturnsNotAwaitingPayment()
{
using var dbContext = InMemoryDbContextFactory.Create();
var userId = Guid.NewGuid();
var request = PaymentRequest.Create(userId, PaymentPeriod.Year, 6000);
request.MarkPaymentSent();
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
);
Assert.False(result.IsSuccess);
Assert.Equal(BillingErrors.RequestNotAwaitingPayment, result.Error);
}
}
@@ -28,7 +28,10 @@ public class RequireActivationBehaviorTests
IsBlocked: false,
MaxConfigs: 3,
MaxIpLimit: RoleQuota.Unlimited,
SubscriptionToken: "sub-token"
SubscriptionToken: "sub-token",
BillingEnabled: false,
BillingPaidUntil: null,
BillingSuspended: false
);
[Fact]
@@ -47,7 +47,10 @@ public class GetMyConfigsQueryHandlerTests
false,
5,
RoleQuota.Unlimited,
"sub-token"
"sub-token",
false,
null,
false
);
_identityService.GetProfileAsync(userId, Arg.Any<CancellationToken>()).Returns(profile);
@@ -27,7 +27,10 @@ public class RotateVpnConfigCommandHandlerTests
false,
3,
RoleQuota.Unlimited,
"sub-token"
"sub-token",
false,
null,
false
);
[Fact]
@@ -25,7 +25,10 @@ public class AddTicketCommentCommandHandlerTests
IsBlocked: false,
MaxConfigs: 3,
MaxIpLimit: RoleQuota.Unlimited,
SubscriptionToken: "token"
SubscriptionToken: "token",
BillingEnabled: false,
BillingPaidUntil: null,
BillingSuspended: false
);
[Fact]
@@ -23,7 +23,7 @@ public class CreateRoleRequestTicketCommandHandlerTests
var currentUser = FakeCurrentUser.Authenticated(userId, "alice");
_roleService
.ListRolesAsync(Arg.Any<CancellationToken>())
.Returns(new List<RoleDto> { new(roleId, "premium", 5, 2, false) });
.Returns(new List<RoleDto> { new(roleId, "premium", 5, 2, false, false) });
var handler = new CreateRoleRequestTicketCommandHandler(
dbContext,
@@ -61,7 +61,7 @@ public class CreateRoleRequestTicketCommandHandlerTests
var currentUser = FakeCurrentUser.Authenticated(Guid.NewGuid());
_roleService
.ListRolesAsync(Arg.Any<CancellationToken>())
.Returns(new List<RoleDto> { new(roleId, "admin", -1, -1, true) });
.Returns(new List<RoleDto> { new(roleId, "admin", -1, -1, true, false) });
var handler = new CreateRoleRequestTicketCommandHandler(
dbContext,
@@ -21,7 +21,10 @@ public class ListSelectableRolesQueryHandlerTests
IsBlocked: false,
MaxConfigs: 3,
MaxIpLimit: 1,
SubscriptionToken: "token"
SubscriptionToken: "token",
BillingEnabled: false,
BillingPaidUntil: null,
BillingSuspended: false
);
[Fact]
@@ -37,9 +40,9 @@ public class ListSelectableRolesQueryHandlerTests
.Returns(
new List<RoleDto>
{
new(adminRoleId, "admin", -1, -1, IsSystem: true),
new(currentRoleId, "user", 3, 1, IsSystem: true),
new(extendedRoleId, "extended", 10, 5, IsSystem: false),
new(adminRoleId, "admin", -1, -1, IsSystem: true, BillingEnabled: false),
new(currentRoleId, "user", 3, 1, IsSystem: true, BillingEnabled: false),
new(extendedRoleId, "extended", 10, 5, IsSystem: false, BillingEnabled: false),
}
);
_identityService
@@ -71,8 +74,8 @@ public class ListSelectableRolesQueryHandlerTests
.Returns(
new List<RoleDto>
{
new(adminRoleId, "admin", -1, -1, IsSystem: true),
new(userRoleId, "user", 3, 1, IsSystem: true),
new(adminRoleId, "admin", -1, -1, IsSystem: true, BillingEnabled: false),
new(userRoleId, "user", 3, 1, IsSystem: true, BillingEnabled: false),
}
);
_identityService
@@ -98,7 +98,10 @@ public class GetLoginRequestStatusQueryHandlerTests
false,
3,
RoleQuota.Unlimited,
"sub-token"
"sub-token",
false,
null,
false
);
_identityService.GetProfileAsync(userId, Arg.Any<CancellationToken>()).Returns(profile);
_jwtTokenService
@@ -0,0 +1,101 @@
using PnvPanel.Domain.Billing;
using PnvPanel.Domain.Exceptions;
using Xunit;
namespace PnvPanel.Domain.Tests.Billing;
public class PaymentRequestTests
{
[Fact]
public void Create_StartsInAwaitingPayment()
{
var request = PaymentRequest.Create(Guid.NewGuid(), PaymentPeriod.Quarter, 1500);
Assert.Equal(PaymentRequestStatus.AwaitingPayment, request.Status);
Assert.Equal(1500, request.AmountSnapshot);
}
[Fact]
public void MarkPaymentSent_FromAwaitingPayment_MovesToAwaitingConfirmation()
{
var request = PaymentRequest.Create(Guid.NewGuid(), PaymentPeriod.Quarter, 1500);
request.MarkPaymentSent();
Assert.Equal(PaymentRequestStatus.AwaitingConfirmation, request.Status);
}
[Fact]
public void MarkPaymentSent_WhenAlreadySent_Throws()
{
var request = PaymentRequest.Create(Guid.NewGuid(), PaymentPeriod.Quarter, 1500);
request.MarkPaymentSent();
Assert.Throws<DomainException>(() => request.MarkPaymentSent());
}
[Fact]
public void Cancel_FromAwaitingPayment_MovesToCancelled()
{
var request = PaymentRequest.Create(Guid.NewGuid(), PaymentPeriod.Quarter, 1500);
request.Cancel();
Assert.Equal(PaymentRequestStatus.Cancelled, request.Status);
}
[Fact]
public void Cancel_AfterMarkPaymentSent_Throws()
{
var request = PaymentRequest.Create(Guid.NewGuid(), PaymentPeriod.Quarter, 1500);
request.MarkPaymentSent();
Assert.Throws<DomainException>(() => request.Cancel());
}
[Fact]
public void Confirm_FromAwaitingConfirmation_Succeeds()
{
var adminId = Guid.NewGuid();
var request = PaymentRequest.Create(Guid.NewGuid(), PaymentPeriod.Year, 5000);
request.MarkPaymentSent();
request.Confirm(adminId);
Assert.Equal(PaymentRequestStatus.Confirmed, request.Status);
Assert.Equal(adminId, request.DecidedBy);
Assert.NotNull(request.DecidedAt);
}
[Fact]
public void Confirm_FromAwaitingPayment_Succeeds()
{
// Админ мог увидеть оплату раньше, чем пользователь нажал "Я оплатил".
var request = PaymentRequest.Create(Guid.NewGuid(), PaymentPeriod.Year, 5000);
request.Confirm(Guid.NewGuid());
Assert.Equal(PaymentRequestStatus.Confirmed, request.Status);
}
[Fact]
public void Reject_FromAwaitingConfirmation_SetsReason()
{
var request = PaymentRequest.Create(Guid.NewGuid(), PaymentPeriod.HalfYear, 3000);
request.MarkPaymentSent();
request.Reject(Guid.NewGuid(), "Платёж не найден");
Assert.Equal(PaymentRequestStatus.Rejected, request.Status);
Assert.Equal("Платёж не найден", request.RejectionReason);
}
[Fact]
public void Confirm_WhenAlreadyDecided_Throws()
{
var request = PaymentRequest.Create(Guid.NewGuid(), PaymentPeriod.Quarter, 1500);
request.Cancel();
Assert.Throws<DomainException>(() => request.Confirm(Guid.NewGuid()));
}
}
@@ -137,6 +137,62 @@ public class VpnConfigTests
Assert.Equal(ConfigStatus.Revoked, config.Status);
}
[Fact]
public void Suspend_WhenActive_SetsExpired()
{
var config = VpnConfig.Create(Guid.NewGuid(), Guid.NewGuid(), VpnProtocol.Vless, null);
config.Suspend();
Assert.Equal(ConfigStatus.Expired, config.Status);
}
[Fact]
public void Suspend_WhenDisabledByAdmin_DoesNotOverrideBlock()
{
// Suspend (биллинг) не должен путать своё состояние с Disable (блокировка админом) — иначе
// Resume() ошибочно вернёт в Active конфиг, погашенный не за неуплату.
var config = VpnConfig.Create(Guid.NewGuid(), Guid.NewGuid(), VpnProtocol.Vless, null);
config.Disable();
config.Suspend();
Assert.Equal(ConfigStatus.Disabled, config.Status);
}
[Fact]
public void Resume_WhenExpired_ReturnsToActive()
{
var config = VpnConfig.Create(Guid.NewGuid(), Guid.NewGuid(), VpnProtocol.Vless, null);
config.Suspend();
config.Resume();
Assert.Equal(ConfigStatus.Active, config.Status);
}
[Fact]
public void Resume_WhenDisabledByAdmin_DoesNotResurrect()
{
var config = VpnConfig.Create(Guid.NewGuid(), Guid.NewGuid(), VpnProtocol.Vless, null);
config.Disable();
config.Resume();
Assert.Equal(ConfigStatus.Disabled, config.Status);
}
[Fact]
public void SetBillingExpiry_SetsExpiresAt()
{
var config = VpnConfig.Create(Guid.NewGuid(), Guid.NewGuid(), VpnProtocol.Vless, null);
var expiresAt = DateTimeOffset.UtcNow.AddMonths(3);
config.SetBillingExpiry(expiresAt);
Assert.Equal(expiresAt, config.ExpiresAt);
}
[Fact]
public void UpdateTraffic_SetsBytesAndLastSyncAt()
{
@@ -0,0 +1,86 @@
using System.Net;
using System.Net.Http.Json;
using PnvPanel.IntegrationTests.TestSupport;
using Xunit;
using static PnvPanel.IntegrationTests.TestSupport.AuthTestHelper;
namespace PnvPanel.IntegrationTests.Billing;
[Collection(IntegrationTestCollection.Name)]
public class BillingFlowTests(PnvPanelWebApplicationFactory factory)
{
private sealed record RoleResponse(Guid Id, string Name, bool BillingEnabled);
private sealed record ActivationRequestResponse(Guid Id);
private sealed record BillingStatusResponse(
bool BillingEnabled,
DateTimeOffset? PaidUntil,
bool Suspended,
string RequisitesText,
object? ActiveRequest
);
/// <summary>
/// Проверяет сквозной путь, недоступный unit-тестам (RoleService — Infrastructure/Identity):
/// назначение billing-роли автоматически выдаёт грейс-период, и он виден пользователю через
/// /api/billing/status.
/// </summary>
[Fact]
public async Task AssigningBillingRole_GrantsGracePeriod_VisibleInBillingStatus()
{
using var adminClient = factory.CreateClient();
var adminToken = await LoginAsAdminAsync(adminClient);
adminClient.UseBearerToken(adminToken);
var createRoleResponse = await adminClient.PostJsonAsync(
"/api/admin/roles",
new
{
name = $"billing_{Guid.NewGuid():N}"[..20],
maxConfigs = 5,
maxIpLimit = -1,
billingEnabled = true,
}
);
Assert.Equal(HttpStatusCode.OK, createRoleResponse.StatusCode);
var role = await createRoleResponse.ReadAsAsync<RoleResponse>();
Assert.True(role!.BillingEnabled);
using var userClient = factory.CreateClient();
var userName = $"bill_{Guid.NewGuid():N}"[..20];
var (userId, userToken) = await RegisterAndLoginAsync(userClient, userName, "P@ssw0rd123");
userClient.UseBearerToken(userToken);
var activationRequestResponse = await userClient.PostJsonAsync(
"/api/activation/request",
new { comment = (string?)null }
);
var activationRequest =
await activationRequestResponse.ReadAsAsync<ActivationRequestResponse>();
var approveActivationResponse = await adminClient.PostAsync(
$"/api/admin/activation-requests/{activationRequest!.Id}/approve",
content: null
);
Assert.Equal(HttpStatusCode.NoContent, approveActivationResponse.StatusCode);
var assignRoleResponse = await adminClient.PatchAsJsonAsync(
$"/api/admin/users/{userId}/role",
new { roleId = role.Id },
PnvPanel.IntegrationTests.TestSupport.HttpClientJsonExtensions.JsonOptions
);
Assert.Equal(HttpStatusCode.NoContent, assignRoleResponse.StatusCode);
var beforeCheck = DateTimeOffset.UtcNow;
var statusResponse = await userClient.GetAsync("/api/billing/status");
Assert.Equal(HttpStatusCode.OK, statusResponse.StatusCode);
var status = await statusResponse.ReadAsAsync<BillingStatusResponse>();
Assert.True(status!.BillingEnabled);
Assert.False(status.Suspended);
Assert.NotNull(status.PaidUntil);
// Дефолтный грейс — 7 дней (BillingSettings.DefaultGraceDays), пока админ не настроил своё.
Assert.True(status.PaidUntil > beforeCheck.AddDays(6));
Assert.True(status.PaidUntil < beforeCheck.AddDays(8));
}
}