Implement extension request and gift functionalities in billing system
CI / Backend (build + test) (push) Successful in 1m27s
CI / Frontend (lint + typecheck + build) (push) Successful in 33s

- 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:
Leonid Pershin
2026-07-19 05:30:11 +03:00
parent e088e302e9
commit 24cee9bb78
46 changed files with 2541 additions and 78 deletions
@@ -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,
@@ -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>;
@@ -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>;
@@ -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
);
}
@@ -77,6 +77,7 @@ public sealed class CreateBugReportTicketCommandHandler(
null,
null,
null,
null,
ticket.CreatedAt,
[commentDto]
);
@@ -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;
@@ -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);
}
}
@@ -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);
}
}
@@ -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,
}
@@ -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");
}
}
}
@@ -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");
@@ -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);
}
}
@@ -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);
}
}
@@ -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);
}
}
@@ -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()
{