Implement billing status notification and enhance user management integration
- 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:
@@ -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(
|
||||
|
||||
+33
-18
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
+2
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user