Enhance billing request handling to support immediate config suspension and protection
- 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:
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user