Enhance billing request handling to support immediate config suspension and protection
CI / Backend (build + test) (push) Successful in 1m21s
CI / Frontend (lint + typecheck + build) (push) Successful in 33s

- Updated the `RejectPaymentRequestCommandHandler` to immediately suspend user configs if a payment request is rejected and no other pending subscription requests exist.
- Introduced `SuspendIfStillUnpaidAsync` method to handle the logic for suspending configs based on the user's billing status.
- Enhanced the `MarkPaymentSentCommandHandler` to protect configs during the payment confirmation process, ensuring users remain active while awaiting admin approval.
- Refactored `BillingConfigResumer` to include methods for protecting and suspending configs, improving the overall billing management flow.
- Updated tests to cover new behaviors and ensure proper functionality in various scenarios related to payment requests and config management.
This commit is contained in:
Leonid Pershin
2026-07-19 19:25:33 +03:00
parent 979eddf72e
commit b32756d5bc
8 changed files with 617 additions and 200 deletions
@@ -2,10 +2,10 @@ using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using PnvPanel.Application.Billing;
using PnvPanel.Application.Common.Interfaces;
using PnvPanel.Domain.Audit;
using PnvPanel.Domain.Billing;
using PnvPanel.Domain.Configs;
using PnvPanel.Infrastructure.Persistence;
namespace PnvPanel.Infrastructure.BackgroundJobs;
@@ -13,9 +13,10 @@ namespace PnvPanel.Infrastructure.BackgroundJobs;
/// <summary>
/// Обходит пользователей с billing-ролью: гасит конфиги, у кого истёк оплаченный период (Active →
/// Expired, отдельно от блокировки админом — см. VpnConfig.Suspend), и шлёт предупреждение за 3 дня
/// до истечения. Пользователь с заявкой на оплату в AwaitingConfirmation не трогается вообще — пока
/// админ не подтвердит/отклонит, приостановка не наступает (не по вине пользователя, что админ не
/// успел проверить оплату).
/// до истечения. Пользователь с Subscription-заявкой на оплату в AwaitingConfirmation не гасится —
/// вместо этого на каждом тике "защищается" на панели (BillingConfigResumer.ProtectPendingConfigsAsync,
/// см. её XML-doc для причины) до тех пор, пока админ не подтвердит/отклонит (не по вине пользователя,
/// что админ не успел проверить оплату).
/// </summary>
public sealed class BillingService(
IServiceScopeFactory scopeFactory,
@@ -55,20 +56,44 @@ public sealed class BillingService(
foreach (var user in billingUsers)
{
// Заявка ждёт решения админа — не гасим конфиги, пока он не подтвердит/отклонит (см.
// ConfirmPaymentRequestCommandHandler/RejectPaymentRequestCommandHandler).
// Subscription-заявка ждёт решения админа — не гасим конфиги, пока он не подтвердит/
// отклонит, но и не просто "ничего не делаем": держим клиента рабочим на панели (Xray
// проверяет expiryTime сам, независимо от нашего статуса) — см.
// ConfirmPaymentRequestCommandHandler/RejectPaymentRequestCommandHandler и
// BillingConfigResumer.ProtectPendingConfigsAsync. RoleChangeTopUp намеренно не учитывается
// здесь — это доплата за апгрейд роли, а не оплата подписки, её ожидание не должно
// спасать от приостановки за реально просроченную подписку.
var hasAwaitingConfirmation = await dbContext.PaymentRequests.AnyAsync(
r => r.UserId == user.UserId && r.Status == PaymentRequestStatus.AwaitingConfirmation,
r =>
r.UserId == user.UserId
&& r.Kind == PaymentRequestKind.Subscription
&& r.Status == PaymentRequestStatus.AwaitingConfirmation,
cancellationToken
);
if (hasAwaitingConfirmation)
{
await BillingConfigResumer.ProtectPendingConfigsAsync(
dbContext,
gateway,
logger,
user.UserId,
cancellationToken
);
continue;
}
if (user.PaidUntil is not { } paidUntil || paidUntil <= now)
{
// Идемпотентно гасим конфиги на каждом тике (самовосстановление после временной
// недоступности ноды), но уведомление/аудит/флаг — только один раз, при первом обнаружении.
await DisableActiveConfigsAsync(user.UserId, dbContext, gateway, notifier, cancellationToken);
await BillingConfigResumer.SuspendConfigsAsync(
dbContext,
gateway,
notifier,
logger,
user.UserId,
cancellationToken
);
if (!user.Suspended)
{
@@ -113,68 +138,4 @@ public sealed class BillingService(
await dbContext.SaveChangesAsync(cancellationToken);
}
private async Task DisableActiveConfigsAsync(
Guid userId,
AppDbContext dbContext,
IXuiPanelGateway gateway,
IRealtimeNotifier notifier,
CancellationToken cancellationToken
)
{
var configs = await dbContext
.VpnConfigs.Where(c => c.UserId == userId && c.Status == ConfigStatus.Active)
.ToListAsync(cancellationToken);
foreach (var config in configs)
{
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)
{
// Приостанавливаем через expiryTime в прошлом, а не enable:false — переключение enable
// ненадёжно останавливает уже установленные соединения на стороне Xray/панели (см.
// IXuiPanelGateway.UpdateClientAsync), просроченный expiryTime — надёжно и независимо
// от enable. name: null — не переименовываем клиента.
var updateResult = await gateway.UpdateClientAsync(
node,
inbound.RemoteInboundId,
config.ClientExternalId,
config.Protocol,
name: null,
enable: null,
expiresAt: DateTimeOffset.UtcNow.AddDays(-1),
cancellationToken
);
if (!updateResult.IsSuccess)
{
// Нода недоступна — конфиг остаётся Active, подхватится следующим циклом.
logger.LogWarning(
"Failed to disable client for config {ConfigId} on node {NodeId} while suspending user {UserId} for non-payment: {Error}",
config.Id,
node.Id,
userId,
updateResult.Error
);
continue;
}
}
config.Suspend();
await notifier.NotifyConfigStatusChangedAsync(
config.UserId,
config.Id,
config.Status,
cancellationToken
);
}
}
}