Implement extension request and gift functionalities in billing system
- Added new endpoints for creating and managing extension requests, allowing users to request billing period extensions. - Implemented admin approval processes for extension requests via Telegram, including inline buttons for approval and rejection. - Introduced a gifting feature for admins to grant additional billing days directly to users without a request. - Updated the support ticket model to accommodate extension requests and their associated properties. - Enhanced the Telegram notifier to inform admins of new extension requests and notify users of approval or rejection. - Updated frontend components to support the new extension request and gifting functionalities, including user interfaces for managing these features. - Revised API documentation to reflect the new endpoints and their usage in the billing context.
This commit is contained in:
@@ -26,6 +26,7 @@ public static class AdminBillingEndpoints
|
||||
admin
|
||||
.MapPost("/requests/{id:guid}/reject", RejectRequest)
|
||||
.Produces(StatusCodes.Status204NoContent);
|
||||
admin.MapPost("/gift", GrantGift).Produces(StatusCodes.Status204NoContent);
|
||||
|
||||
return app;
|
||||
}
|
||||
@@ -80,6 +81,19 @@ public static class AdminBillingEndpoints
|
||||
);
|
||||
return result.ToHttpResult();
|
||||
}
|
||||
|
||||
private static async Task<IResult> GrantGift(
|
||||
GrantGiftBody body,
|
||||
ISender sender,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var result = await sender.Send(
|
||||
new GrantBillingGiftCommand(body.UserId, body.Days),
|
||||
cancellationToken
|
||||
);
|
||||
return result.ToHttpResult();
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record ListPaymentRequestsRequest(
|
||||
@@ -89,3 +103,5 @@ public sealed record ListPaymentRequestsRequest(
|
||||
);
|
||||
|
||||
public sealed record RejectPaymentRequestBody(string? Reason);
|
||||
|
||||
public sealed record GrantGiftBody(Guid UserId, int Days);
|
||||
|
||||
@@ -33,6 +33,12 @@ public static class AdminSupportEndpoints
|
||||
admin
|
||||
.MapPost("/tickets/{id:guid}/reject", RejectRoleRequest)
|
||||
.Produces(StatusCodes.Status204NoContent);
|
||||
admin
|
||||
.MapPost("/tickets/{id:guid}/approve-extension", ApproveExtensionRequest)
|
||||
.Produces(StatusCodes.Status204NoContent);
|
||||
admin
|
||||
.MapPost("/tickets/{id:guid}/reject-extension", RejectExtensionRequest)
|
||||
.Produces(StatusCodes.Status204NoContent);
|
||||
|
||||
return app;
|
||||
}
|
||||
@@ -119,6 +125,32 @@ public static class AdminSupportEndpoints
|
||||
);
|
||||
return result.ToHttpResult();
|
||||
}
|
||||
|
||||
private static async Task<IResult> ApproveExtensionRequest(
|
||||
Guid id,
|
||||
ISender sender,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var result = await sender.Send(new ApproveExtensionRequestCommand(id), cancellationToken);
|
||||
return result.ToHttpResult();
|
||||
}
|
||||
|
||||
private static async Task<IResult> RejectExtensionRequest(
|
||||
Guid id,
|
||||
RejectExtensionRequestBody body,
|
||||
ISender sender,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var result = await sender.Send(
|
||||
new RejectExtensionRequestCommand(id, body.Reason),
|
||||
cancellationToken
|
||||
);
|
||||
return result.ToHttpResult();
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record RejectRoleRequestBody(string? Reason);
|
||||
|
||||
public sealed record RejectExtensionRequestBody(string? Reason);
|
||||
|
||||
@@ -6,6 +6,7 @@ using PnvPanel.Application.Common.Models;
|
||||
using PnvPanel.Application.Support;
|
||||
using PnvPanel.Application.Support.AddComment;
|
||||
using PnvPanel.Application.Support.CreateBugReport;
|
||||
using PnvPanel.Application.Support.CreateExtensionRequest;
|
||||
using PnvPanel.Application.Support.CreateRoleRequest;
|
||||
using PnvPanel.Application.Support.GetAttachment;
|
||||
using PnvPanel.Application.Support.GetSupportPricing;
|
||||
@@ -30,6 +31,9 @@ public static class SupportEndpoints
|
||||
.DisableAntiforgery()
|
||||
.Produces<TicketDetailDto>();
|
||||
group.MapPost("/tickets/role-requests", CreateRoleRequest).Produces<TicketDetailDto>();
|
||||
group
|
||||
.MapPost("/tickets/extension-requests", CreateExtensionRequest)
|
||||
.Produces<TicketDetailDto>();
|
||||
group.MapGet("/tickets", ListMyTickets).Produces<PagedList<TicketSummaryDto>>();
|
||||
group.MapGet("/tickets/{id:guid}", GetTicket).Produces<TicketDetailDto>();
|
||||
group
|
||||
@@ -89,6 +93,17 @@ public static class SupportEndpoints
|
||||
return result.ToHttpResult();
|
||||
}
|
||||
|
||||
private static async Task<IResult> CreateExtensionRequest(
|
||||
CreateExtensionRequestBody body,
|
||||
ISender sender,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var command = new CreateExtensionRequestTicketCommand(body.RequestedDays, body.Justification);
|
||||
var result = await sender.Send(command, cancellationToken);
|
||||
return result.ToHttpResult();
|
||||
}
|
||||
|
||||
private static async Task<IResult> ListMyTickets(
|
||||
[AsParameters] ListTicketsRequest request,
|
||||
ISender sender,
|
||||
@@ -175,6 +190,8 @@ public sealed record CreateRoleRequestBody(
|
||||
string Justification
|
||||
);
|
||||
|
||||
public sealed record CreateExtensionRequestBody(int RequestedDays, string Justification);
|
||||
|
||||
public sealed record ListTicketsRequest(
|
||||
TicketType? Type,
|
||||
TicketStatus? Status,
|
||||
|
||||
@@ -489,6 +489,55 @@ public sealed class PnvBotUpdateHandler(
|
||||
|
||||
break;
|
||||
}
|
||||
case "erq":
|
||||
{
|
||||
if (!await TrySetAdminCurrentUserAsync(services, fromId, cancellationToken))
|
||||
{
|
||||
await botClient.AnswerCallbackQuery(
|
||||
callback.Id,
|
||||
"Недостаточно прав.",
|
||||
cancellationToken: cancellationToken
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
var result =
|
||||
parts[1] == "approve"
|
||||
? await sender.Send(
|
||||
new ApproveExtensionRequestCommand(requestId),
|
||||
cancellationToken
|
||||
)
|
||||
: await sender.Send(
|
||||
new RejectExtensionRequestCommand(requestId, Reason: null),
|
||||
cancellationToken
|
||||
);
|
||||
|
||||
await botClient.AnswerCallbackQuery(
|
||||
callback.Id,
|
||||
result.IsSuccess ? "Готово" : result.Error.Message,
|
||||
cancellationToken: cancellationToken
|
||||
);
|
||||
if (callback.Message is not null)
|
||||
{
|
||||
var statusText = result.IsSuccess
|
||||
? (
|
||||
parts[1] == "approve"
|
||||
? "✅ Заявка на продление одобрена."
|
||||
: "❌ Заявка отклонена."
|
||||
)
|
||||
: $"⚠️ {result.Error.Message}";
|
||||
var text = $"{Escape(callback.Message.Text ?? "")}\n\n{statusText}";
|
||||
await botClient.EditMessageText(
|
||||
chatId.Value,
|
||||
callback.Message.Id,
|
||||
text,
|
||||
parseMode: ParseMode.Html,
|
||||
cancellationToken: cancellationToken
|
||||
);
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
case "pay":
|
||||
{
|
||||
if (!await TrySetAdminCurrentUserAsync(services, fromId, cancellationToken))
|
||||
|
||||
@@ -144,6 +144,47 @@ internal sealed class TelegramNotifier(
|
||||
}
|
||||
}
|
||||
|
||||
public async Task NotifyAdminsExtensionRequestCreatedAsync(
|
||||
Guid ticketId,
|
||||
string userName,
|
||||
int requestedDays,
|
||||
string justification,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(options.Value.BotToken))
|
||||
return;
|
||||
|
||||
var text =
|
||||
$"🆕 Заявка на продление от <b>{Escape(userName)}</b> — {requestedDays} дн.\nОбоснование: {Escape(justification)}";
|
||||
|
||||
var keyboard = new InlineKeyboardMarkup(
|
||||
new[]
|
||||
{
|
||||
InlineKeyboardButton.WithCallbackData("✅ Одобрить", $"erq:approve:{ticketId}"),
|
||||
InlineKeyboardButton.WithCallbackData("❌ Отклонить", $"erq:reject:{ticketId}"),
|
||||
}
|
||||
);
|
||||
|
||||
foreach (var adminId in options.Value.ParseAdminTelegramUserIds())
|
||||
{
|
||||
try
|
||||
{
|
||||
await botClient.SendMessage(
|
||||
adminId,
|
||||
text,
|
||||
parseMode: ParseMode.Html,
|
||||
replyMarkup: keyboard,
|
||||
cancellationToken: cancellationToken
|
||||
);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Админ мог не запускать бота (нет чата с ботом) — пропускаем, не валим команду.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public async Task NotifyAdminsTicketReopenedAsync(
|
||||
Guid ticketId,
|
||||
string userName,
|
||||
|
||||
+9
-59
@@ -7,7 +7,6 @@ using PnvPanel.Application.Common.Messaging;
|
||||
using PnvPanel.Application.Common.Models;
|
||||
using PnvPanel.Domain.Audit;
|
||||
using PnvPanel.Domain.Billing;
|
||||
using PnvPanel.Domain.Configs;
|
||||
|
||||
namespace PnvPanel.Application.Admin.Billing;
|
||||
|
||||
@@ -65,64 +64,15 @@ public sealed class ConfirmPaymentRequestCommandHandler(
|
||||
|
||||
request.Confirm(adminId);
|
||||
|
||||
var configs = await dbContext
|
||||
.VpnConfigs.Where(c =>
|
||||
c.UserId == request.UserId
|
||||
&& (c.Status == ConfigStatus.Active || c.Status == ConfigStatus.Expired)
|
||||
)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
foreach (var config in configs)
|
||||
{
|
||||
if (config.Status == ConfigStatus.Expired)
|
||||
{
|
||||
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)
|
||||
{
|
||||
var updateResult = await gateway.UpdateClientAsync(
|
||||
node,
|
||||
inbound.RemoteInboundId,
|
||||
config.ClientExternalId,
|
||||
config.Protocol,
|
||||
config.Label ?? config.ClientEmail,
|
||||
enable: true,
|
||||
cancellationToken
|
||||
);
|
||||
|
||||
if (!updateResult.IsSuccess)
|
||||
{
|
||||
// Нода недоступна — не трогаем локальный статус, подхватится следующим
|
||||
// подтверждением/циклом BillingService (идемпотентно).
|
||||
logger.LogWarning(
|
||||
"Failed to enable client for config {ConfigId} on node {NodeId} while confirming payment {RequestId}: {Error}",
|
||||
config.Id,
|
||||
node.Id,
|
||||
request.Id,
|
||||
updateResult.Error
|
||||
);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
config.Resume();
|
||||
await notifier.NotifyConfigStatusChangedAsync(
|
||||
config.UserId,
|
||||
config.Id,
|
||||
config.Status,
|
||||
cancellationToken
|
||||
);
|
||||
}
|
||||
|
||||
config.SetBillingExpiry(newPaidUntil);
|
||||
}
|
||||
await BillingConfigResumer.ResumeConfigsAsync(
|
||||
dbContext,
|
||||
gateway,
|
||||
notifier,
|
||||
logger,
|
||||
request.UserId,
|
||||
newPaidUntil,
|
||||
cancellationToken
|
||||
);
|
||||
|
||||
dbContext.AuditLogs.Add(
|
||||
AuditLog.Create(
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
using PnvPanel.Application.Common.Messaging;
|
||||
using PnvPanel.Application.Common.Models;
|
||||
|
||||
namespace PnvPanel.Application.Admin.Billing;
|
||||
|
||||
/// <summary>Админ дарит пользователю N дней подписки — продлевает BillingPaidUntil от
|
||||
/// max(текущий, сейчас), возвращает приостановленные конфиги, шлёт уведомление пользователю.</summary>
|
||||
public sealed record GrantBillingGiftCommand(Guid UserId, int Days) : ICommand<Result>;
|
||||
@@ -0,0 +1,76 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using PnvPanel.Application.Admin.Users;
|
||||
using PnvPanel.Application.Auth;
|
||||
using PnvPanel.Application.Billing;
|
||||
using PnvPanel.Application.Common.Interfaces;
|
||||
using PnvPanel.Application.Common.Messaging;
|
||||
using PnvPanel.Application.Common.Models;
|
||||
using PnvPanel.Domain.Audit;
|
||||
|
||||
namespace PnvPanel.Application.Admin.Billing;
|
||||
|
||||
public sealed class GrantBillingGiftCommandHandler(
|
||||
IAppDbContext dbContext,
|
||||
IIdentityService identityService,
|
||||
IXuiPanelGateway gateway,
|
||||
IRealtimeNotifier notifier,
|
||||
ITelegramNotifier telegramNotifier,
|
||||
ICurrentUser currentUser,
|
||||
ILogger<GrantBillingGiftCommandHandler> logger
|
||||
) : ICommandHandler<GrantBillingGiftCommand, Result>
|
||||
{
|
||||
public async Task<Result> Handle(GrantBillingGiftCommand command, CancellationToken cancellationToken)
|
||||
{
|
||||
if (currentUser.UserId is not { } adminId)
|
||||
return Result.Failure(AuthErrors.Unauthorized);
|
||||
|
||||
var profile = await identityService.GetProfileAsync(command.UserId, cancellationToken);
|
||||
if (profile is null)
|
||||
return Result.Failure(UserErrors.NotFound);
|
||||
|
||||
if (!profile.BillingEnabled)
|
||||
return Result.Failure(BillingErrors.NotEnabled);
|
||||
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var baseline = profile.BillingPaidUntil is { } paidUntil && paidUntil > now ? paidUntil : now;
|
||||
var newPaidUntil = baseline.AddDays(command.Days);
|
||||
|
||||
var extendResult = await identityService.ExtendBillingPaidUntilAsync(
|
||||
command.UserId,
|
||||
newPaidUntil,
|
||||
cancellationToken
|
||||
);
|
||||
if (!extendResult.IsSuccess)
|
||||
return extendResult;
|
||||
|
||||
await BillingConfigResumer.ResumeConfigsAsync(
|
||||
dbContext,
|
||||
gateway,
|
||||
notifier,
|
||||
logger,
|
||||
command.UserId,
|
||||
newPaidUntil,
|
||||
cancellationToken
|
||||
);
|
||||
|
||||
dbContext.AuditLogs.Add(
|
||||
AuditLog.Create(
|
||||
adminId,
|
||||
"BillingGiftGranted",
|
||||
"User",
|
||||
command.UserId.ToString(),
|
||||
metadata: $"{{\"days\":{command.Days}}}",
|
||||
AuditSource.Web
|
||||
)
|
||||
);
|
||||
|
||||
await telegramNotifier.NotifyUserAsync(
|
||||
command.UserId,
|
||||
$"🎁 Вам подарено {command.Days} дн. подписки! Доступ продлён до {newPaidUntil:dd.MM.yyyy}.",
|
||||
"/billing",
|
||||
cancellationToken
|
||||
);
|
||||
|
||||
return Result.Success();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
using FluentValidation;
|
||||
|
||||
namespace PnvPanel.Application.Admin.Billing;
|
||||
|
||||
public sealed class GrantBillingGiftCommandValidator : AbstractValidator<GrantBillingGiftCommand>
|
||||
{
|
||||
public GrantBillingGiftCommandValidator()
|
||||
{
|
||||
RuleFor(x => x.Days).GreaterThan(0).LessThanOrEqualTo(365);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
using PnvPanel.Application.Common.Messaging;
|
||||
using PnvPanel.Application.Common.Models;
|
||||
|
||||
namespace PnvPanel.Application.Admin.Support;
|
||||
|
||||
/// <summary>Одобрение продлевает AppUser.BillingPaidUntil на RequestedDays (от max(текущий, сейчас))
|
||||
/// и возвращает в Active конфиги, приостановленные за неуплату — см. BillingConfigResumer.</summary>
|
||||
public sealed record ApproveExtensionRequestCommand(Guid TicketId) : ICommand<Result>;
|
||||
+94
@@ -0,0 +1,94 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using PnvPanel.Application.Auth;
|
||||
using PnvPanel.Application.Billing;
|
||||
using PnvPanel.Application.Common.Interfaces;
|
||||
using PnvPanel.Application.Common.Messaging;
|
||||
using PnvPanel.Application.Common.Models;
|
||||
using PnvPanel.Application.Support;
|
||||
using PnvPanel.Domain.Audit;
|
||||
using PnvPanel.Domain.Support;
|
||||
|
||||
namespace PnvPanel.Application.Admin.Support;
|
||||
|
||||
public sealed class ApproveExtensionRequestCommandHandler(
|
||||
IAppDbContext dbContext,
|
||||
IIdentityService identityService,
|
||||
IXuiPanelGateway gateway,
|
||||
IRealtimeNotifier notifier,
|
||||
ITelegramNotifier telegramNotifier,
|
||||
ICurrentUser currentUser,
|
||||
ILogger<ApproveExtensionRequestCommandHandler> logger
|
||||
) : ICommandHandler<ApproveExtensionRequestCommand, Result>
|
||||
{
|
||||
public async Task<Result> Handle(
|
||||
ApproveExtensionRequestCommand command,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
if (currentUser.UserId is not { } adminId)
|
||||
return Result.Failure(AuthErrors.Unauthorized);
|
||||
|
||||
var ticket = await dbContext.SupportTickets.FirstOrDefaultAsync(
|
||||
t => t.Id == command.TicketId,
|
||||
cancellationToken
|
||||
);
|
||||
if (ticket is null)
|
||||
return Result.Failure(SupportErrors.NotFound);
|
||||
|
||||
if (ticket.Type != TicketType.ExtensionRequest)
|
||||
return Result.Failure(SupportErrors.NotExtensionRequest);
|
||||
|
||||
if (ticket.Status != TicketStatus.Open)
|
||||
return Result.Failure(SupportErrors.NotOpen);
|
||||
|
||||
var profile = await identityService.GetProfileAsync(ticket.UserId, cancellationToken);
|
||||
if (profile is null)
|
||||
return Result.Failure(AuthErrors.Unauthorized);
|
||||
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var baseline = profile.BillingPaidUntil is { } paidUntil && paidUntil > now ? paidUntil : now;
|
||||
var newPaidUntil = baseline.AddDays(ticket.RequestedDays!.Value);
|
||||
|
||||
var extendResult = await identityService.ExtendBillingPaidUntilAsync(
|
||||
ticket.UserId,
|
||||
newPaidUntil,
|
||||
cancellationToken
|
||||
);
|
||||
if (!extendResult.IsSuccess)
|
||||
return extendResult;
|
||||
|
||||
await BillingConfigResumer.ResumeConfigsAsync(
|
||||
dbContext,
|
||||
gateway,
|
||||
notifier,
|
||||
logger,
|
||||
ticket.UserId,
|
||||
newPaidUntil,
|
||||
cancellationToken
|
||||
);
|
||||
|
||||
ticket.Resolve();
|
||||
|
||||
dbContext.AuditLogs.Add(
|
||||
AuditLog.Create(
|
||||
adminId,
|
||||
"ExtensionRequestApproved",
|
||||
"SupportTicket",
|
||||
ticket.Id.ToString(),
|
||||
metadata: null,
|
||||
AuditSource.Web
|
||||
)
|
||||
);
|
||||
|
||||
await notifier.NotifyTicketUpdatedAsync(ticket.Id, ticket.UserId, cancellationToken);
|
||||
await telegramNotifier.NotifyUserAsync(
|
||||
ticket.UserId,
|
||||
$"✅ Заявка на продление одобрена. Доступ продлён до {newPaidUntil:dd.MM.yyyy}.",
|
||||
$"/support?ticket={ticket.Id}",
|
||||
cancellationToken
|
||||
);
|
||||
|
||||
return Result.Success();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
using PnvPanel.Application.Common.Messaging;
|
||||
using PnvPanel.Application.Common.Models;
|
||||
|
||||
namespace PnvPanel.Application.Admin.Support;
|
||||
|
||||
public sealed record RejectExtensionRequestCommand(Guid TicketId, string? Reason) : ICommand<Result>;
|
||||
+66
@@ -0,0 +1,66 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using PnvPanel.Application.Auth;
|
||||
using PnvPanel.Application.Common.Interfaces;
|
||||
using PnvPanel.Application.Common.Messaging;
|
||||
using PnvPanel.Application.Common.Models;
|
||||
using PnvPanel.Application.Support;
|
||||
using PnvPanel.Domain.Audit;
|
||||
using PnvPanel.Domain.Support;
|
||||
|
||||
namespace PnvPanel.Application.Admin.Support;
|
||||
|
||||
public sealed class RejectExtensionRequestCommandHandler(
|
||||
IAppDbContext dbContext,
|
||||
IRealtimeNotifier notifier,
|
||||
ITelegramNotifier telegramNotifier,
|
||||
ICurrentUser currentUser
|
||||
) : ICommandHandler<RejectExtensionRequestCommand, Result>
|
||||
{
|
||||
public async Task<Result> Handle(
|
||||
RejectExtensionRequestCommand command,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
if (currentUser.UserId is not { } adminId)
|
||||
return Result.Failure(AuthErrors.Unauthorized);
|
||||
|
||||
var ticket = await dbContext.SupportTickets.FirstOrDefaultAsync(
|
||||
t => t.Id == command.TicketId,
|
||||
cancellationToken
|
||||
);
|
||||
if (ticket is null)
|
||||
return Result.Failure(SupportErrors.NotFound);
|
||||
|
||||
if (ticket.Type != TicketType.ExtensionRequest)
|
||||
return Result.Failure(SupportErrors.NotExtensionRequest);
|
||||
|
||||
if (ticket.Status == TicketStatus.Closed)
|
||||
return Result.Failure(SupportErrors.AlreadyClosed);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(command.Reason))
|
||||
dbContext.TicketComments.Add(TicketComment.Create(ticket.Id, adminId, command.Reason));
|
||||
|
||||
ticket.Close();
|
||||
|
||||
dbContext.AuditLogs.Add(
|
||||
AuditLog.Create(
|
||||
adminId,
|
||||
"ExtensionRequestRejected",
|
||||
"SupportTicket",
|
||||
ticket.Id.ToString(),
|
||||
metadata: null,
|
||||
AuditSource.Web
|
||||
)
|
||||
);
|
||||
|
||||
await notifier.NotifyTicketUpdatedAsync(ticket.Id, ticket.UserId, cancellationToken);
|
||||
await telegramNotifier.NotifyUserAsync(
|
||||
ticket.UserId,
|
||||
"❌ Ваша заявка на продление отклонена.",
|
||||
$"/support?ticket={ticket.Id}",
|
||||
cancellationToken
|
||||
);
|
||||
|
||||
return Result.Success();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using PnvPanel.Application.Common.Interfaces;
|
||||
using PnvPanel.Domain.Configs;
|
||||
|
||||
namespace PnvPanel.Application.Billing;
|
||||
|
||||
/// <summary>
|
||||
/// Общая часть "продлить оплату" — возвращает в Active конфиги, приостановленные за неуплату
|
||||
/// (Expired), и синхронизирует ExpiresAt на все конфиги пользователя. Используется при подтверждении
|
||||
/// заявки на оплату, одобрении заявки на продление и выдаче гифт-дней админом — расчёт самого
|
||||
/// newPaidUntil (AddMonths для платежа, AddDays для продления/гифта) остаётся на вызывающей стороне,
|
||||
/// как и AppUser.BillingPaidUntil (см. IIdentityService.ExtendBillingPaidUntilAsync, зовётся отдельно
|
||||
/// до этого helper'а).
|
||||
/// </summary>
|
||||
internal static class BillingConfigResumer
|
||||
{
|
||||
public static async Task ResumeConfigsAsync(
|
||||
IAppDbContext dbContext,
|
||||
IXuiPanelGateway gateway,
|
||||
IRealtimeNotifier notifier,
|
||||
ILogger logger,
|
||||
Guid userId,
|
||||
DateTimeOffset newPaidUntil,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var configs = await dbContext
|
||||
.VpnConfigs.Where(c =>
|
||||
c.UserId == userId
|
||||
&& (c.Status == ConfigStatus.Active || c.Status == ConfigStatus.Expired)
|
||||
)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
foreach (var config in configs)
|
||||
{
|
||||
if (config.Status == ConfigStatus.Expired)
|
||||
{
|
||||
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)
|
||||
{
|
||||
var updateResult = await gateway.UpdateClientAsync(
|
||||
node,
|
||||
inbound.RemoteInboundId,
|
||||
config.ClientExternalId,
|
||||
config.Protocol,
|
||||
config.Label ?? config.ClientEmail,
|
||||
enable: true,
|
||||
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,
|
||||
config.Id,
|
||||
config.Status,
|
||||
cancellationToken
|
||||
);
|
||||
}
|
||||
|
||||
config.SetBillingExpiry(newPaidUntil);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -62,4 +62,14 @@ public interface ITelegramNotifier
|
||||
int amount,
|
||||
CancellationToken cancellationToken
|
||||
);
|
||||
|
||||
/// <summary>Заявка на продление оплаченного периода — инлайн-кнопки «Одобрить/Отклонить», решается
|
||||
/// полностью в Telegram (аналогично заявке на роль).</summary>
|
||||
Task NotifyAdminsExtensionRequestCreatedAsync(
|
||||
Guid ticketId,
|
||||
string userName,
|
||||
int requestedDays,
|
||||
string justification,
|
||||
CancellationToken cancellationToken
|
||||
);
|
||||
}
|
||||
|
||||
+1
@@ -77,6 +77,7 @@ public sealed class CreateBugReportTicketCommandHandler(
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
ticket.CreatedAt,
|
||||
[commentDto]
|
||||
);
|
||||
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
using PnvPanel.Application.Common.Messaging;
|
||||
using PnvPanel.Application.Common.Models;
|
||||
|
||||
namespace PnvPanel.Application.Support.CreateExtensionRequest;
|
||||
|
||||
/// <summary>Заявка на продление оплаченного периода — только для ролей с включённым биллингом
|
||||
/// (AppRole.BillingEnabled), см. CreateExtensionRequestTicketCommandHandler.</summary>
|
||||
public sealed record CreateExtensionRequestTicketCommand(int RequestedDays, string Justification)
|
||||
: ICommand<Result<TicketDetailDto>>,
|
||||
IRequiresActivation;
|
||||
+94
@@ -0,0 +1,94 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using PnvPanel.Application.Auth;
|
||||
using PnvPanel.Application.Billing;
|
||||
using PnvPanel.Application.Common.Interfaces;
|
||||
using PnvPanel.Application.Common.Messaging;
|
||||
using PnvPanel.Application.Common.Models;
|
||||
using PnvPanel.Domain.Support;
|
||||
|
||||
namespace PnvPanel.Application.Support.CreateExtensionRequest;
|
||||
|
||||
public sealed class CreateExtensionRequestTicketCommandHandler(
|
||||
IAppDbContext dbContext,
|
||||
IIdentityService identityService,
|
||||
IRealtimeNotifier notifier,
|
||||
ITelegramNotifier telegramNotifier,
|
||||
ICurrentUser currentUser
|
||||
) : ICommandHandler<CreateExtensionRequestTicketCommand, Result<TicketDetailDto>>
|
||||
{
|
||||
public async Task<Result<TicketDetailDto>> Handle(
|
||||
CreateExtensionRequestTicketCommand command,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
if (currentUser.UserId is not { } userId)
|
||||
return Result.Failure<TicketDetailDto>(AuthErrors.Unauthorized);
|
||||
|
||||
var profile = await identityService.GetProfileAsync(userId, cancellationToken);
|
||||
if (profile is null)
|
||||
return Result.Failure<TicketDetailDto>(AuthErrors.Unauthorized);
|
||||
|
||||
if (!profile.BillingEnabled)
|
||||
return Result.Failure<TicketDetailDto>(BillingErrors.NotEnabled);
|
||||
|
||||
var hasPending = await dbContext.SupportTickets.AnyAsync(
|
||||
t =>
|
||||
t.UserId == userId
|
||||
&& t.Type == TicketType.ExtensionRequest
|
||||
&& t.Status == TicketStatus.Open,
|
||||
cancellationToken
|
||||
);
|
||||
if (hasPending)
|
||||
return Result.Failure<TicketDetailDto>(SupportErrors.ExtensionRequestAlreadyPending);
|
||||
|
||||
var ticket = SupportTicket.CreateExtensionRequest(userId, command.RequestedDays);
|
||||
dbContext.SupportTickets.Add(ticket);
|
||||
|
||||
var comment = TicketComment.Create(ticket.Id, userId, command.Justification);
|
||||
dbContext.TicketComments.Add(comment);
|
||||
|
||||
var userName = currentUser.UserName ?? userId.ToString();
|
||||
|
||||
await notifier.NotifyTicketCreatedAsync(
|
||||
ticket.Id,
|
||||
userId,
|
||||
userName,
|
||||
ticket.Type,
|
||||
cancellationToken
|
||||
);
|
||||
await telegramNotifier.NotifyAdminsExtensionRequestCreatedAsync(
|
||||
ticket.Id,
|
||||
userName,
|
||||
command.RequestedDays,
|
||||
command.Justification,
|
||||
cancellationToken
|
||||
);
|
||||
|
||||
var commentDto = new TicketCommentDto(
|
||||
comment.Id,
|
||||
userId,
|
||||
userName,
|
||||
comment.Body,
|
||||
comment.CreatedAt,
|
||||
[]
|
||||
);
|
||||
|
||||
var dto = new TicketDetailDto(
|
||||
ticket.Id,
|
||||
ticket.UserId,
|
||||
userName,
|
||||
ticket.Type,
|
||||
ticket.Status,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
ticket.RequestedDays,
|
||||
ticket.CreatedAt,
|
||||
[commentDto]
|
||||
);
|
||||
|
||||
return Result.Success(dto);
|
||||
}
|
||||
}
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
using FluentValidation;
|
||||
|
||||
namespace PnvPanel.Application.Support.CreateExtensionRequest;
|
||||
|
||||
public sealed class CreateExtensionRequestTicketCommandValidator
|
||||
: AbstractValidator<CreateExtensionRequestTicketCommand>
|
||||
{
|
||||
public CreateExtensionRequestTicketCommandValidator()
|
||||
{
|
||||
RuleFor(x => x.RequestedDays).GreaterThan(0).LessThanOrEqualTo(365);
|
||||
RuleFor(x => x.Justification).NotEmpty().MaximumLength(4000);
|
||||
}
|
||||
}
|
||||
+1
@@ -108,6 +108,7 @@ public sealed class CreateRoleRequestTicketCommandHandler(
|
||||
ticket.ProposedRoleName,
|
||||
ticket.ProposedMaxConfigs,
|
||||
ticket.ProposedMaxIpLimit,
|
||||
ticket.RequestedDays,
|
||||
ticket.CreatedAt,
|
||||
[commentDto]
|
||||
);
|
||||
|
||||
@@ -48,6 +48,16 @@ public static class SupportErrors
|
||||
"Это не заявка на роль."
|
||||
);
|
||||
|
||||
public static readonly Error ExtensionRequestAlreadyPending = Error.Conflict(
|
||||
"Support.ExtensionRequestAlreadyPending",
|
||||
"У вас уже есть необработанная заявка на продление."
|
||||
);
|
||||
|
||||
public static readonly Error NotExtensionRequest = Error.Validation(
|
||||
"Support.NotExtensionRequest",
|
||||
"Это не заявка на продление."
|
||||
);
|
||||
|
||||
public static readonly Error TooManyAttachments = Error.Validation(
|
||||
"Support.TooManyAttachments",
|
||||
$"Слишком много вложений (максимум {TicketAttachmentValidation.MaxAttachments})."
|
||||
|
||||
@@ -5,6 +5,7 @@ namespace PnvPanel.Application.Support;
|
||||
/// <summary>
|
||||
/// RequestedRoleName — имя существующей роли, если RequestedRoleId задан (резолвится хендлером,
|
||||
/// на SupportTicket хранится только Id). Для новой роли имя лежит прямо в ProposedRoleName.
|
||||
/// RequestedDays — только для ExtensionRequest (продление оплаченного периода).
|
||||
/// </summary>
|
||||
public sealed record TicketDetailDto(
|
||||
Guid Id,
|
||||
@@ -17,6 +18,7 @@ public sealed record TicketDetailDto(
|
||||
string? ProposedRoleName,
|
||||
int? ProposedMaxConfigs,
|
||||
int? ProposedMaxIpLimit,
|
||||
int? RequestedDays,
|
||||
DateTimeOffset CreatedAt,
|
||||
IReadOnlyList<TicketCommentDto> Comments
|
||||
);
|
||||
|
||||
@@ -76,6 +76,7 @@ internal static class TicketMapping
|
||||
ticket.ProposedRoleName,
|
||||
ticket.ProposedMaxConfigs,
|
||||
ticket.ProposedMaxIpLimit,
|
||||
ticket.RequestedDays,
|
||||
ticket.CreatedAt,
|
||||
commentDtos
|
||||
);
|
||||
|
||||
@@ -8,7 +8,8 @@ namespace PnvPanel.Domain.Support;
|
||||
/// или параметры новой). Текст обращения и переписка — в TicketComment, отдельной таблицей (не
|
||||
/// навигационная коллекция — см. конвенцию проекта на плоских сущностях, ср. TrafficSample/VpnConfig).
|
||||
/// Для RoleRequest заполнен либо RequestedRoleId, либо Proposed* — гарантируется отдельными фабриками,
|
||||
/// а не runtime-проверкой одного универсального конструктора.
|
||||
/// а не runtime-проверкой одного универсального конструктора. Для ExtensionRequest (продление
|
||||
/// оплаченного периода — только для billing-ролей, см. AppRole.BillingEnabled) заполнен RequestedDays.
|
||||
/// </summary>
|
||||
public sealed class SupportTicket : Entity
|
||||
{
|
||||
@@ -19,6 +20,7 @@ public sealed class SupportTicket : Entity
|
||||
public string? ProposedRoleName { get; private set; }
|
||||
public int? ProposedMaxConfigs { get; private set; }
|
||||
public int? ProposedMaxIpLimit { get; private set; }
|
||||
public int? RequestedDays { get; private set; }
|
||||
public DateTimeOffset CreatedAt { get; private set; }
|
||||
|
||||
private SupportTicket() { }
|
||||
@@ -68,6 +70,19 @@ public sealed class SupportTicket : Entity
|
||||
};
|
||||
}
|
||||
|
||||
public static SupportTicket CreateExtensionRequest(Guid userId, int requestedDays)
|
||||
{
|
||||
return new SupportTicket
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
UserId = userId,
|
||||
Type = TicketType.ExtensionRequest,
|
||||
Status = TicketStatus.Open,
|
||||
RequestedDays = requestedDays,
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>Решено (в т.ч. заявка на роль одобрена — роль выдаётся оркестрацией на уровне Application).</summary>
|
||||
public void Resolve()
|
||||
{
|
||||
|
||||
@@ -4,4 +4,5 @@ public enum TicketType
|
||||
{
|
||||
BugReport,
|
||||
RoleRequest,
|
||||
ExtensionRequest,
|
||||
}
|
||||
|
||||
+1046
File diff suppressed because it is too large
Load Diff
+28
@@ -0,0 +1,28 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace PnvPanel.Infrastructure.Persistence.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddExtensionRequest : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "RequestedDays",
|
||||
table: "SupportTickets",
|
||||
type: "integer",
|
||||
nullable: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "RequestedDays",
|
||||
table: "SupportTickets");
|
||||
}
|
||||
}
|
||||
}
|
||||
+3
@@ -625,6 +625,9 @@ namespace PnvPanel.Infrastructure.Persistence.Migrations
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.Property<int?>("RequestedDays")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<Guid?>("RequestedRoleId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
|
||||
+132
@@ -0,0 +1,132 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using NSubstitute;
|
||||
using PnvPanel.Application.Admin.Billing;
|
||||
using PnvPanel.Application.Admin.Users;
|
||||
using PnvPanel.Application.Billing;
|
||||
using PnvPanel.Application.Common.Interfaces;
|
||||
using PnvPanel.Application.Common.Models;
|
||||
using PnvPanel.Application.Tests.TestSupport;
|
||||
using Xunit;
|
||||
|
||||
namespace PnvPanel.Application.Tests.Admin.Billing;
|
||||
|
||||
public class GrantBillingGiftCommandHandlerTests
|
||||
{
|
||||
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<GrantBillingGiftCommandHandler> _logger = Substitute.For<
|
||||
ILogger<GrantBillingGiftCommandHandler>
|
||||
>();
|
||||
|
||||
private static CurrentUserProfile Profile(Guid userId, bool billingEnabled, DateTimeOffset? paidUntil) =>
|
||||
new(
|
||||
userId,
|
||||
"alice",
|
||||
Guid.NewGuid(),
|
||||
"premium",
|
||||
IsActivated: true,
|
||||
IsBlocked: false,
|
||||
MaxConfigs: 5,
|
||||
MaxIpLimit: 3,
|
||||
SubscriptionToken: "sub-token",
|
||||
BillingEnabled: billingEnabled,
|
||||
BillingPaidUntil: paidUntil,
|
||||
BillingSuspended: false
|
||||
);
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_WhenBillingEnabled_ExtendsPaidUntilAndNotifiesUser()
|
||||
{
|
||||
using var dbContext = InMemoryDbContextFactory.Create();
|
||||
var adminId = Guid.NewGuid();
|
||||
var userId = Guid.NewGuid();
|
||||
_identityService
|
||||
.GetProfileAsync(userId, Arg.Any<CancellationToken>())
|
||||
.Returns(Profile(userId, billingEnabled: true, paidUntil: null));
|
||||
_identityService
|
||||
.ExtendBillingPaidUntilAsync(userId, Arg.Any<DateTimeOffset>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Result.Success());
|
||||
|
||||
var handler = new GrantBillingGiftCommandHandler(
|
||||
dbContext,
|
||||
_identityService,
|
||||
_gateway,
|
||||
_notifier,
|
||||
_telegramNotifier,
|
||||
FakeCurrentUser.Authenticated(adminId, "admin"),
|
||||
_logger
|
||||
);
|
||||
|
||||
var before = DateTimeOffset.UtcNow;
|
||||
var result = await handler.Handle(new GrantBillingGiftCommand(userId, 30), CancellationToken.None);
|
||||
|
||||
Assert.True(result.IsSuccess);
|
||||
await _identityService
|
||||
.Received(1)
|
||||
.ExtendBillingPaidUntilAsync(
|
||||
userId,
|
||||
Arg.Is<DateTimeOffset>(d => d >= before.AddDays(30).AddMinutes(-1)),
|
||||
Arg.Any<CancellationToken>()
|
||||
);
|
||||
await _telegramNotifier
|
||||
.Received(1)
|
||||
.NotifyUserAsync(
|
||||
userId,
|
||||
Arg.Is<string>(m => m.Contains("30")),
|
||||
Arg.Any<string?>(),
|
||||
Arg.Any<CancellationToken>()
|
||||
);
|
||||
}
|
||||
|
||||
[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, paidUntil: null));
|
||||
|
||||
var handler = new GrantBillingGiftCommandHandler(
|
||||
dbContext,
|
||||
_identityService,
|
||||
_gateway,
|
||||
_notifier,
|
||||
_telegramNotifier,
|
||||
FakeCurrentUser.Authenticated(Guid.NewGuid(), "admin"),
|
||||
_logger
|
||||
);
|
||||
|
||||
var result = await handler.Handle(new GrantBillingGiftCommand(userId, 30), CancellationToken.None);
|
||||
|
||||
Assert.False(result.IsSuccess);
|
||||
Assert.Equal(BillingErrors.NotEnabled, result.Error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_WhenUserNotFound_ReturnsUserNotFound()
|
||||
{
|
||||
using var dbContext = InMemoryDbContextFactory.Create();
|
||||
var userId = Guid.NewGuid();
|
||||
_identityService
|
||||
.GetProfileAsync(userId, Arg.Any<CancellationToken>())
|
||||
.Returns((CurrentUserProfile?)null);
|
||||
|
||||
var handler = new GrantBillingGiftCommandHandler(
|
||||
dbContext,
|
||||
_identityService,
|
||||
_gateway,
|
||||
_notifier,
|
||||
_telegramNotifier,
|
||||
FakeCurrentUser.Authenticated(Guid.NewGuid(), "admin"),
|
||||
_logger
|
||||
);
|
||||
|
||||
var result = await handler.Handle(new GrantBillingGiftCommand(userId, 30), CancellationToken.None);
|
||||
|
||||
Assert.False(result.IsSuccess);
|
||||
Assert.Equal(UserErrors.NotFound, result.Error);
|
||||
}
|
||||
}
|
||||
+189
@@ -0,0 +1,189 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using NSubstitute;
|
||||
using PnvPanel.Application.Admin.Support;
|
||||
using PnvPanel.Application.Common.Interfaces;
|
||||
using PnvPanel.Application.Common.Models;
|
||||
using PnvPanel.Application.Support;
|
||||
using PnvPanel.Application.Tests.TestSupport;
|
||||
using PnvPanel.Domain.Configs;
|
||||
using PnvPanel.Domain.Inbounds;
|
||||
using PnvPanel.Domain.Nodes;
|
||||
using PnvPanel.Domain.Support;
|
||||
using Xunit;
|
||||
|
||||
namespace PnvPanel.Application.Tests.Admin.Support;
|
||||
|
||||
public class ApproveExtensionRequestCommandHandlerTests
|
||||
{
|
||||
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<ApproveExtensionRequestCommandHandler> _logger = Substitute.For<
|
||||
ILogger<ApproveExtensionRequestCommandHandler>
|
||||
>();
|
||||
|
||||
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
|
||||
);
|
||||
|
||||
private ApproveExtensionRequestCommandHandler CreateHandler(
|
||||
PnvPanel.Infrastructure.Persistence.AppDbContext dbContext,
|
||||
Guid adminId
|
||||
) =>
|
||||
new(
|
||||
dbContext,
|
||||
_identityService,
|
||||
_gateway,
|
||||
_notifier,
|
||||
_telegramNotifier,
|
||||
FakeCurrentUser.Authenticated(adminId, "admin"),
|
||||
_logger
|
||||
);
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_WhenNoPriorPayment_ExtendsFromNowAndResolves()
|
||||
{
|
||||
using var dbContext = InMemoryDbContextFactory.Create();
|
||||
var adminId = Guid.NewGuid();
|
||||
var userId = Guid.NewGuid();
|
||||
var ticket = SupportTicket.CreateExtensionRequest(userId, 10);
|
||||
dbContext.SupportTickets.Add(ticket);
|
||||
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 = CreateHandler(dbContext, adminId);
|
||||
var before = DateTimeOffset.UtcNow;
|
||||
|
||||
var result = await handler.Handle(
|
||||
new ApproveExtensionRequestCommand(ticket.Id),
|
||||
CancellationToken.None
|
||||
);
|
||||
|
||||
Assert.True(result.IsSuccess);
|
||||
Assert.Equal(TicketStatus.Resolved, ticket.Status);
|
||||
await _identityService
|
||||
.Received(1)
|
||||
.ExtendBillingPaidUntilAsync(
|
||||
userId,
|
||||
Arg.Is<DateTimeOffset>(d => d >= before.AddDays(10).AddMinutes(-1)),
|
||||
Arg.Any<CancellationToken>()
|
||||
);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_ResumesExpiredConfigs()
|
||||
{
|
||||
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");
|
||||
config.Suspend();
|
||||
|
||||
dbContext.Nodes.Add(node);
|
||||
dbContext.Inbounds.Add(inbound);
|
||||
dbContext.VpnConfigs.Add(config);
|
||||
|
||||
var ticket = SupportTicket.CreateExtensionRequest(userId, 5);
|
||||
dbContext.SupportTickets.Add(ticket);
|
||||
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 = CreateHandler(dbContext, adminId);
|
||||
|
||||
var result = await handler.Handle(
|
||||
new ApproveExtensionRequestCommand(ticket.Id),
|
||||
CancellationToken.None
|
||||
);
|
||||
|
||||
Assert.True(result.IsSuccess);
|
||||
Assert.Equal(ConfigStatus.Active, config.Status);
|
||||
Assert.NotNull(config.ExpiresAt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_WhenTicketNotOpen_ReturnsNotOpen()
|
||||
{
|
||||
using var dbContext = InMemoryDbContextFactory.Create();
|
||||
var adminId = Guid.NewGuid();
|
||||
var userId = Guid.NewGuid();
|
||||
var ticket = SupportTicket.CreateExtensionRequest(userId, 5);
|
||||
ticket.Close();
|
||||
dbContext.SupportTickets.Add(ticket);
|
||||
await dbContext.SaveChangesAsync(CancellationToken.None);
|
||||
|
||||
var handler = CreateHandler(dbContext, adminId);
|
||||
|
||||
var result = await handler.Handle(
|
||||
new ApproveExtensionRequestCommand(ticket.Id),
|
||||
CancellationToken.None
|
||||
);
|
||||
|
||||
Assert.False(result.IsSuccess);
|
||||
Assert.Equal(SupportErrors.NotOpen, result.Error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_WhenNotExtensionRequest_ReturnsNotExtensionRequest()
|
||||
{
|
||||
using var dbContext = InMemoryDbContextFactory.Create();
|
||||
var adminId = Guid.NewGuid();
|
||||
var ticket = SupportTicket.CreateBugReport(Guid.NewGuid());
|
||||
dbContext.SupportTickets.Add(ticket);
|
||||
await dbContext.SaveChangesAsync(CancellationToken.None);
|
||||
|
||||
var handler = CreateHandler(dbContext, adminId);
|
||||
|
||||
var result = await handler.Handle(
|
||||
new ApproveExtensionRequestCommand(ticket.Id),
|
||||
CancellationToken.None
|
||||
);
|
||||
|
||||
Assert.False(result.IsSuccess);
|
||||
Assert.Equal(SupportErrors.NotExtensionRequest, result.Error);
|
||||
}
|
||||
}
|
||||
+73
@@ -0,0 +1,73 @@
|
||||
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;
|
||||
|
||||
namespace PnvPanel.Application.Tests.Admin.Support;
|
||||
|
||||
public class RejectExtensionRequestCommandHandlerTests
|
||||
{
|
||||
private readonly IRealtimeNotifier _notifier = Substitute.For<IRealtimeNotifier>();
|
||||
private readonly ITelegramNotifier _telegramNotifier = Substitute.For<ITelegramNotifier>();
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_WhenOpen_ClosesAndNotifiesUser()
|
||||
{
|
||||
using var dbContext = InMemoryDbContextFactory.Create();
|
||||
var adminId = Guid.NewGuid();
|
||||
var userId = Guid.NewGuid();
|
||||
var ticket = SupportTicket.CreateExtensionRequest(userId, 5);
|
||||
dbContext.SupportTickets.Add(ticket);
|
||||
await dbContext.SaveChangesAsync(CancellationToken.None);
|
||||
|
||||
var handler = new RejectExtensionRequestCommandHandler(
|
||||
dbContext,
|
||||
_notifier,
|
||||
_telegramNotifier,
|
||||
FakeCurrentUser.Authenticated(adminId, "admin")
|
||||
);
|
||||
|
||||
var result = await handler.Handle(
|
||||
new RejectExtensionRequestCommand(ticket.Id, "Недостаточно оснований"),
|
||||
CancellationToken.None
|
||||
);
|
||||
|
||||
Assert.True(result.IsSuccess);
|
||||
Assert.Equal(TicketStatus.Closed, ticket.Status);
|
||||
await _telegramNotifier
|
||||
.Received(1)
|
||||
.NotifyUserAsync(
|
||||
userId,
|
||||
Arg.Any<string>(),
|
||||
Arg.Any<string?>(),
|
||||
Arg.Any<CancellationToken>()
|
||||
);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_WhenNotExtensionRequest_ReturnsNotExtensionRequest()
|
||||
{
|
||||
using var dbContext = InMemoryDbContextFactory.Create();
|
||||
var ticket = SupportTicket.CreateBugReport(Guid.NewGuid());
|
||||
dbContext.SupportTickets.Add(ticket);
|
||||
await dbContext.SaveChangesAsync(CancellationToken.None);
|
||||
|
||||
var handler = new RejectExtensionRequestCommandHandler(
|
||||
dbContext,
|
||||
_notifier,
|
||||
_telegramNotifier,
|
||||
FakeCurrentUser.Authenticated(Guid.NewGuid(), "admin")
|
||||
);
|
||||
|
||||
var result = await handler.Handle(
|
||||
new RejectExtensionRequestCommand(ticket.Id, null),
|
||||
CancellationToken.None
|
||||
);
|
||||
|
||||
Assert.False(result.IsSuccess);
|
||||
Assert.Equal(SupportErrors.NotExtensionRequest, result.Error);
|
||||
}
|
||||
}
|
||||
+124
@@ -0,0 +1,124 @@
|
||||
using NSubstitute;
|
||||
using PnvPanel.Application.Billing;
|
||||
using PnvPanel.Application.Common.Interfaces;
|
||||
using PnvPanel.Application.Support;
|
||||
using PnvPanel.Application.Support.CreateExtensionRequest;
|
||||
using PnvPanel.Application.Tests.TestSupport;
|
||||
using PnvPanel.Domain.Support;
|
||||
using Xunit;
|
||||
|
||||
namespace PnvPanel.Application.Tests.Support;
|
||||
|
||||
public class CreateExtensionRequestTicketCommandHandlerTests
|
||||
{
|
||||
private readonly IIdentityService _identityService = Substitute.For<IIdentityService>();
|
||||
private readonly IRealtimeNotifier _notifier = Substitute.For<IRealtimeNotifier>();
|
||||
private readonly ITelegramNotifier _telegramNotifier = Substitute.For<ITelegramNotifier>();
|
||||
|
||||
private static CurrentUserProfile Profile(Guid userId, bool billingEnabled) =>
|
||||
new(
|
||||
userId,
|
||||
"alice",
|
||||
Guid.NewGuid(),
|
||||
"premium",
|
||||
IsActivated: true,
|
||||
IsBlocked: false,
|
||||
MaxConfigs: 5,
|
||||
MaxIpLimit: 3,
|
||||
SubscriptionToken: "sub-token",
|
||||
BillingEnabled: billingEnabled,
|
||||
BillingPaidUntil: null,
|
||||
BillingSuspended: false
|
||||
);
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_WhenBillingEnabled_CreatesTicketAndNotifiesAdmins()
|
||||
{
|
||||
using var dbContext = InMemoryDbContextFactory.Create();
|
||||
var userId = Guid.NewGuid();
|
||||
_identityService
|
||||
.GetProfileAsync(userId, Arg.Any<CancellationToken>())
|
||||
.Returns(Profile(userId, billingEnabled: true));
|
||||
|
||||
var handler = new CreateExtensionRequestTicketCommandHandler(
|
||||
dbContext,
|
||||
_identityService,
|
||||
_notifier,
|
||||
_telegramNotifier,
|
||||
FakeCurrentUser.Authenticated(userId, "alice")
|
||||
);
|
||||
|
||||
var result = await handler.Handle(
|
||||
new CreateExtensionRequestTicketCommand(14, "нужно продлить"),
|
||||
CancellationToken.None
|
||||
);
|
||||
|
||||
Assert.True(result.IsSuccess);
|
||||
Assert.Equal(TicketType.ExtensionRequest, result.Value.Type);
|
||||
Assert.Equal(14, result.Value.RequestedDays);
|
||||
await _telegramNotifier
|
||||
.Received(1)
|
||||
.NotifyAdminsExtensionRequestCreatedAsync(
|
||||
Arg.Any<Guid>(),
|
||||
"alice",
|
||||
14,
|
||||
"нужно продлить",
|
||||
Arg.Any<CancellationToken>()
|
||||
);
|
||||
}
|
||||
|
||||
[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 CreateExtensionRequestTicketCommandHandler(
|
||||
dbContext,
|
||||
_identityService,
|
||||
_notifier,
|
||||
_telegramNotifier,
|
||||
FakeCurrentUser.Authenticated(userId)
|
||||
);
|
||||
|
||||
var result = await handler.Handle(
|
||||
new CreateExtensionRequestTicketCommand(14, "нужно продлить"),
|
||||
CancellationToken.None
|
||||
);
|
||||
|
||||
Assert.False(result.IsSuccess);
|
||||
Assert.Equal(BillingErrors.NotEnabled, result.Error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_WhenPendingExtensionRequestExists_ReturnsConflict()
|
||||
{
|
||||
using var dbContext = InMemoryDbContextFactory.Create();
|
||||
var userId = Guid.NewGuid();
|
||||
dbContext.SupportTickets.Add(SupportTicket.CreateExtensionRequest(userId, 7));
|
||||
await dbContext.SaveChangesAsync(CancellationToken.None);
|
||||
|
||||
_identityService
|
||||
.GetProfileAsync(userId, Arg.Any<CancellationToken>())
|
||||
.Returns(Profile(userId, billingEnabled: true));
|
||||
|
||||
var handler = new CreateExtensionRequestTicketCommandHandler(
|
||||
dbContext,
|
||||
_identityService,
|
||||
_notifier,
|
||||
_telegramNotifier,
|
||||
FakeCurrentUser.Authenticated(userId)
|
||||
);
|
||||
|
||||
var result = await handler.Handle(
|
||||
new CreateExtensionRequestTicketCommand(3, "ещё заявка"),
|
||||
CancellationToken.None
|
||||
);
|
||||
|
||||
Assert.False(result.IsSuccess);
|
||||
Assert.Equal(SupportErrors.ExtensionRequestAlreadyPending, result.Error);
|
||||
}
|
||||
}
|
||||
@@ -45,6 +45,21 @@ public class SupportTicketTests
|
||||
Assert.Equal(3, ticket.ProposedMaxIpLimit);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateExtensionRequest_SetsRequestedDays()
|
||||
{
|
||||
var userId = Guid.NewGuid();
|
||||
|
||||
var ticket = SupportTicket.CreateExtensionRequest(userId, 14);
|
||||
|
||||
Assert.Equal(userId, ticket.UserId);
|
||||
Assert.Equal(TicketType.ExtensionRequest, ticket.Type);
|
||||
Assert.Equal(TicketStatus.Open, ticket.Status);
|
||||
Assert.Equal(14, ticket.RequestedDays);
|
||||
Assert.Null(ticket.RequestedRoleId);
|
||||
Assert.Null(ticket.ProposedRoleName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Resolve_WhenOpen_SetsResolved()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user