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:
+50
-1
@@ -1,4 +1,5 @@
|
|||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
using PnvPanel.Application.Auth;
|
using PnvPanel.Application.Auth;
|
||||||
using PnvPanel.Application.Billing;
|
using PnvPanel.Application.Billing;
|
||||||
using PnvPanel.Application.Common.Interfaces;
|
using PnvPanel.Application.Common.Interfaces;
|
||||||
@@ -11,8 +12,12 @@ namespace PnvPanel.Application.Admin.Billing;
|
|||||||
|
|
||||||
public sealed class RejectPaymentRequestCommandHandler(
|
public sealed class RejectPaymentRequestCommandHandler(
|
||||||
IAppDbContext dbContext,
|
IAppDbContext dbContext,
|
||||||
|
IIdentityService identityService,
|
||||||
|
IXuiPanelGateway gateway,
|
||||||
|
IRealtimeNotifier notifier,
|
||||||
ITelegramNotifier telegramNotifier,
|
ITelegramNotifier telegramNotifier,
|
||||||
ICurrentUser currentUser
|
ICurrentUser currentUser,
|
||||||
|
ILogger<RejectPaymentRequestCommandHandler> logger
|
||||||
) : ICommandHandler<RejectPaymentRequestCommand, Result>
|
) : ICommandHandler<RejectPaymentRequestCommand, Result>
|
||||||
{
|
{
|
||||||
public async Task<Result> Handle(
|
public async Task<Result> Handle(
|
||||||
@@ -38,6 +43,13 @@ public sealed class RejectPaymentRequestCommandHandler(
|
|||||||
|
|
||||||
request.Reject(adminId, command.Reason);
|
request.Reject(adminId, command.Reason);
|
||||||
|
|
||||||
|
// Пока заявка висела на проверке, конфиги могли быть временно "защищены" на панели
|
||||||
|
// (ProtectPendingConfigsAsync — enable/expiresAt подвинуты вперёд без изменения локального
|
||||||
|
// статуса). Раз оплату отклонили и период всё ещё просрочен, а других Subscription-заявок на
|
||||||
|
// проверке нет — снимаем защиту немедленно, не дожидаясь часового тика BillingService.
|
||||||
|
if (request.Kind == PaymentRequestKind.Subscription)
|
||||||
|
await SuspendIfStillUnpaidAsync(request.Id, request.UserId, cancellationToken);
|
||||||
|
|
||||||
dbContext.AuditLogs.Add(
|
dbContext.AuditLogs.Add(
|
||||||
AuditLog.Create(
|
AuditLog.Create(
|
||||||
adminId,
|
adminId,
|
||||||
@@ -61,4 +73,41 @@ public sealed class RejectPaymentRequestCommandHandler(
|
|||||||
|
|
||||||
return Result.Success();
|
return Result.Success();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async Task SuspendIfStillUnpaidAsync(
|
||||||
|
Guid rejectedRequestId,
|
||||||
|
Guid userId,
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var profile = await identityService.GetProfileAsync(userId, cancellationToken);
|
||||||
|
if (profile is not { BillingEnabled: true })
|
||||||
|
return;
|
||||||
|
if (profile.BillingPaidUntil is { } paidUntil && paidUntil > DateTimeOffset.UtcNow)
|
||||||
|
return;
|
||||||
|
|
||||||
|
// Саму отклоняемую заявку исключаем явно: request.Reject(...) уже поменял её статус на
|
||||||
|
// Rejected в трекере EF, но до SaveChangesAsync (в конце пайплайна) в БД всё ещё лежит старое
|
||||||
|
// значение AwaitingConfirmation — без Id-исключения запрос ниже ложно принял бы её за "ещё
|
||||||
|
// одну" висящую заявку и никогда бы не приостанавливал конфиги.
|
||||||
|
var hasOtherPending = await dbContext.PaymentRequests.AnyAsync(
|
||||||
|
r =>
|
||||||
|
r.Id != rejectedRequestId
|
||||||
|
&& r.UserId == userId
|
||||||
|
&& r.Kind == PaymentRequestKind.Subscription
|
||||||
|
&& r.Status == PaymentRequestStatus.AwaitingConfirmation,
|
||||||
|
cancellationToken
|
||||||
|
);
|
||||||
|
if (hasOtherPending)
|
||||||
|
return;
|
||||||
|
|
||||||
|
await BillingConfigResumer.SuspendConfigsAsync(
|
||||||
|
dbContext,
|
||||||
|
gateway,
|
||||||
|
notifier,
|
||||||
|
logger,
|
||||||
|
userId,
|
||||||
|
cancellationToken
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,20 +1,26 @@
|
|||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using PnvPanel.Application.Common.Interfaces;
|
using PnvPanel.Application.Common.Interfaces;
|
||||||
|
using PnvPanel.Domain.Billing;
|
||||||
using PnvPanel.Domain.Configs;
|
using PnvPanel.Domain.Configs;
|
||||||
|
|
||||||
namespace PnvPanel.Application.Billing;
|
namespace PnvPanel.Application.Billing;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Общая часть "продлить оплату" — возвращает в Active конфиги, приостановленные за неуплату
|
/// Общая синхронизация панели 3x-ui со статусом оплаты: продление (ResumeConfigsAsync), временная
|
||||||
/// (Expired), и синхронизирует ExpiresAt на все конфиги пользователя. Используется при подтверждении
|
/// защита на время рассмотрения заявки админом (ProtectPendingConfigsAsync) и приостановка за неуплату
|
||||||
/// заявки на оплату, одобрении заявки на продление и выдаче гифт-дней админом — расчёт самого
|
/// (SuspendConfigsAsync). Xray проверяет expiryTime клиента сам, независимо от нашего локального
|
||||||
/// newPaidUntil (AddMonths для платежа, AddDays для продления/гифта) остаётся на вызывающей стороне,
|
/// статуса — поэтому "просто не гасить, пока заявка на проверке" недостаточно: если реальный
|
||||||
/// как и AppUser.BillingPaidUntil (см. IIdentityService.ExtendBillingPaidUntilAsync, зовётся отдельно
|
/// BillingPaidUntil уже в прошлом, панель заблокирует клиента сама, пока мы явно не подвинем
|
||||||
/// до этого helper'а).
|
/// expiryTime вперёд. По той же причине везде передаём enable И expiresAt вместе (см.
|
||||||
|
/// IXuiPanelGateway.UpdateClientAsync) — по факту эксплуатации одного enable недостаточно.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
internal static class BillingConfigResumer
|
public static class BillingConfigResumer
|
||||||
{
|
{
|
||||||
|
/// <summary>Подтверждённая оплата/продление/гифт — реальный новый срок. Пушится на панель для
|
||||||
|
/// ЛЮБОГО статуса конфига, не только Expired: пользователь мог доплатить/продлить заранее, пока
|
||||||
|
/// конфиг ещё Active — панель должна узнать новый срок сразу, а не только когда конфиг реально
|
||||||
|
/// просрочится.</summary>
|
||||||
public static async Task ResumeConfigsAsync(
|
public static async Task ResumeConfigsAsync(
|
||||||
IAppDbContext dbContext,
|
IAppDbContext dbContext,
|
||||||
IXuiPanelGateway gateway,
|
IXuiPanelGateway gateway,
|
||||||
@@ -25,58 +31,24 @@ internal static class BillingConfigResumer
|
|||||||
CancellationToken cancellationToken
|
CancellationToken cancellationToken
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
var configs = await dbContext
|
var configs = await ActiveOrExpiredConfigsAsync(dbContext, userId, cancellationToken);
|
||||||
.VpnConfigs.Where(c =>
|
|
||||||
c.UserId == userId
|
|
||||||
&& (c.Status == ConfigStatus.Active || c.Status == ConfigStatus.Expired)
|
|
||||||
)
|
|
||||||
.ToListAsync(cancellationToken);
|
|
||||||
|
|
||||||
foreach (var config in configs)
|
foreach (var config in configs)
|
||||||
{
|
{
|
||||||
if (config.Status == ConfigStatus.Expired)
|
var wasExpired = config.Status == ConfigStatus.Expired;
|
||||||
|
var pushed = await PushExpiryAsync(
|
||||||
|
dbContext,
|
||||||
|
gateway,
|
||||||
|
logger,
|
||||||
|
config,
|
||||||
|
enable: true,
|
||||||
|
newPaidUntil,
|
||||||
|
"extending billing",
|
||||||
|
cancellationToken
|
||||||
|
);
|
||||||
|
|
||||||
|
if (wasExpired && pushed)
|
||||||
{
|
{
|
||||||
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 = новый newPaidUntil, а не enable:true — тот же
|
|
||||||
// механизм приостановки, что и у BillingService (см. IXuiPanelGateway.UpdateClientAsync):
|
|
||||||
// переписываем просроченную дату на настоящую, панель/Xray сами перестают считать
|
|
||||||
// клиента истёкшим. name: null — не переименовываем клиента.
|
|
||||||
var updateResult = await gateway.UpdateClientAsync(
|
|
||||||
node,
|
|
||||||
inbound.RemoteInboundId,
|
|
||||||
config.ClientExternalId,
|
|
||||||
config.Protocol,
|
|
||||||
name: null,
|
|
||||||
enable: null,
|
|
||||||
expiresAt: newPaidUntil,
|
|
||||||
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();
|
config.Resume();
|
||||||
await notifier.NotifyConfigStatusChangedAsync(
|
await notifier.NotifyConfigStatusChangedAsync(
|
||||||
config.UserId,
|
config.UserId,
|
||||||
@@ -89,4 +61,148 @@ internal static class BillingConfigResumer
|
|||||||
config.SetBillingExpiry(newPaidUntil);
|
config.SetBillingExpiry(newPaidUntil);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Заявка на оплату ждёт решения админа (AwaitingConfirmation) — держим клиента рабочим
|
||||||
|
/// на панели на время рассмотрения (expiresAt = сейчас + BillingSettings.GraceDays), не трогая
|
||||||
|
/// Status/ExpiresAt в БД — это временная мера до Confirm (ResumeConfigsAsync проставит настоящий
|
||||||
|
/// срок) или Reject (SuspendConfigsAsync вернёт как было). Вызывается и сразу при отметке "я
|
||||||
|
/// оплатил" (MarkPaymentSentCommandHandler), и на каждом тике BillingService, пока заявка висит —
|
||||||
|
/// идемпотентно, каждый вызов просто продлевает грейс ещё на GraceDays от текущего момента.</summary>
|
||||||
|
public static async Task ProtectPendingConfigsAsync(
|
||||||
|
IAppDbContext dbContext,
|
||||||
|
IXuiPanelGateway gateway,
|
||||||
|
ILogger logger,
|
||||||
|
Guid userId,
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var settings = await dbContext.BillingSettings.AsNoTracking().FirstOrDefaultAsync(cancellationToken);
|
||||||
|
var graceUntil = DateTimeOffset.UtcNow.AddDays(settings?.GraceDays ?? BillingSettings.DefaultGraceDays);
|
||||||
|
|
||||||
|
var configs = await ActiveOrExpiredConfigsAsync(dbContext, userId, cancellationToken);
|
||||||
|
|
||||||
|
foreach (var config in configs)
|
||||||
|
{
|
||||||
|
await PushExpiryAsync(
|
||||||
|
dbContext,
|
||||||
|
gateway,
|
||||||
|
logger,
|
||||||
|
config,
|
||||||
|
enable: true,
|
||||||
|
graceUntil,
|
||||||
|
"protecting pending payment for",
|
||||||
|
cancellationToken
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Приостановка за неуплату — общая для фоновой джобы (BillingService) и немедленной
|
||||||
|
/// реакции на отклонение заявки админом (RejectPaymentRequestCommandHandler), чтобы не ждать
|
||||||
|
/// следующего часового тика. Проходит и по уже Expired конфигам — они могли быть временно
|
||||||
|
/// "защищены" ProtectPendingConfigsAsync (enable/expiresAt на панели уехали вперёд, а статус в БД
|
||||||
|
/// остался как был), и при отклонении заявки эту защиту нужно снять с панели тоже, а не только
|
||||||
|
/// локально.</summary>
|
||||||
|
public static async Task SuspendConfigsAsync(
|
||||||
|
IAppDbContext dbContext,
|
||||||
|
IXuiPanelGateway gateway,
|
||||||
|
IRealtimeNotifier notifier,
|
||||||
|
ILogger logger,
|
||||||
|
Guid userId,
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var expiredAt = DateTimeOffset.UtcNow.AddDays(-1);
|
||||||
|
var configs = await ActiveOrExpiredConfigsAsync(dbContext, userId, cancellationToken);
|
||||||
|
|
||||||
|
foreach (var config in configs)
|
||||||
|
{
|
||||||
|
var wasActive = config.Status == ConfigStatus.Active;
|
||||||
|
var pushed = await PushExpiryAsync(
|
||||||
|
dbContext,
|
||||||
|
gateway,
|
||||||
|
logger,
|
||||||
|
config,
|
||||||
|
enable: false,
|
||||||
|
expiredAt,
|
||||||
|
"suspending for non-payment",
|
||||||
|
cancellationToken
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!wasActive || !pushed)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
config.Suspend();
|
||||||
|
await notifier.NotifyConfigStatusChangedAsync(
|
||||||
|
config.UserId,
|
||||||
|
config.Id,
|
||||||
|
config.Status,
|
||||||
|
cancellationToken
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Task<List<VpnConfig>> ActiveOrExpiredConfigsAsync(
|
||||||
|
IAppDbContext dbContext,
|
||||||
|
Guid userId,
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
) =>
|
||||||
|
dbContext
|
||||||
|
.VpnConfigs.Where(c =>
|
||||||
|
c.UserId == userId
|
||||||
|
&& (c.Status == ConfigStatus.Active || c.Status == ConfigStatus.Expired)
|
||||||
|
)
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
/// <summary>Возвращает false только если панель реально отвергла обновление (нода недоступна и
|
||||||
|
/// т.п.) — вызывающий тогда пропускает смену локального статуса, чтобы не разойтись с панелью
|
||||||
|
/// (подхватится следующим циклом/событием). Если у конфига нет инбаунда/ноды вовсе — на панели
|
||||||
|
/// нечего обновлять, но локальный статус менять можно (как и раньше в BillingService/
|
||||||
|
/// BillingConfigResumer).</summary>
|
||||||
|
private static async Task<bool> PushExpiryAsync(
|
||||||
|
IAppDbContext dbContext,
|
||||||
|
IXuiPanelGateway gateway,
|
||||||
|
ILogger logger,
|
||||||
|
VpnConfig config,
|
||||||
|
bool enable,
|
||||||
|
DateTimeOffset expiresAt,
|
||||||
|
string action,
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
)
|
||||||
|
{
|
||||||
|
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 null || node is null)
|
||||||
|
return true;
|
||||||
|
|
||||||
|
var result = await gateway.UpdateClientAsync(
|
||||||
|
node,
|
||||||
|
inbound.RemoteInboundId,
|
||||||
|
config.ClientExternalId,
|
||||||
|
config.Protocol,
|
||||||
|
name: null,
|
||||||
|
enable,
|
||||||
|
expiresAt,
|
||||||
|
cancellationToken
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result.IsSuccess)
|
||||||
|
return true;
|
||||||
|
|
||||||
|
logger.LogWarning(
|
||||||
|
"Failed to update client for config {ConfigId} on node {NodeId} while {Action} user {UserId}: {Error}",
|
||||||
|
config.Id,
|
||||||
|
node.Id,
|
||||||
|
action,
|
||||||
|
config.UserId,
|
||||||
|
result.Error
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+23
-1
@@ -1,4 +1,5 @@
|
|||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
using PnvPanel.Application.Auth;
|
using PnvPanel.Application.Auth;
|
||||||
using PnvPanel.Application.Common.Interfaces;
|
using PnvPanel.Application.Common.Interfaces;
|
||||||
using PnvPanel.Application.Common.Messaging;
|
using PnvPanel.Application.Common.Messaging;
|
||||||
@@ -10,8 +11,10 @@ namespace PnvPanel.Application.Billing.MarkPaymentSent;
|
|||||||
public sealed class MarkPaymentSentCommandHandler(
|
public sealed class MarkPaymentSentCommandHandler(
|
||||||
IAppDbContext dbContext,
|
IAppDbContext dbContext,
|
||||||
IIdentityService identityService,
|
IIdentityService identityService,
|
||||||
|
IXuiPanelGateway gateway,
|
||||||
ITelegramNotifier telegramNotifier,
|
ITelegramNotifier telegramNotifier,
|
||||||
ICurrentUser currentUser
|
ICurrentUser currentUser,
|
||||||
|
ILogger<MarkPaymentSentCommandHandler> logger
|
||||||
) : ICommandHandler<MarkPaymentSentCommand, Result>
|
) : ICommandHandler<MarkPaymentSentCommand, Result>
|
||||||
{
|
{
|
||||||
public async Task<Result> Handle(MarkPaymentSentCommand command, CancellationToken cancellationToken)
|
public async Task<Result> Handle(MarkPaymentSentCommand command, CancellationToken cancellationToken)
|
||||||
@@ -32,6 +35,25 @@ public sealed class MarkPaymentSentCommandHandler(
|
|||||||
request.MarkPaymentSent();
|
request.MarkPaymentSent();
|
||||||
|
|
||||||
var profile = await identityService.GetProfileAsync(userId, cancellationToken);
|
var profile = await identityService.GetProfileAsync(userId, cancellationToken);
|
||||||
|
|
||||||
|
// Оплаченный период уже мог истечь, пока пользователь собирался заплатить — не ждём часовой
|
||||||
|
// тик BillingService, сразу держим клиента рабочим на панели на время рассмотрения заявки
|
||||||
|
// (см. BillingConfigResumer.ProtectPendingConfigsAsync). Только для Subscription — доплата за
|
||||||
|
// смену роли (RoleChangeTopUp) на приостановку не влияет.
|
||||||
|
if (
|
||||||
|
request.Kind == PaymentRequestKind.Subscription
|
||||||
|
&& (profile?.BillingPaidUntil is null || profile.BillingPaidUntil < DateTimeOffset.UtcNow)
|
||||||
|
)
|
||||||
|
{
|
||||||
|
await BillingConfigResumer.ProtectPendingConfigsAsync(
|
||||||
|
dbContext,
|
||||||
|
gateway,
|
||||||
|
logger,
|
||||||
|
userId,
|
||||||
|
cancellationToken
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
await telegramNotifier.NotifyAdminsPaymentRequestedAsync(
|
await telegramNotifier.NotifyAdminsPaymentRequestedAsync(
|
||||||
request.Id,
|
request.Id,
|
||||||
profile?.UserName ?? userId.ToString(),
|
profile?.UserName ?? userId.ToString(),
|
||||||
|
|||||||
@@ -2,10 +2,10 @@ using Microsoft.EntityFrameworkCore;
|
|||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
using Microsoft.Extensions.Hosting;
|
using Microsoft.Extensions.Hosting;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
|
using PnvPanel.Application.Billing;
|
||||||
using PnvPanel.Application.Common.Interfaces;
|
using PnvPanel.Application.Common.Interfaces;
|
||||||
using PnvPanel.Domain.Audit;
|
using PnvPanel.Domain.Audit;
|
||||||
using PnvPanel.Domain.Billing;
|
using PnvPanel.Domain.Billing;
|
||||||
using PnvPanel.Domain.Configs;
|
|
||||||
using PnvPanel.Infrastructure.Persistence;
|
using PnvPanel.Infrastructure.Persistence;
|
||||||
|
|
||||||
namespace PnvPanel.Infrastructure.BackgroundJobs;
|
namespace PnvPanel.Infrastructure.BackgroundJobs;
|
||||||
@@ -13,9 +13,10 @@ namespace PnvPanel.Infrastructure.BackgroundJobs;
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Обходит пользователей с billing-ролью: гасит конфиги, у кого истёк оплаченный период (Active →
|
/// Обходит пользователей с billing-ролью: гасит конфиги, у кого истёк оплаченный период (Active →
|
||||||
/// Expired, отдельно от блокировки админом — см. VpnConfig.Suspend), и шлёт предупреждение за 3 дня
|
/// Expired, отдельно от блокировки админом — см. VpnConfig.Suspend), и шлёт предупреждение за 3 дня
|
||||||
/// до истечения. Пользователь с заявкой на оплату в AwaitingConfirmation не трогается вообще — пока
|
/// до истечения. Пользователь с Subscription-заявкой на оплату в AwaitingConfirmation не гасится —
|
||||||
/// админ не подтвердит/отклонит, приостановка не наступает (не по вине пользователя, что админ не
|
/// вместо этого на каждом тике "защищается" на панели (BillingConfigResumer.ProtectPendingConfigsAsync,
|
||||||
/// успел проверить оплату).
|
/// см. её XML-doc для причины) до тех пор, пока админ не подтвердит/отклонит (не по вине пользователя,
|
||||||
|
/// что админ не успел проверить оплату).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class BillingService(
|
public sealed class BillingService(
|
||||||
IServiceScopeFactory scopeFactory,
|
IServiceScopeFactory scopeFactory,
|
||||||
@@ -55,20 +56,44 @@ public sealed class BillingService(
|
|||||||
|
|
||||||
foreach (var user in billingUsers)
|
foreach (var user in billingUsers)
|
||||||
{
|
{
|
||||||
// Заявка ждёт решения админа — не гасим конфиги, пока он не подтвердит/отклонит (см.
|
// Subscription-заявка ждёт решения админа — не гасим конфиги, пока он не подтвердит/
|
||||||
// ConfirmPaymentRequestCommandHandler/RejectPaymentRequestCommandHandler).
|
// отклонит, но и не просто "ничего не делаем": держим клиента рабочим на панели (Xray
|
||||||
|
// проверяет expiryTime сам, независимо от нашего статуса) — см.
|
||||||
|
// ConfirmPaymentRequestCommandHandler/RejectPaymentRequestCommandHandler и
|
||||||
|
// BillingConfigResumer.ProtectPendingConfigsAsync. RoleChangeTopUp намеренно не учитывается
|
||||||
|
// здесь — это доплата за апгрейд роли, а не оплата подписки, её ожидание не должно
|
||||||
|
// спасать от приостановки за реально просроченную подписку.
|
||||||
var hasAwaitingConfirmation = await dbContext.PaymentRequests.AnyAsync(
|
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
|
cancellationToken
|
||||||
);
|
);
|
||||||
if (hasAwaitingConfirmation)
|
if (hasAwaitingConfirmation)
|
||||||
|
{
|
||||||
|
await BillingConfigResumer.ProtectPendingConfigsAsync(
|
||||||
|
dbContext,
|
||||||
|
gateway,
|
||||||
|
logger,
|
||||||
|
user.UserId,
|
||||||
|
cancellationToken
|
||||||
|
);
|
||||||
continue;
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
if (user.PaidUntil is not { } paidUntil || paidUntil <= now)
|
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)
|
if (!user.Suspended)
|
||||||
{
|
{
|
||||||
@@ -113,68 +138,4 @@ public sealed class BillingService(
|
|||||||
|
|
||||||
await dbContext.SaveChangesAsync(cancellationToken);
|
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
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
+16
-3
@@ -193,8 +193,9 @@ public class ConfirmPaymentRequestCommandHandlerTests
|
|||||||
Assert.NotNull(expiredConfig.ExpiresAt);
|
Assert.NotNull(expiredConfig.ExpiresAt);
|
||||||
Assert.NotNull(activeConfig.ExpiresAt);
|
Assert.NotNull(activeConfig.ExpiresAt);
|
||||||
Assert.Equal(expiredConfig.ExpiresAt, activeConfig.ExpiresAt);
|
Assert.Equal(expiredConfig.ExpiresAt, activeConfig.ExpiresAt);
|
||||||
// enable: null — возврат из приостановки теперь идёт через expiresAt (новый newPaidUntil),
|
// enable:true И expiresAt=новый newPaidUntil вместе (см. IXuiPanelGateway.UpdateClientAsync) —
|
||||||
// а не через переключение enable (см. IXuiPanelGateway.UpdateClientAsync).
|
// и для ранее Expired конфига (ext-1), и для уже Active (ext-2): пользователь мог доплатить
|
||||||
|
// заранее, панель должна узнать новый срок сразу, а не только когда конфиг реально просрочится.
|
||||||
await _gateway
|
await _gateway
|
||||||
.Received(1)
|
.Received(1)
|
||||||
.UpdateClientAsync(
|
.UpdateClientAsync(
|
||||||
@@ -203,10 +204,22 @@ public class ConfirmPaymentRequestCommandHandlerTests
|
|||||||
"ext-1",
|
"ext-1",
|
||||||
VpnProtocol.Vless,
|
VpnProtocol.Vless,
|
||||||
Arg.Any<string>(),
|
Arg.Any<string>(),
|
||||||
null,
|
true,
|
||||||
Arg.Is<DateTimeOffset?>(d => d == expiredConfig.ExpiresAt),
|
Arg.Is<DateTimeOffset?>(d => d == expiredConfig.ExpiresAt),
|
||||||
Arg.Any<CancellationToken>()
|
Arg.Any<CancellationToken>()
|
||||||
);
|
);
|
||||||
|
await _gateway
|
||||||
|
.Received(1)
|
||||||
|
.UpdateClientAsync(
|
||||||
|
Arg.Any<Node>(),
|
||||||
|
inbound.RemoteInboundId,
|
||||||
|
"ext-2",
|
||||||
|
VpnProtocol.Vless,
|
||||||
|
Arg.Any<string>(),
|
||||||
|
true,
|
||||||
|
Arg.Is<DateTimeOffset?>(d => d == activeConfig.ExpiresAt),
|
||||||
|
Arg.Any<CancellationToken>()
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|||||||
+152
-20
@@ -1,16 +1,54 @@
|
|||||||
|
using Microsoft.Extensions.Logging;
|
||||||
using NSubstitute;
|
using NSubstitute;
|
||||||
using PnvPanel.Application.Admin.Billing;
|
using PnvPanel.Application.Admin.Billing;
|
||||||
using PnvPanel.Application.Billing;
|
using PnvPanel.Application.Billing;
|
||||||
using PnvPanel.Application.Common.Interfaces;
|
using PnvPanel.Application.Common.Interfaces;
|
||||||
|
using PnvPanel.Application.Common.Models;
|
||||||
using PnvPanel.Application.Tests.TestSupport;
|
using PnvPanel.Application.Tests.TestSupport;
|
||||||
using PnvPanel.Domain.Billing;
|
using PnvPanel.Domain.Billing;
|
||||||
|
using PnvPanel.Domain.Configs;
|
||||||
|
using PnvPanel.Domain.Inbounds;
|
||||||
|
using PnvPanel.Domain.Nodes;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
|
|
||||||
namespace PnvPanel.Application.Tests.Admin.Billing;
|
namespace PnvPanel.Application.Tests.Admin.Billing;
|
||||||
|
|
||||||
public class RejectPaymentRequestCommandHandlerTests
|
public class RejectPaymentRequestCommandHandlerTests
|
||||||
{
|
{
|
||||||
|
private readonly IIdentityService _identityService = Substitute.For<IIdentityService>();
|
||||||
|
private readonly IXuiPanelGateway _gateway = Substitute.For<IXuiPanelGateway>();
|
||||||
|
private readonly IRealtimeNotifier _notifier = Substitute.For<IRealtimeNotifier>();
|
||||||
private readonly ITelegramNotifier _telegramNotifier = Substitute.For<ITelegramNotifier>();
|
private readonly ITelegramNotifier _telegramNotifier = Substitute.For<ITelegramNotifier>();
|
||||||
|
private readonly ILogger<RejectPaymentRequestCommandHandler> _logger = Substitute.For<
|
||||||
|
ILogger<RejectPaymentRequestCommandHandler>
|
||||||
|
>();
|
||||||
|
|
||||||
|
private RejectPaymentRequestCommandHandler CreateHandler(IAppDbContext dbContext, Guid adminId) =>
|
||||||
|
new(
|
||||||
|
dbContext,
|
||||||
|
_identityService,
|
||||||
|
_gateway,
|
||||||
|
_notifier,
|
||||||
|
_telegramNotifier,
|
||||||
|
FakeCurrentUser.Authenticated(adminId, "admin"),
|
||||||
|
_logger
|
||||||
|
);
|
||||||
|
|
||||||
|
private static CurrentUserProfile Profile(Guid userId, DateTimeOffset? paidUntil) =>
|
||||||
|
new(
|
||||||
|
userId,
|
||||||
|
"alice",
|
||||||
|
Guid.NewGuid(),
|
||||||
|
"premium",
|
||||||
|
IsActivated: true,
|
||||||
|
IsBlocked: false,
|
||||||
|
MaxConfigs: 5,
|
||||||
|
MaxIpLimit: 3,
|
||||||
|
SubscriptionToken: "sub-token",
|
||||||
|
BillingEnabled: true,
|
||||||
|
BillingPaidUntil: paidUntil,
|
||||||
|
BillingSuspended: false
|
||||||
|
);
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Handle_WhenAwaitingConfirmation_RejectsAndNotifiesUser()
|
public async Task Handle_WhenAwaitingConfirmation_RejectsAndNotifiesUser()
|
||||||
@@ -23,16 +61,8 @@ public class RejectPaymentRequestCommandHandlerTests
|
|||||||
dbContext.PaymentRequests.Add(request);
|
dbContext.PaymentRequests.Add(request);
|
||||||
await dbContext.SaveChangesAsync(CancellationToken.None);
|
await dbContext.SaveChangesAsync(CancellationToken.None);
|
||||||
|
|
||||||
var handler = new RejectPaymentRequestCommandHandler(
|
var result = await CreateHandler(dbContext, adminId)
|
||||||
dbContext,
|
.Handle(new RejectPaymentRequestCommand(request.Id, "Платёж не найден"), CancellationToken.None);
|
||||||
_telegramNotifier,
|
|
||||||
FakeCurrentUser.Authenticated(adminId, "admin")
|
|
||||||
);
|
|
||||||
|
|
||||||
var result = await handler.Handle(
|
|
||||||
new RejectPaymentRequestCommand(request.Id, "Платёж не найден"),
|
|
||||||
CancellationToken.None
|
|
||||||
);
|
|
||||||
|
|
||||||
Assert.True(result.IsSuccess);
|
Assert.True(result.IsSuccess);
|
||||||
Assert.Equal(PaymentRequestStatus.Rejected, request.Status);
|
Assert.Equal(PaymentRequestStatus.Rejected, request.Status);
|
||||||
@@ -52,18 +82,120 @@ public class RejectPaymentRequestCommandHandlerTests
|
|||||||
{
|
{
|
||||||
using var dbContext = InMemoryDbContextFactory.Create();
|
using var dbContext = InMemoryDbContextFactory.Create();
|
||||||
|
|
||||||
var handler = new RejectPaymentRequestCommandHandler(
|
var result = await CreateHandler(dbContext, Guid.NewGuid())
|
||||||
dbContext,
|
.Handle(new RejectPaymentRequestCommand(Guid.NewGuid(), null), CancellationToken.None);
|
||||||
_telegramNotifier,
|
|
||||||
FakeCurrentUser.Authenticated(Guid.NewGuid(), "admin")
|
|
||||||
);
|
|
||||||
|
|
||||||
var result = await handler.Handle(
|
|
||||||
new RejectPaymentRequestCommand(Guid.NewGuid(), null),
|
|
||||||
CancellationToken.None
|
|
||||||
);
|
|
||||||
|
|
||||||
Assert.False(result.IsSuccess);
|
Assert.False(result.IsSuccess);
|
||||||
Assert.Equal(BillingErrors.RequestNotFound, result.Error);
|
Assert.Equal(BillingErrors.RequestNotFound, result.Error);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Handle_WhenStillUnpaidAndNoOtherPendingRequest_SuspendsConfigsImmediately()
|
||||||
|
{
|
||||||
|
using var dbContext = InMemoryDbContextFactory.Create();
|
||||||
|
var adminId = Guid.NewGuid();
|
||||||
|
var userId = Guid.NewGuid();
|
||||||
|
|
||||||
|
var node = Node.Register(
|
||||||
|
"node-1",
|
||||||
|
new Uri("https://node1.example.com"),
|
||||||
|
new NodeCredentials("admin", "protected"),
|
||||||
|
null
|
||||||
|
);
|
||||||
|
var inbound = Inbound.FromRemote(node.Id, "1", VpnProtocol.Vless, "remark", 443);
|
||||||
|
var config = VpnConfig.Create(userId, inbound.Id, VpnProtocol.Vless, null);
|
||||||
|
config.AssignRemoteClient("ext-1");
|
||||||
|
dbContext.Nodes.Add(node);
|
||||||
|
dbContext.Inbounds.Add(inbound);
|
||||||
|
dbContext.VpnConfigs.Add(config);
|
||||||
|
|
||||||
|
var request = PaymentRequest.Create(userId, PaymentPeriod.Quarter, 1500);
|
||||||
|
request.MarkPaymentSent();
|
||||||
|
dbContext.PaymentRequests.Add(request);
|
||||||
|
await dbContext.SaveChangesAsync(CancellationToken.None);
|
||||||
|
|
||||||
|
_identityService
|
||||||
|
.GetProfileAsync(userId, Arg.Any<CancellationToken>())
|
||||||
|
.Returns(Profile(userId, paidUntil: DateTimeOffset.UtcNow.AddDays(-1)));
|
||||||
|
_gateway
|
||||||
|
.UpdateClientAsync(
|
||||||
|
Arg.Any<Node>(),
|
||||||
|
Arg.Any<string>(),
|
||||||
|
Arg.Any<string>(),
|
||||||
|
Arg.Any<VpnProtocol>(),
|
||||||
|
Arg.Any<string>(),
|
||||||
|
Arg.Any<bool?>(),
|
||||||
|
Arg.Any<DateTimeOffset?>(),
|
||||||
|
Arg.Any<CancellationToken>()
|
||||||
|
)
|
||||||
|
.Returns(Result.Success());
|
||||||
|
|
||||||
|
var result = await CreateHandler(dbContext, adminId)
|
||||||
|
.Handle(new RejectPaymentRequestCommand(request.Id, null), CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.True(result.IsSuccess);
|
||||||
|
Assert.Equal(ConfigStatus.Expired, config.Status);
|
||||||
|
await _gateway
|
||||||
|
.Received(1)
|
||||||
|
.UpdateClientAsync(
|
||||||
|
Arg.Any<Node>(),
|
||||||
|
inbound.RemoteInboundId,
|
||||||
|
"ext-1",
|
||||||
|
VpnProtocol.Vless,
|
||||||
|
Arg.Any<string>(),
|
||||||
|
false,
|
||||||
|
Arg.Any<DateTimeOffset?>(),
|
||||||
|
Arg.Any<CancellationToken>()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Handle_WhenAnotherSubscriptionRequestStillPending_DoesNotSuspend()
|
||||||
|
{
|
||||||
|
using var dbContext = InMemoryDbContextFactory.Create();
|
||||||
|
var adminId = Guid.NewGuid();
|
||||||
|
var userId = Guid.NewGuid();
|
||||||
|
|
||||||
|
var node = Node.Register(
|
||||||
|
"node-1",
|
||||||
|
new Uri("https://node1.example.com"),
|
||||||
|
new NodeCredentials("admin", "protected"),
|
||||||
|
null
|
||||||
|
);
|
||||||
|
var inbound = Inbound.FromRemote(node.Id, "1", VpnProtocol.Vless, "remark", 443);
|
||||||
|
var config = VpnConfig.Create(userId, inbound.Id, VpnProtocol.Vless, null);
|
||||||
|
config.AssignRemoteClient("ext-1");
|
||||||
|
dbContext.Nodes.Add(node);
|
||||||
|
dbContext.Inbounds.Add(inbound);
|
||||||
|
dbContext.VpnConfigs.Add(config);
|
||||||
|
|
||||||
|
var request = PaymentRequest.Create(userId, PaymentPeriod.Quarter, 1500);
|
||||||
|
request.MarkPaymentSent();
|
||||||
|
var otherRequest = PaymentRequest.Create(userId, PaymentPeriod.Quarter, 1500);
|
||||||
|
otherRequest.MarkPaymentSent();
|
||||||
|
dbContext.PaymentRequests.AddRange(request, otherRequest);
|
||||||
|
await dbContext.SaveChangesAsync(CancellationToken.None);
|
||||||
|
|
||||||
|
_identityService
|
||||||
|
.GetProfileAsync(userId, Arg.Any<CancellationToken>())
|
||||||
|
.Returns(Profile(userId, paidUntil: DateTimeOffset.UtcNow.AddDays(-1)));
|
||||||
|
|
||||||
|
var result = await CreateHandler(dbContext, adminId)
|
||||||
|
.Handle(new RejectPaymentRequestCommand(request.Id, null), CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.True(result.IsSuccess);
|
||||||
|
Assert.Equal(ConfigStatus.Active, config.Status);
|
||||||
|
await _gateway
|
||||||
|
.DidNotReceive()
|
||||||
|
.UpdateClientAsync(
|
||||||
|
Arg.Any<Node>(),
|
||||||
|
Arg.Any<string>(),
|
||||||
|
Arg.Any<string>(),
|
||||||
|
Arg.Any<VpnProtocol>(),
|
||||||
|
Arg.Any<string>(),
|
||||||
|
Arg.Any<bool?>(),
|
||||||
|
Arg.Any<DateTimeOffset?>(),
|
||||||
|
Arg.Any<CancellationToken>()
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+120
-22
@@ -1,9 +1,13 @@
|
|||||||
|
using Microsoft.Extensions.Logging;
|
||||||
using NSubstitute;
|
using NSubstitute;
|
||||||
using PnvPanel.Application.Billing;
|
using PnvPanel.Application.Billing;
|
||||||
using PnvPanel.Application.Billing.MarkPaymentSent;
|
using PnvPanel.Application.Billing.MarkPaymentSent;
|
||||||
using PnvPanel.Application.Common.Interfaces;
|
using PnvPanel.Application.Common.Interfaces;
|
||||||
|
using PnvPanel.Application.Common.Models;
|
||||||
using PnvPanel.Application.Tests.TestSupport;
|
using PnvPanel.Application.Tests.TestSupport;
|
||||||
using PnvPanel.Domain.Billing;
|
using PnvPanel.Domain.Billing;
|
||||||
|
using PnvPanel.Domain.Inbounds;
|
||||||
|
using PnvPanel.Domain.Nodes;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
|
|
||||||
namespace PnvPanel.Application.Tests.Billing.MarkPaymentSent;
|
namespace PnvPanel.Application.Tests.Billing.MarkPaymentSent;
|
||||||
@@ -11,7 +15,30 @@ namespace PnvPanel.Application.Tests.Billing.MarkPaymentSent;
|
|||||||
public class MarkPaymentSentCommandHandlerTests
|
public class MarkPaymentSentCommandHandlerTests
|
||||||
{
|
{
|
||||||
private readonly IIdentityService _identityService = Substitute.For<IIdentityService>();
|
private readonly IIdentityService _identityService = Substitute.For<IIdentityService>();
|
||||||
|
private readonly IXuiPanelGateway _gateway = Substitute.For<IXuiPanelGateway>();
|
||||||
private readonly ITelegramNotifier _telegramNotifier = Substitute.For<ITelegramNotifier>();
|
private readonly ITelegramNotifier _telegramNotifier = Substitute.For<ITelegramNotifier>();
|
||||||
|
private readonly ILogger<MarkPaymentSentCommandHandler> _logger = Substitute.For<
|
||||||
|
ILogger<MarkPaymentSentCommandHandler>
|
||||||
|
>();
|
||||||
|
|
||||||
|
private MarkPaymentSentCommandHandler CreateHandler(IAppDbContext dbContext, Guid userId) =>
|
||||||
|
new(dbContext, _identityService, _gateway, _telegramNotifier, FakeCurrentUser.Authenticated(userId, "alice"), _logger);
|
||||||
|
|
||||||
|
private static CurrentUserProfile Profile(Guid userId, DateTimeOffset? paidUntil) =>
|
||||||
|
new(
|
||||||
|
userId,
|
||||||
|
"alice",
|
||||||
|
Guid.NewGuid(),
|
||||||
|
"premium",
|
||||||
|
IsActivated: true,
|
||||||
|
IsBlocked: false,
|
||||||
|
MaxConfigs: 5,
|
||||||
|
MaxIpLimit: 3,
|
||||||
|
SubscriptionToken: "sub-token",
|
||||||
|
BillingEnabled: true,
|
||||||
|
BillingPaidUntil: paidUntil,
|
||||||
|
BillingSuspended: false
|
||||||
|
);
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Handle_WhenAwaitingPayment_MovesToAwaitingConfirmationAndNotifiesAdmins()
|
public async Task Handle_WhenAwaitingPayment_MovesToAwaitingConfirmationAndNotifiesAdmins()
|
||||||
@@ -22,17 +49,8 @@ public class MarkPaymentSentCommandHandlerTests
|
|||||||
dbContext.PaymentRequests.Add(request);
|
dbContext.PaymentRequests.Add(request);
|
||||||
await dbContext.SaveChangesAsync(CancellationToken.None);
|
await dbContext.SaveChangesAsync(CancellationToken.None);
|
||||||
|
|
||||||
var handler = new MarkPaymentSentCommandHandler(
|
var result = await CreateHandler(dbContext, userId)
|
||||||
dbContext,
|
.Handle(new MarkPaymentSentCommand(request.Id), CancellationToken.None);
|
||||||
_identityService,
|
|
||||||
_telegramNotifier,
|
|
||||||
FakeCurrentUser.Authenticated(userId, "alice")
|
|
||||||
);
|
|
||||||
|
|
||||||
var result = await handler.Handle(
|
|
||||||
new MarkPaymentSentCommand(request.Id),
|
|
||||||
CancellationToken.None
|
|
||||||
);
|
|
||||||
|
|
||||||
Assert.True(result.IsSuccess);
|
Assert.True(result.IsSuccess);
|
||||||
Assert.Equal(PaymentRequestStatus.AwaitingConfirmation, request.Status);
|
Assert.Equal(PaymentRequestStatus.AwaitingConfirmation, request.Status);
|
||||||
@@ -58,19 +76,99 @@ public class MarkPaymentSentCommandHandlerTests
|
|||||||
dbContext.PaymentRequests.Add(request);
|
dbContext.PaymentRequests.Add(request);
|
||||||
await dbContext.SaveChangesAsync(CancellationToken.None);
|
await dbContext.SaveChangesAsync(CancellationToken.None);
|
||||||
|
|
||||||
var handler = new MarkPaymentSentCommandHandler(
|
var result = await CreateHandler(dbContext, userId)
|
||||||
dbContext,
|
.Handle(new MarkPaymentSentCommand(request.Id), CancellationToken.None);
|
||||||
_identityService,
|
|
||||||
_telegramNotifier,
|
|
||||||
FakeCurrentUser.Authenticated(userId)
|
|
||||||
);
|
|
||||||
|
|
||||||
var result = await handler.Handle(
|
|
||||||
new MarkPaymentSentCommand(request.Id),
|
|
||||||
CancellationToken.None
|
|
||||||
);
|
|
||||||
|
|
||||||
Assert.False(result.IsSuccess);
|
Assert.False(result.IsSuccess);
|
||||||
Assert.Equal(BillingErrors.RequestNotAwaitingPayment, result.Error);
|
Assert.Equal(BillingErrors.RequestNotAwaitingPayment, result.Error);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Handle_WhenPaidUntilAlreadyExpired_ProtectsConfigsOnPanel()
|
||||||
|
{
|
||||||
|
using var dbContext = InMemoryDbContextFactory.Create();
|
||||||
|
var userId = Guid.NewGuid();
|
||||||
|
|
||||||
|
var node = Node.Register(
|
||||||
|
"node-1",
|
||||||
|
new Uri("https://node1.example.com"),
|
||||||
|
new NodeCredentials("admin", "protected"),
|
||||||
|
null
|
||||||
|
);
|
||||||
|
var inbound = Inbound.FromRemote(node.Id, "1", VpnProtocol.Vless, "remark", 443);
|
||||||
|
var config = Domain.Configs.VpnConfig.Create(userId, inbound.Id, VpnProtocol.Vless, null);
|
||||||
|
config.AssignRemoteClient("ext-1");
|
||||||
|
dbContext.Nodes.Add(node);
|
||||||
|
dbContext.Inbounds.Add(inbound);
|
||||||
|
dbContext.VpnConfigs.Add(config);
|
||||||
|
|
||||||
|
var request = PaymentRequest.Create(userId, PaymentPeriod.Quarter, 1500);
|
||||||
|
dbContext.PaymentRequests.Add(request);
|
||||||
|
await dbContext.SaveChangesAsync(CancellationToken.None);
|
||||||
|
|
||||||
|
_identityService
|
||||||
|
.GetProfileAsync(userId, Arg.Any<CancellationToken>())
|
||||||
|
.Returns(Profile(userId, paidUntil: DateTimeOffset.UtcNow.AddDays(-1)));
|
||||||
|
_gateway
|
||||||
|
.UpdateClientAsync(
|
||||||
|
Arg.Any<Node>(),
|
||||||
|
Arg.Any<string>(),
|
||||||
|
Arg.Any<string>(),
|
||||||
|
Arg.Any<VpnProtocol>(),
|
||||||
|
Arg.Any<string>(),
|
||||||
|
Arg.Any<bool?>(),
|
||||||
|
Arg.Any<DateTimeOffset?>(),
|
||||||
|
Arg.Any<CancellationToken>()
|
||||||
|
)
|
||||||
|
.Returns(Result.Success());
|
||||||
|
|
||||||
|
var result = await CreateHandler(dbContext, userId)
|
||||||
|
.Handle(new MarkPaymentSentCommand(request.Id), CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.True(result.IsSuccess);
|
||||||
|
// enable:true + expiresAt в будущем — держим клиента рабочим на панели, пока заявка на проверке.
|
||||||
|
await _gateway
|
||||||
|
.Received(1)
|
||||||
|
.UpdateClientAsync(
|
||||||
|
Arg.Any<Node>(),
|
||||||
|
inbound.RemoteInboundId,
|
||||||
|
"ext-1",
|
||||||
|
VpnProtocol.Vless,
|
||||||
|
Arg.Any<string>(),
|
||||||
|
true,
|
||||||
|
Arg.Is<DateTimeOffset?>(d => d > DateTimeOffset.UtcNow),
|
||||||
|
Arg.Any<CancellationToken>()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Handle_WhenPaidUntilStillInFuture_DoesNotTouchGateway()
|
||||||
|
{
|
||||||
|
using var dbContext = InMemoryDbContextFactory.Create();
|
||||||
|
var userId = Guid.NewGuid();
|
||||||
|
var request = PaymentRequest.Create(userId, PaymentPeriod.Quarter, 1500);
|
||||||
|
dbContext.PaymentRequests.Add(request);
|
||||||
|
await dbContext.SaveChangesAsync(CancellationToken.None);
|
||||||
|
|
||||||
|
_identityService
|
||||||
|
.GetProfileAsync(userId, Arg.Any<CancellationToken>())
|
||||||
|
.Returns(Profile(userId, paidUntil: DateTimeOffset.UtcNow.AddDays(10)));
|
||||||
|
|
||||||
|
var result = await CreateHandler(dbContext, userId)
|
||||||
|
.Handle(new MarkPaymentSentCommand(request.Id), CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.True(result.IsSuccess);
|
||||||
|
await _gateway
|
||||||
|
.DidNotReceive()
|
||||||
|
.UpdateClientAsync(
|
||||||
|
Arg.Any<Node>(),
|
||||||
|
Arg.Any<string>(),
|
||||||
|
Arg.Any<string>(),
|
||||||
|
Arg.Any<VpnProtocol>(),
|
||||||
|
Arg.Any<string>(),
|
||||||
|
Arg.Any<bool?>(),
|
||||||
|
Arg.Any<DateTimeOffset?>(),
|
||||||
|
Arg.Any<CancellationToken>()
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+52
-26
@@ -488,33 +488,59 @@ Singleton (как `PricingSettings`) — реквизиты для оплаты
|
|||||||
### BillingService — приостановка за неуплату (фоновая джоба)
|
### BillingService — приостановка за неуплату (фоновая джоба)
|
||||||
`Infrastructure/BackgroundJobs/BillingService.cs`, раз в час (по образцу `TrafficSyncService`). Для
|
`Infrastructure/BackgroundJobs/BillingService.cs`, раз в час (по образцу `TrafficSyncService`). Для
|
||||||
каждого пользователя с billing-ролью, не заблокированного (`IsBlocked`):
|
каждого пользователя с billing-ролью, не заблокированного (`IsBlocked`):
|
||||||
- есть `PaymentRequest` в статусе `AwaitingConfirmation` → **пропустить** — это и есть защита «заявка
|
- есть **Subscription**-`PaymentRequest` в статусе `AwaitingConfirmation` → не гасить, а защитить
|
||||||
висит на подтверждении админом, а срок истёк» из требований: конфиги не гасятся, пока админ не
|
(`BillingConfigResumer.ProtectPendingConfigsAsync`, см. ниже) и пропустить остальную обработку тика.
|
||||||
решит (не по вине пользователя, что админ не успел проверить оплату);
|
`RoleChangeTopUp` в этот фильтр намеренно не входит — доплата за апгрейд роли не должна спасать от
|
||||||
- `BillingPaidUntil` в прошлом (или `null`) и ещё не `BillingSuspended` → приостановить все `Active`
|
приостановки за реально просроченную подписку;
|
||||||
конфиги (`Suspend()` → `Expired`, гейтвей `UpdateClientAsync(expiresAt: вчера)`, идемпотентно как в
|
- `BillingPaidUntil` в прошлом (или `null`) и ещё не `BillingSuspended` → приостановить
|
||||||
`BlockUserCommandHandler`), `AppUser.BillingSuspended = true`, Telegram-уведомление пользователю,
|
(`BillingConfigResumer.SuspendConfigsAsync`), `AppUser.BillingSuspended = true`, Telegram-уведомление
|
||||||
`AuditLog` (`BillingSuspended`, источник `System`). На последующих тиках (уже suspended) — только
|
пользователю, `AuditLog` (`BillingSuspended`, источник `System`). На последующих тиках (уже
|
||||||
идемпотентная досуспензия «зависших» `Active`-конфигов (самовосстановление после недоступности
|
suspended) — только идемпотентная досуспензия «зависших» конфигов (самовосстановление после
|
||||||
ноды), без повторных уведомлений;
|
недоступности ноды), без повторных уведомлений;
|
||||||
- до истечения ≤ 3 дней и предупреждение для этого `PaidUntil` ещё не отправлено
|
- до истечения ≤ 3 дней и предупреждение для этого `PaidUntil` ещё не отправлено
|
||||||
(`BillingLastWarnedForPaidUntil != PaidUntil`) → Telegram-предупреждение, отметка отправки.
|
(`BillingLastWarnedForPaidUntil != PaidUntil`) → Telegram-предупреждение, отметка отправки.
|
||||||
|
|
||||||
#### Приостановка/возврат за неуплату — через expiresAt, не enable
|
#### Синхронизация панели 3x-ui со статусом оплаты — `BillingConfigResumer`
|
||||||
|
|
||||||
Приостановка за неуплату (`BillingService`) и возврат (`BillingConfigResumer`) управляют панельным
|
`Application/Billing/BillingConfigResumer.cs` — общая точка для всей синхронизации панельного клиента
|
||||||
клиентом через `IXuiPanelGateway.UpdateClientAsync(..., expiresAt: ..., enable: null, ...)`, а не через
|
с оплатой; используется и `BillingService` (Infrastructure, за счёт направления зависимостей
|
||||||
`enable: false/true` — по факту эксплуатации переключение `enable` ненадёжно останавливает уже
|
`Infrastructure → Application`), и Application-хендлерами напрямую. Три операции, все — через
|
||||||
установленные соединения на стороне Xray/панели, а просроченный `expiryTime` — надёжно, и не зависит
|
`IXuiPanelGateway.UpdateClientAsync(..., enable: ..., expiresAt: ..., ...)`, и **всегда передают оба
|
||||||
от `enable`. Конкретно:
|
параметра явно** (не полагаясь на один `enable` или один `expiryTime`) — по факту эксплуатации ни один
|
||||||
- **Приостановка**: `expiresAt = UtcNow.AddDays(-1)` — гарантированно просроченная дата, `enable` не
|
из двух по отдельности не даёт достаточной гарантии (переключение `enable` не всегда обрывает уже
|
||||||
трогаем (`null`, «оставить как есть» — см. ThreeXui.Net `UpdateClientRequest`).
|
установленные соединения на стороне Xray/панели, поэтому дублируем просроченным/будущим `expiryTime`):
|
||||||
- **Возврат**: `expiresAt = newPaidUntil` (реальный новый срок оплаты, не «снять ограничение» на
|
|
||||||
бесконечность) — так панель/Xray сама несёт актуальный срок: если `BillingService` вдруг пропустит
|
- **`SuspendConfigsAsync`** — приостановка: `enable: false`, `expiresAt = UtcNow.AddDays(-1)`
|
||||||
тик, панель всё равно перестанет пускать по истечении этой даты независимо от приложения.
|
(гарантированно просроченная дата). Проходит и по `Active`, и по уже `Expired` конфигам (последние
|
||||||
- **Создание** (`CreateVpnConfigCommandHandler`) уже пушит `expiresAt = profile.BillingPaidUntil` при
|
могли быть временно защищены `ProtectPendingConfigsAsync` — см. ниже, — защиту с них тоже нужно снять
|
||||||
`BillingEnabled` через `AddClientAsync` — свежий конфиг сразу несёт правильный срок, не «без
|
при отклонении заявки, не только локальный статус). Вызывается из `BillingService` (часовой тик) и из
|
||||||
ограничения» до первого цикла `BillingService`.
|
`RejectPaymentRequestCommandHandler` (немедленно при отклонении — см. ниже).
|
||||||
|
- **`ResumeConfigsAsync`** — подтверждённая оплата/продление/гифт: `enable: true`, `expiresAt =
|
||||||
|
newPaidUntil` (реальный новый срок, не «снять ограничение» на бесконечность — так панель/Xray сама
|
||||||
|
несёт актуальный срок и продолжит блокировать по его истечении, даже если `BillingService` вдруг
|
||||||
|
пропустит тик). Пушится на панель для **любого** статуса конфига, не только `Expired` — пользователь
|
||||||
|
мог продлить/доплатить заранее, пока конфиг ещё `Active`, и панель должна узнать новый срок сразу, а
|
||||||
|
не только когда конфиг реально просрочится и `BillingService` его тронет.
|
||||||
|
- **`ProtectPendingConfigsAsync`** — заявка на оплату ждёт решения админа: `enable: true`, `expiresAt =
|
||||||
|
UtcNow + BillingSettings.GraceDays` — временный грейс, пока админ проверяет. **Не трогает** `Status`/
|
||||||
|
`ExpiresAt` в БД — это провизорная мера на панели до `Confirm` (`ResumeConfigsAsync` проставит
|
||||||
|
настоящий срок) или `Reject` (`SuspendConfigsAsync` вернёт как было). Нужна отдельно от простого
|
||||||
|
«пропустить приостановку», потому что Xray сам проверяет `expiryTime` клиента независимо от нашего
|
||||||
|
локального статуса — если реальный `BillingPaidUntil` уже в прошлом, панель заблокирует клиента сама,
|
||||||
|
даже если наше приложение ничего не приостанавливало. Вызывается сразу при `MarkPaymentSentCommandHandler`
|
||||||
|
(не ждём часовой тик — пользователь отметил оплату, конфиги должны остаться рабочими немедленно) и
|
||||||
|
повторно на каждом тике `BillingService`, пока заявка висит (идемпотентно продлевает грейс).
|
||||||
|
|
||||||
|
Симметрично: если админ **отклоняет** заявку (`RejectPaymentRequestCommandHandler`) и период всё ещё
|
||||||
|
просрочен, а других Subscription-заявок на проверке нет — `SuspendConfigsAsync` вызывается немедленно,
|
||||||
|
а не через до часа ожидания следующего тика `BillingService` (поиск «других заявок» явно исключает саму
|
||||||
|
отклоняемую по Id: `request.Reject(...)` меняет статус только в трекере EF, до `SaveChangesAsync` в БД
|
||||||
|
всё ещё лежит старое `AwaitingConfirmation` — без исключения по Id проверка ложно приняла бы её за ещё
|
||||||
|
одну висящую заявку).
|
||||||
|
|
||||||
|
Прочие точки, не входящие в `BillingConfigResumer`:
|
||||||
|
- **Создание** (`CreateVpnConfigCommandHandler`) пушит `expiresAt = profile.BillingPaidUntil` при
|
||||||
|
`BillingEnabled` через `AddClientAsync` — свежий конфиг сразу несёт правильный срок.
|
||||||
- **Ротация** (`RotateVpnConfigCommandHandler`) переносит текущий `config.ExpiresAt` на нового клиента
|
- **Ротация** (`RotateVpnConfigCommandHandler`) переносит текущий `config.ExpiresAt` на нового клиента
|
||||||
— ротация не должна ни продлевать, ни сбрасывать оплаченный период.
|
— ротация не должна ни продлевать, ни сбрасывать оплаченный период.
|
||||||
- Блокировка/разблокировка админом (`Disable()`/`Enable()`) — отдельная ось, управляет только
|
- Блокировка/разблокировка админом (`Disable()`/`Enable()`) — отдельная ось, управляет только
|
||||||
@@ -531,10 +557,10 @@ Singleton (как `PricingSettings`) — реквизиты для оплаты
|
|||||||
`GET/POST /api/billing/*` — пользователь (статус, создание/отмена заявки, «я оплатил», отправка
|
`GET/POST /api/billing/*` — пользователь (статус, создание/отмена заявки, «я оплатил», отправка
|
||||||
реквизитов в свой Telegram). `GET/PUT/POST /api/admin/billing/*` — админ (настройки, список заявок,
|
реквизитов в свой Telegram). `GET/PUT/POST /api/admin/billing/*` — админ (настройки, список заявок,
|
||||||
подтверждение/отклонение, `POST /gift` — выдать N дней конкретному пользователю без заявки), только
|
подтверждение/отклонение, `POST /gift` — выдать N дней конкретному пользователю без заявки), только
|
||||||
`admin`. Продление PaidUntil попадает в панель тремя путями — подтверждённая `PaymentRequest`,
|
`admin`. Продление `PaidUntil` попадает в панель тремя путями — подтверждённая `PaymentRequest`,
|
||||||
одобренная `SupportTicket(ExtensionRequest)` и прямой гифт от админа — все три используют один и тот
|
одобренная `SupportTicket(ExtensionRequest)` и прямой гифт от админа — все три используют один и тот
|
||||||
же `BillingConfigResumer` (см. Application/Billing), различается только вычисление `newPaidUntil`
|
же `BillingConfigResumer.ResumeConfigsAsync`, различается только вычисление `newPaidUntil` (месяцы для
|
||||||
(месяцы для оплаты, дни для продления/гифта) и триггер (пользователь vs админ).
|
оплаты, дни для продления/гифта) и триггер (пользователь vs админ).
|
||||||
|
|
||||||
### ActivationRequest — запрос активации
|
### ActivationRequest — запрос активации
|
||||||
Пользователь просит активацию у админа; админ одобряет/отклоняет на сайте или в Telegram.
|
Пользователь просит активацию у админа; админ одобряет/отклоняет на сайте или в Telegram.
|
||||||
|
|||||||
Reference in New Issue
Block a user