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,