Implement billing status notification and enhance user management integration
CI / Backend (build + test) (push) Successful in 1m30s
CI / Frontend (lint + typecheck + build) (push) Successful in 33s

- Added `NotifyBillingStatusChangedAsync` method to `IRealtimeNotifier` for notifying clients about changes in billing status.
- Updated `BillingConfigResumer` to call the new notification method after modifying billing configurations, ensuring users receive real-time updates.
- Enhanced `ListUsersQueryHandler` to include a `BillingPendingReview` property in `UserSummaryDto`, indicating if a user has a pending payment request awaiting confirmation.
- Refactored various command handlers to utilize `AdvisoryLock` for managing concurrent requests, preventing race conditions in billing operations.
- Updated tests to cover new notification behaviors and ensure proper functionality in billing status management.
This commit is contained in:
Leonid Pershin
2026-07-19 23:22:57 +03:00
parent b32756d5bc
commit e19860ba46
49 changed files with 1195 additions and 233 deletions
@@ -60,6 +60,8 @@ public static class BillingConfigResumer
config.SetBillingExpiry(newPaidUntil);
}
await notifier.NotifyBillingStatusChangedAsync(userId, cancellationToken);
}
/// <summary>Заявка на оплату ждёт решения админа (AwaitingConfirmation) — держим клиента рабочим
@@ -71,6 +73,7 @@ public static class BillingConfigResumer
public static async Task ProtectPendingConfigsAsync(
IAppDbContext dbContext,
IXuiPanelGateway gateway,
IRealtimeNotifier notifier,
ILogger logger,
Guid userId,
CancellationToken cancellationToken
@@ -94,6 +97,8 @@ public static class BillingConfigResumer
cancellationToken
);
}
await notifier.NotifyBillingStatusChangedAsync(userId, cancellationToken);
}
/// <summary>Приостановка за неуплату — общая для фоновой джобы (BillingService) и немедленной
@@ -139,6 +144,8 @@ public static class BillingConfigResumer
cancellationToken
);
}
await notifier.NotifyBillingStatusChangedAsync(userId, cancellationToken);
}
private static Task<List<VpnConfig>> ActiveOrExpiredConfigsAsync(
@@ -1,5 +1,6 @@
using Microsoft.EntityFrameworkCore;
using PnvPanel.Application.Auth;
using PnvPanel.Application.Common.Concurrency;
using PnvPanel.Application.Common.Interfaces;
using PnvPanel.Application.Common.Messaging;
using PnvPanel.Application.Common.Models;
@@ -8,6 +9,9 @@ using PnvPanel.Domain.Pricing;
namespace PnvPanel.Application.Billing.CreatePaymentRequest;
/// <summary>Проверка "нет активной заявки" + создание — под AdvisoryLock (по UserId): без неё
/// два одновременных запроса (двойной клик, повтор при таймауте) оба могли бы пройти проверку и
/// создать по заявке каждый — а дальше их обе можно независимо подтвердить, продлив оплату дважды.</summary>
public sealed class CreatePaymentRequestCommandHandler(
IAppDbContext dbContext,
IIdentityService identityService,
@@ -32,21 +36,6 @@ public sealed class CreatePaymentRequestCommandHandler(
if (profile.MaxConfigs == RoleQuota.Unlimited)
return Result.Failure<PaymentRequestDto>(BillingErrors.UnlimitedRoleNotSupported);
// RoleChangeTopUp не считается активной подписной заявкой — доплата за смену роли не должна
// мешать пользователю продлить/оформить обычную подписку.
var hasActiveRequest = await dbContext.PaymentRequests.AnyAsync(
r =>
r.UserId == userId
&& r.Kind == PaymentRequestKind.Subscription
&& (
r.Status == PaymentRequestStatus.AwaitingPayment
|| r.Status == PaymentRequestStatus.AwaitingConfirmation
),
cancellationToken
);
if (hasActiveRequest)
return Result.Failure<PaymentRequestDto>(BillingErrors.ActiveRequestExists);
var pricing = await dbContext.PricingSettings.AsNoTracking().FirstOrDefaultAsync(cancellationToken);
if (pricing is null)
return Result.Failure<PaymentRequestDto>(BillingErrors.PricingNotConfigured);
@@ -69,9 +58,35 @@ public sealed class CreatePaymentRequestCommandHandler(
var amount = PricingDiscount.Apply(rate * profile.MaxConfigs * command.Period.ToMonths(), discountPercent);
var request = PaymentRequest.Create(userId, command.Period, amount);
dbContext.PaymentRequests.Add(request);
var claimed = await AdvisoryLock.RunAsync(
dbContext,
userId,
async lockedCancellationToken =>
{
// RoleChangeTopUp не считается активной подписной заявкой — доплата за смену роли не
// должна мешать пользователю продлить/оформить обычную подписку.
var hasActiveRequest = await dbContext.PaymentRequests.AnyAsync(
r =>
r.UserId == userId
&& r.Kind == PaymentRequestKind.Subscription
&& (
r.Status == PaymentRequestStatus.AwaitingPayment
|| r.Status == PaymentRequestStatus.AwaitingConfirmation
),
lockedCancellationToken
);
if (hasActiveRequest)
return Result.Failure<PaymentRequest>(BillingErrors.ActiveRequestExists);
return Result.Success(PaymentRequestDto.FromDomain(request));
var request = PaymentRequest.Create(userId, command.Period, amount);
dbContext.PaymentRequests.Add(request);
return Result.Success(request);
},
cancellationToken
);
return claimed.IsSuccess
? Result.Success(PaymentRequestDto.FromDomain(claimed.Value))
: Result.Failure<PaymentRequestDto>(claimed.Error);
}
}
@@ -12,6 +12,7 @@ public sealed class MarkPaymentSentCommandHandler(
IAppDbContext dbContext,
IIdentityService identityService,
IXuiPanelGateway gateway,
IRealtimeNotifier notifier,
ITelegramNotifier telegramNotifier,
ICurrentUser currentUser,
ILogger<MarkPaymentSentCommandHandler> logger
@@ -48,6 +49,7 @@ public sealed class MarkPaymentSentCommandHandler(
await BillingConfigResumer.ProtectPendingConfigsAsync(
dbContext,
gateway,
notifier,
logger,
userId,
cancellationToken