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