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

- Updated the `RejectPaymentRequestCommandHandler` to immediately suspend user configs if a payment request is rejected and no other pending subscription requests exist.
- Introduced `SuspendIfStillUnpaidAsync` method to handle the logic for suspending configs based on the user's billing status.
- Enhanced the `MarkPaymentSentCommandHandler` to protect configs during the payment confirmation process, ensuring users remain active while awaiting admin approval.
- Refactored `BillingConfigResumer` to include methods for protecting and suspending configs, improving the overall billing management flow.
- Updated tests to cover new behaviors and ensure proper functionality in various scenarios related to payment requests and config management.
This commit is contained in:
Leonid Pershin
2026-07-19 19:25:33 +03:00
parent 979eddf72e
commit b32756d5bc
8 changed files with 617 additions and 200 deletions
@@ -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<RejectPaymentRequestCommandHandler> logger
) : ICommandHandler<RejectPaymentRequestCommand, Result>
{
public async Task<Result> 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
);
}
}