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
@@ -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
);