diff --git a/backend/src/PnvPanel.Application/Admin/Billing/RejectPaymentRequestCommandHandler.cs b/backend/src/PnvPanel.Application/Admin/Billing/RejectPaymentRequestCommandHandler.cs index 46a2643..d5d8a1f 100644 --- a/backend/src/PnvPanel.Application/Admin/Billing/RejectPaymentRequestCommandHandler.cs +++ b/backend/src/PnvPanel.Application/Admin/Billing/RejectPaymentRequestCommandHandler.cs @@ -1,4 +1,5 @@ using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; using PnvPanel.Application.Auth; using PnvPanel.Application.Billing; using PnvPanel.Application.Common.Interfaces; @@ -11,8 +12,12 @@ namespace PnvPanel.Application.Admin.Billing; public sealed class RejectPaymentRequestCommandHandler( IAppDbContext dbContext, + IIdentityService identityService, + IXuiPanelGateway gateway, + IRealtimeNotifier notifier, ITelegramNotifier telegramNotifier, - ICurrentUser currentUser + ICurrentUser currentUser, + ILogger logger ) : ICommandHandler { public async Task Handle( @@ -38,6 +43,13 @@ public sealed class RejectPaymentRequestCommandHandler( 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( AuditLog.Create( adminId, @@ -61,4 +73,41 @@ public sealed class RejectPaymentRequestCommandHandler( 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 + ); + } } diff --git a/backend/src/PnvPanel.Application/Billing/BillingConfigResumer.cs b/backend/src/PnvPanel.Application/Billing/BillingConfigResumer.cs index 52f535b..4cca6db 100644 --- a/backend/src/PnvPanel.Application/Billing/BillingConfigResumer.cs +++ b/backend/src/PnvPanel.Application/Billing/BillingConfigResumer.cs @@ -1,20 +1,26 @@ using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; using PnvPanel.Application.Common.Interfaces; +using PnvPanel.Domain.Billing; using PnvPanel.Domain.Configs; namespace PnvPanel.Application.Billing; /// -/// Общая часть "продлить оплату" — возвращает в Active конфиги, приостановленные за неуплату -/// (Expired), и синхронизирует ExpiresAt на все конфиги пользователя. Используется при подтверждении -/// заявки на оплату, одобрении заявки на продление и выдаче гифт-дней админом — расчёт самого -/// newPaidUntil (AddMonths для платежа, AddDays для продления/гифта) остаётся на вызывающей стороне, -/// как и AppUser.BillingPaidUntil (см. IIdentityService.ExtendBillingPaidUntilAsync, зовётся отдельно -/// до этого helper'а). +/// Общая синхронизация панели 3x-ui со статусом оплаты: продление (ResumeConfigsAsync), временная +/// защита на время рассмотрения заявки админом (ProtectPendingConfigsAsync) и приостановка за неуплату +/// (SuspendConfigsAsync). Xray проверяет expiryTime клиента сам, независимо от нашего локального +/// статуса — поэтому "просто не гасить, пока заявка на проверке" недостаточно: если реальный +/// BillingPaidUntil уже в прошлом, панель заблокирует клиента сама, пока мы явно не подвинем +/// expiryTime вперёд. По той же причине везде передаём enable И expiresAt вместе (см. +/// IXuiPanelGateway.UpdateClientAsync) — по факту эксплуатации одного enable недостаточно. /// -internal static class BillingConfigResumer +public static class BillingConfigResumer { + /// Подтверждённая оплата/продление/гифт — реальный новый срок. Пушится на панель для + /// ЛЮБОГО статуса конфига, не только Expired: пользователь мог доплатить/продлить заранее, пока + /// конфиг ещё Active — панель должна узнать новый срок сразу, а не только когда конфиг реально + /// просрочится. public static async Task ResumeConfigsAsync( IAppDbContext dbContext, IXuiPanelGateway gateway, @@ -25,58 +31,24 @@ internal static class BillingConfigResumer CancellationToken cancellationToken ) { - var configs = await dbContext - .VpnConfigs.Where(c => - c.UserId == userId - && (c.Status == ConfigStatus.Active || c.Status == ConfigStatus.Expired) - ) - .ToListAsync(cancellationToken); + var configs = await ActiveOrExpiredConfigsAsync(dbContext, userId, cancellationToken); 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(); await notifier.NotifyConfigStatusChangedAsync( config.UserId, @@ -89,4 +61,148 @@ internal static class BillingConfigResumer config.SetBillingExpiry(newPaidUntil); } } + + /// Заявка на оплату ждёт решения админа (AwaitingConfirmation) — держим клиента рабочим + /// на панели на время рассмотрения (expiresAt = сейчас + BillingSettings.GraceDays), не трогая + /// Status/ExpiresAt в БД — это временная мера до Confirm (ResumeConfigsAsync проставит настоящий + /// срок) или Reject (SuspendConfigsAsync вернёт как было). Вызывается и сразу при отметке "я + /// оплатил" (MarkPaymentSentCommandHandler), и на каждом тике BillingService, пока заявка висит — + /// идемпотентно, каждый вызов просто продлевает грейс ещё на GraceDays от текущего момента. + 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 + ); + } + } + + /// Приостановка за неуплату — общая для фоновой джобы (BillingService) и немедленной + /// реакции на отклонение заявки админом (RejectPaymentRequestCommandHandler), чтобы не ждать + /// следующего часового тика. Проходит и по уже Expired конфигам — они могли быть временно + /// "защищены" ProtectPendingConfigsAsync (enable/expiresAt на панели уехали вперёд, а статус в БД + /// остался как был), и при отклонении заявки эту защиту нужно снять с панели тоже, а не только + /// локально. + 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> ActiveOrExpiredConfigsAsync( + IAppDbContext dbContext, + Guid userId, + CancellationToken cancellationToken + ) => + dbContext + .VpnConfigs.Where(c => + c.UserId == userId + && (c.Status == ConfigStatus.Active || c.Status == ConfigStatus.Expired) + ) + .ToListAsync(cancellationToken); + + /// Возвращает false только если панель реально отвергла обновление (нода недоступна и + /// т.п.) — вызывающий тогда пропускает смену локального статуса, чтобы не разойтись с панелью + /// (подхватится следующим циклом/событием). Если у конфига нет инбаунда/ноды вовсе — на панели + /// нечего обновлять, но локальный статус менять можно (как и раньше в BillingService/ + /// BillingConfigResumer). + private static async Task 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; + } } diff --git a/backend/src/PnvPanel.Application/Billing/MarkPaymentSent/MarkPaymentSentCommandHandler.cs b/backend/src/PnvPanel.Application/Billing/MarkPaymentSent/MarkPaymentSentCommandHandler.cs index 9acb6ab..c25e9e9 100644 --- a/backend/src/PnvPanel.Application/Billing/MarkPaymentSent/MarkPaymentSentCommandHandler.cs +++ b/backend/src/PnvPanel.Application/Billing/MarkPaymentSent/MarkPaymentSentCommandHandler.cs @@ -1,4 +1,5 @@ using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; using PnvPanel.Application.Auth; using PnvPanel.Application.Common.Interfaces; using PnvPanel.Application.Common.Messaging; @@ -10,8 +11,10 @@ namespace PnvPanel.Application.Billing.MarkPaymentSent; public sealed class MarkPaymentSentCommandHandler( IAppDbContext dbContext, IIdentityService identityService, + IXuiPanelGateway gateway, ITelegramNotifier telegramNotifier, - ICurrentUser currentUser + ICurrentUser currentUser, + ILogger logger ) : ICommandHandler { public async Task Handle(MarkPaymentSentCommand command, CancellationToken cancellationToken) @@ -32,6 +35,25 @@ public sealed class MarkPaymentSentCommandHandler( request.MarkPaymentSent(); 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( request.Id, profile?.UserName ?? userId.ToString(), diff --git a/backend/src/PnvPanel.Infrastructure/BackgroundJobs/BillingService.cs b/backend/src/PnvPanel.Infrastructure/BackgroundJobs/BillingService.cs index 270c7c7..c1fef86 100644 --- a/backend/src/PnvPanel.Infrastructure/BackgroundJobs/BillingService.cs +++ b/backend/src/PnvPanel.Infrastructure/BackgroundJobs/BillingService.cs @@ -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; /// /// Обходит пользователей с billing-ролью: гасит конфиги, у кого истёк оплаченный период (Active → /// Expired, отдельно от блокировки админом — см. VpnConfig.Suspend), и шлёт предупреждение за 3 дня -/// до истечения. Пользователь с заявкой на оплату в AwaitingConfirmation не трогается вообще — пока -/// админ не подтвердит/отклонит, приостановка не наступает (не по вине пользователя, что админ не -/// успел проверить оплату). +/// до истечения. Пользователь с Subscription-заявкой на оплату в AwaitingConfirmation не гасится — +/// вместо этого на каждом тике "защищается" на панели (BillingConfigResumer.ProtectPendingConfigsAsync, +/// см. её XML-doc для причины) до тех пор, пока админ не подтвердит/отклонит (не по вине пользователя, +/// что админ не успел проверить оплату). /// 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 - ); - } - } } diff --git a/backend/tests/PnvPanel.Application.Tests/Admin/Billing/ConfirmPaymentRequestCommandHandlerTests.cs b/backend/tests/PnvPanel.Application.Tests/Admin/Billing/ConfirmPaymentRequestCommandHandlerTests.cs index 845dd00..d71f384 100644 --- a/backend/tests/PnvPanel.Application.Tests/Admin/Billing/ConfirmPaymentRequestCommandHandlerTests.cs +++ b/backend/tests/PnvPanel.Application.Tests/Admin/Billing/ConfirmPaymentRequestCommandHandlerTests.cs @@ -193,8 +193,9 @@ public class ConfirmPaymentRequestCommandHandlerTests Assert.NotNull(expiredConfig.ExpiresAt); Assert.NotNull(activeConfig.ExpiresAt); Assert.Equal(expiredConfig.ExpiresAt, activeConfig.ExpiresAt); - // enable: null — возврат из приостановки теперь идёт через expiresAt (новый newPaidUntil), - // а не через переключение enable (см. IXuiPanelGateway.UpdateClientAsync). + // enable:true И expiresAt=новый newPaidUntil вместе (см. IXuiPanelGateway.UpdateClientAsync) — + // и для ранее Expired конфига (ext-1), и для уже Active (ext-2): пользователь мог доплатить + // заранее, панель должна узнать новый срок сразу, а не только когда конфиг реально просрочится. await _gateway .Received(1) .UpdateClientAsync( @@ -203,10 +204,22 @@ public class ConfirmPaymentRequestCommandHandlerTests "ext-1", VpnProtocol.Vless, Arg.Any(), - null, + true, Arg.Is(d => d == expiredConfig.ExpiresAt), Arg.Any() ); + await _gateway + .Received(1) + .UpdateClientAsync( + Arg.Any(), + inbound.RemoteInboundId, + "ext-2", + VpnProtocol.Vless, + Arg.Any(), + true, + Arg.Is(d => d == activeConfig.ExpiresAt), + Arg.Any() + ); } [Fact] diff --git a/backend/tests/PnvPanel.Application.Tests/Admin/Billing/RejectPaymentRequestCommandHandlerTests.cs b/backend/tests/PnvPanel.Application.Tests/Admin/Billing/RejectPaymentRequestCommandHandlerTests.cs index e4c5c5e..1e16883 100644 --- a/backend/tests/PnvPanel.Application.Tests/Admin/Billing/RejectPaymentRequestCommandHandlerTests.cs +++ b/backend/tests/PnvPanel.Application.Tests/Admin/Billing/RejectPaymentRequestCommandHandlerTests.cs @@ -1,16 +1,54 @@ +using Microsoft.Extensions.Logging; using NSubstitute; using PnvPanel.Application.Admin.Billing; using PnvPanel.Application.Billing; using PnvPanel.Application.Common.Interfaces; +using PnvPanel.Application.Common.Models; using PnvPanel.Application.Tests.TestSupport; using PnvPanel.Domain.Billing; +using PnvPanel.Domain.Configs; +using PnvPanel.Domain.Inbounds; +using PnvPanel.Domain.Nodes; using Xunit; namespace PnvPanel.Application.Tests.Admin.Billing; public class RejectPaymentRequestCommandHandlerTests { + private readonly IIdentityService _identityService = Substitute.For(); + private readonly IXuiPanelGateway _gateway = Substitute.For(); + private readonly IRealtimeNotifier _notifier = Substitute.For(); private readonly ITelegramNotifier _telegramNotifier = Substitute.For(); + private readonly ILogger _logger = Substitute.For< + ILogger + >(); + + 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] public async Task Handle_WhenAwaitingConfirmation_RejectsAndNotifiesUser() @@ -23,16 +61,8 @@ public class RejectPaymentRequestCommandHandlerTests dbContext.PaymentRequests.Add(request); await dbContext.SaveChangesAsync(CancellationToken.None); - var handler = new RejectPaymentRequestCommandHandler( - dbContext, - _telegramNotifier, - FakeCurrentUser.Authenticated(adminId, "admin") - ); - - var result = await handler.Handle( - new RejectPaymentRequestCommand(request.Id, "Платёж не найден"), - CancellationToken.None - ); + var result = await CreateHandler(dbContext, adminId) + .Handle(new RejectPaymentRequestCommand(request.Id, "Платёж не найден"), CancellationToken.None); Assert.True(result.IsSuccess); Assert.Equal(PaymentRequestStatus.Rejected, request.Status); @@ -52,18 +82,120 @@ public class RejectPaymentRequestCommandHandlerTests { using var dbContext = InMemoryDbContextFactory.Create(); - var handler = new RejectPaymentRequestCommandHandler( - dbContext, - _telegramNotifier, - FakeCurrentUser.Authenticated(Guid.NewGuid(), "admin") - ); - - var result = await handler.Handle( - new RejectPaymentRequestCommand(Guid.NewGuid(), null), - CancellationToken.None - ); + var result = await CreateHandler(dbContext, Guid.NewGuid()) + .Handle(new RejectPaymentRequestCommand(Guid.NewGuid(), null), CancellationToken.None); Assert.False(result.IsSuccess); 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()) + .Returns(Profile(userId, paidUntil: DateTimeOffset.UtcNow.AddDays(-1))); + _gateway + .UpdateClientAsync( + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any() + ) + .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(), + inbound.RemoteInboundId, + "ext-1", + VpnProtocol.Vless, + Arg.Any(), + false, + Arg.Any(), + Arg.Any() + ); + } + + [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()) + .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(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any() + ); + } } diff --git a/backend/tests/PnvPanel.Application.Tests/Billing/MarkPaymentSent/MarkPaymentSentCommandHandlerTests.cs b/backend/tests/PnvPanel.Application.Tests/Billing/MarkPaymentSent/MarkPaymentSentCommandHandlerTests.cs index 9760a91..be555d2 100644 --- a/backend/tests/PnvPanel.Application.Tests/Billing/MarkPaymentSent/MarkPaymentSentCommandHandlerTests.cs +++ b/backend/tests/PnvPanel.Application.Tests/Billing/MarkPaymentSent/MarkPaymentSentCommandHandlerTests.cs @@ -1,9 +1,13 @@ +using Microsoft.Extensions.Logging; using NSubstitute; using PnvPanel.Application.Billing; using PnvPanel.Application.Billing.MarkPaymentSent; using PnvPanel.Application.Common.Interfaces; +using PnvPanel.Application.Common.Models; using PnvPanel.Application.Tests.TestSupport; using PnvPanel.Domain.Billing; +using PnvPanel.Domain.Inbounds; +using PnvPanel.Domain.Nodes; using Xunit; namespace PnvPanel.Application.Tests.Billing.MarkPaymentSent; @@ -11,7 +15,30 @@ namespace PnvPanel.Application.Tests.Billing.MarkPaymentSent; public class MarkPaymentSentCommandHandlerTests { private readonly IIdentityService _identityService = Substitute.For(); + private readonly IXuiPanelGateway _gateway = Substitute.For(); private readonly ITelegramNotifier _telegramNotifier = Substitute.For(); + private readonly ILogger _logger = Substitute.For< + ILogger + >(); + + 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] public async Task Handle_WhenAwaitingPayment_MovesToAwaitingConfirmationAndNotifiesAdmins() @@ -22,17 +49,8 @@ public class MarkPaymentSentCommandHandlerTests dbContext.PaymentRequests.Add(request); await dbContext.SaveChangesAsync(CancellationToken.None); - var handler = new MarkPaymentSentCommandHandler( - dbContext, - _identityService, - _telegramNotifier, - FakeCurrentUser.Authenticated(userId, "alice") - ); - - var result = await handler.Handle( - new MarkPaymentSentCommand(request.Id), - CancellationToken.None - ); + var result = await CreateHandler(dbContext, userId) + .Handle(new MarkPaymentSentCommand(request.Id), CancellationToken.None); Assert.True(result.IsSuccess); Assert.Equal(PaymentRequestStatus.AwaitingConfirmation, request.Status); @@ -58,19 +76,99 @@ public class MarkPaymentSentCommandHandlerTests dbContext.PaymentRequests.Add(request); await dbContext.SaveChangesAsync(CancellationToken.None); - var handler = new MarkPaymentSentCommandHandler( - dbContext, - _identityService, - _telegramNotifier, - FakeCurrentUser.Authenticated(userId) - ); - - var result = await handler.Handle( - new MarkPaymentSentCommand(request.Id), - CancellationToken.None - ); + var result = await CreateHandler(dbContext, userId) + .Handle(new MarkPaymentSentCommand(request.Id), CancellationToken.None); Assert.False(result.IsSuccess); 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()) + .Returns(Profile(userId, paidUntil: DateTimeOffset.UtcNow.AddDays(-1))); + _gateway + .UpdateClientAsync( + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any() + ) + .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(), + inbound.RemoteInboundId, + "ext-1", + VpnProtocol.Vless, + Arg.Any(), + true, + Arg.Is(d => d > DateTimeOffset.UtcNow), + Arg.Any() + ); + } + + [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()) + .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(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any() + ); + } } diff --git a/docs/domain-model.md b/docs/domain-model.md index 6728391..2a11de4 100644 --- a/docs/domain-model.md +++ b/docs/domain-model.md @@ -488,33 +488,59 @@ Singleton (как `PricingSettings`) — реквизиты для оплаты ### BillingService — приостановка за неуплату (фоновая джоба) `Infrastructure/BackgroundJobs/BillingService.cs`, раз в час (по образцу `TrafficSyncService`). Для каждого пользователя с billing-ролью, не заблокированного (`IsBlocked`): -- есть `PaymentRequest` в статусе `AwaitingConfirmation` → **пропустить** — это и есть защита «заявка - висит на подтверждении админом, а срок истёк» из требований: конфиги не гасятся, пока админ не - решит (не по вине пользователя, что админ не успел проверить оплату); -- `BillingPaidUntil` в прошлом (или `null`) и ещё не `BillingSuspended` → приостановить все `Active` - конфиги (`Suspend()` → `Expired`, гейтвей `UpdateClientAsync(expiresAt: вчера)`, идемпотентно как в - `BlockUserCommandHandler`), `AppUser.BillingSuspended = true`, Telegram-уведомление пользователю, - `AuditLog` (`BillingSuspended`, источник `System`). На последующих тиках (уже suspended) — только - идемпотентная досуспензия «зависших» `Active`-конфигов (самовосстановление после недоступности - ноды), без повторных уведомлений; +- есть **Subscription**-`PaymentRequest` в статусе `AwaitingConfirmation` → не гасить, а защитить + (`BillingConfigResumer.ProtectPendingConfigsAsync`, см. ниже) и пропустить остальную обработку тика. + `RoleChangeTopUp` в этот фильтр намеренно не входит — доплата за апгрейд роли не должна спасать от + приостановки за реально просроченную подписку; +- `BillingPaidUntil` в прошлом (или `null`) и ещё не `BillingSuspended` → приостановить + (`BillingConfigResumer.SuspendConfigsAsync`), `AppUser.BillingSuspended = true`, Telegram-уведомление + пользователю, `AuditLog` (`BillingSuspended`, источник `System`). На последующих тиках (уже + suspended) — только идемпотентная досуспензия «зависших» конфигов (самовосстановление после + недоступности ноды), без повторных уведомлений; - до истечения ≤ 3 дней и предупреждение для этого `PaidUntil` ещё не отправлено (`BillingLastWarnedForPaidUntil != PaidUntil`) → Telegram-предупреждение, отметка отправки. -#### Приостановка/возврат за неуплату — через expiresAt, не enable +#### Синхронизация панели 3x-ui со статусом оплаты — `BillingConfigResumer` -Приостановка за неуплату (`BillingService`) и возврат (`BillingConfigResumer`) управляют панельным -клиентом через `IXuiPanelGateway.UpdateClientAsync(..., expiresAt: ..., enable: null, ...)`, а не через -`enable: false/true` — по факту эксплуатации переключение `enable` ненадёжно останавливает уже -установленные соединения на стороне Xray/панели, а просроченный `expiryTime` — надёжно, и не зависит -от `enable`. Конкретно: -- **Приостановка**: `expiresAt = UtcNow.AddDays(-1)` — гарантированно просроченная дата, `enable` не - трогаем (`null`, «оставить как есть» — см. ThreeXui.Net `UpdateClientRequest`). -- **Возврат**: `expiresAt = newPaidUntil` (реальный новый срок оплаты, не «снять ограничение» на - бесконечность) — так панель/Xray сама несёт актуальный срок: если `BillingService` вдруг пропустит - тик, панель всё равно перестанет пускать по истечении этой даты независимо от приложения. -- **Создание** (`CreateVpnConfigCommandHandler`) уже пушит `expiresAt = profile.BillingPaidUntil` при - `BillingEnabled` через `AddClientAsync` — свежий конфиг сразу несёт правильный срок, не «без - ограничения» до первого цикла `BillingService`. +`Application/Billing/BillingConfigResumer.cs` — общая точка для всей синхронизации панельного клиента +с оплатой; используется и `BillingService` (Infrastructure, за счёт направления зависимостей +`Infrastructure → Application`), и Application-хендлерами напрямую. Три операции, все — через +`IXuiPanelGateway.UpdateClientAsync(..., enable: ..., expiresAt: ..., ...)`, и **всегда передают оба +параметра явно** (не полагаясь на один `enable` или один `expiryTime`) — по факту эксплуатации ни один +из двух по отдельности не даёт достаточной гарантии (переключение `enable` не всегда обрывает уже +установленные соединения на стороне Xray/панели, поэтому дублируем просроченным/будущим `expiryTime`): + +- **`SuspendConfigsAsync`** — приостановка: `enable: false`, `expiresAt = UtcNow.AddDays(-1)` + (гарантированно просроченная дата). Проходит и по `Active`, и по уже `Expired` конфигам (последние + могли быть временно защищены `ProtectPendingConfigsAsync` — см. ниже, — защиту с них тоже нужно снять + при отклонении заявки, не только локальный статус). Вызывается из `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` на нового клиента — ротация не должна ни продлевать, ни сбрасывать оплаченный период. - Блокировка/разблокировка админом (`Disable()`/`Enable()`) — отдельная ось, управляет только @@ -531,10 +557,10 @@ Singleton (как `PricingSettings`) — реквизиты для оплаты `GET/POST /api/billing/*` — пользователь (статус, создание/отмена заявки, «я оплатил», отправка реквизитов в свой Telegram). `GET/PUT/POST /api/admin/billing/*` — админ (настройки, список заявок, подтверждение/отклонение, `POST /gift` — выдать N дней конкретному пользователю без заявки), только -`admin`. Продление PaidUntil попадает в панель тремя путями — подтверждённая `PaymentRequest`, +`admin`. Продление `PaidUntil` попадает в панель тремя путями — подтверждённая `PaymentRequest`, одобренная `SupportTicket(ExtensionRequest)` и прямой гифт от админа — все три используют один и тот -же `BillingConfigResumer` (см. Application/Billing), различается только вычисление `newPaidUntil` -(месяцы для оплаты, дни для продления/гифта) и триггер (пользователь vs админ). +же `BillingConfigResumer.ResumeConfigsAsync`, различается только вычисление `newPaidUntil` (месяцы для +оплаты, дни для продления/гифта) и триггер (пользователь vs админ). ### ActivationRequest — запрос активации Пользователь просит активацию у админа; админ одобряет/отклоняет на сайте или в Telegram.