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:
+72
-64
@@ -2,6 +2,7 @@ using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using PnvPanel.Application.Auth;
|
||||
using PnvPanel.Application.Billing;
|
||||
using PnvPanel.Application.Common.Concurrency;
|
||||
using PnvPanel.Application.Common.Interfaces;
|
||||
using PnvPanel.Application.Common.Messaging;
|
||||
using PnvPanel.Application.Common.Models;
|
||||
@@ -14,6 +15,9 @@ namespace PnvPanel.Application.Admin.Billing;
|
||||
/// Продлевает оплату (max(текущий PaidUntil, сейчас) + период), возвращает в Active конфиги,
|
||||
/// приостановленные за неуплату (Expired), и синхронизирует ExpiresAt на все конфиги пользователя —
|
||||
/// зеркало UnblockUserCommandHandler, но по статусу Expired (биллинг), а не Disabled (блокировка).
|
||||
/// Проверка статуса + продление PaidUntil + Confirm() — под AdvisoryLock (по Id заявки): без неё
|
||||
/// конфирм с сайта, гонящийся с конфирмом из Telegram по одной и той же заявке, могли бы оба пройти
|
||||
/// проверку "ещё не решена" и оба продлить PaidUntil — двойное начисление за одну оплату.
|
||||
/// </summary>
|
||||
public sealed class ConfirmPaymentRequestCommandHandler(
|
||||
IAppDbContext dbContext,
|
||||
@@ -33,75 +37,32 @@ public sealed class ConfirmPaymentRequestCommandHandler(
|
||||
if (currentUser.UserId is not { } adminId)
|
||||
return Result.Failure(AuthErrors.Unauthorized);
|
||||
|
||||
var request = await dbContext.PaymentRequests.FirstOrDefaultAsync(
|
||||
r => r.Id == command.RequestId,
|
||||
var claimed = await AdvisoryLock.RunAsync(
|
||||
dbContext,
|
||||
command.RequestId,
|
||||
lockedCancellationToken => ClaimAndConfirmAsync(command.RequestId, adminId, lockedCancellationToken),
|
||||
cancellationToken
|
||||
);
|
||||
if (request is null)
|
||||
return Result.Failure(BillingErrors.RequestNotFound);
|
||||
if (!claimed.IsSuccess)
|
||||
return Result.Failure(claimed.Error);
|
||||
|
||||
if (
|
||||
request.Status
|
||||
is not (PaymentRequestStatus.AwaitingPayment or PaymentRequestStatus.AwaitingConfirmation)
|
||||
)
|
||||
return Result.Failure(BillingErrors.RequestNotDecidable);
|
||||
|
||||
var profile = await identityService.GetProfileAsync(request.UserId, cancellationToken);
|
||||
if (profile is null)
|
||||
return Result.Failure(AuthErrors.Unauthorized);
|
||||
var (request, newPaidUntil) = claimed.Value;
|
||||
|
||||
// RoleChangeTopUp — доплата разницы в цене при апгрейде роли, не покупка времени: подтверждение
|
||||
// не трогает BillingPaidUntil и не возвращает Expired-конфиги (это делает обычная Subscription-
|
||||
// оплата/продление). Period не задан для этого Kind — ToMonths() здесь неприменим.
|
||||
if (request.Kind == PaymentRequestKind.RoleChangeTopUp)
|
||||
// не возвращает Expired-конфиги (это делает обычная Subscription-оплата/продление).
|
||||
if (request.Kind == PaymentRequestKind.Subscription)
|
||||
{
|
||||
request.Confirm(adminId);
|
||||
|
||||
dbContext.AuditLogs.Add(
|
||||
AuditLog.Create(
|
||||
adminId,
|
||||
"PaymentConfirmed",
|
||||
"PaymentRequest",
|
||||
request.Id.ToString(),
|
||||
metadata: null,
|
||||
AuditSource.Web
|
||||
)
|
||||
);
|
||||
|
||||
await telegramNotifier.NotifyUserAsync(
|
||||
await BillingConfigResumer.ResumeConfigsAsync(
|
||||
dbContext,
|
||||
gateway,
|
||||
notifier,
|
||||
logger,
|
||||
request.UserId,
|
||||
"✅ Доплата за смену роли подтверждена.",
|
||||
null,
|
||||
newPaidUntil!.Value,
|
||||
cancellationToken
|
||||
);
|
||||
|
||||
return Result.Success();
|
||||
}
|
||||
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var baseline = profile.BillingPaidUntil is { } paidUntil && paidUntil > now ? paidUntil : now;
|
||||
var newPaidUntil = baseline.AddMonths(request.Period!.Value.ToMonths());
|
||||
|
||||
var extendResult = await identityService.ExtendBillingPaidUntilAsync(
|
||||
request.UserId,
|
||||
newPaidUntil,
|
||||
cancellationToken
|
||||
);
|
||||
if (!extendResult.IsSuccess)
|
||||
return extendResult;
|
||||
|
||||
request.Confirm(adminId);
|
||||
|
||||
await BillingConfigResumer.ResumeConfigsAsync(
|
||||
dbContext,
|
||||
gateway,
|
||||
notifier,
|
||||
logger,
|
||||
request.UserId,
|
||||
newPaidUntil,
|
||||
cancellationToken
|
||||
);
|
||||
|
||||
dbContext.AuditLogs.Add(
|
||||
AuditLog.Create(
|
||||
adminId,
|
||||
@@ -113,13 +74,60 @@ public sealed class ConfirmPaymentRequestCommandHandler(
|
||||
)
|
||||
);
|
||||
|
||||
await telegramNotifier.NotifyUserAsync(
|
||||
request.UserId,
|
||||
$"✅ Оплата подтверждена. Доступ продлён до {newPaidUntil:dd.MM.yyyy}.",
|
||||
null,
|
||||
cancellationToken
|
||||
);
|
||||
var message =
|
||||
request.Kind == PaymentRequestKind.RoleChangeTopUp
|
||||
? "✅ Доплата за смену роли подтверждена."
|
||||
: $"✅ Оплата подтверждена. Доступ продлён до {newPaidUntil:dd.MM.yyyy}.";
|
||||
await telegramNotifier.NotifyUserAsync(request.UserId, message, null, cancellationToken);
|
||||
|
||||
return Result.Success();
|
||||
}
|
||||
|
||||
/// <summary>Критическая секция под локом: проверка статуса, продление PaidUntil (для Subscription)
|
||||
/// и сам Confirm() — всё атомарно вместе, чтобы гонка не могла продлить PaidUntil дважды. Внешний
|
||||
/// I/O (гейтвей, Telegram) сюда намеренно не входит — см. AdvisoryLock.</summary>
|
||||
private async Task<Result<(PaymentRequest Request, DateTimeOffset? NewPaidUntil)>> ClaimAndConfirmAsync(
|
||||
Guid requestId,
|
||||
Guid adminId,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var request = await dbContext.PaymentRequests.FirstOrDefaultAsync(
|
||||
r => r.Id == requestId,
|
||||
cancellationToken
|
||||
);
|
||||
if (request is null)
|
||||
return Result.Failure<(PaymentRequest, DateTimeOffset?)>(BillingErrors.RequestNotFound);
|
||||
|
||||
if (
|
||||
request.Status
|
||||
is not (PaymentRequestStatus.AwaitingPayment or PaymentRequestStatus.AwaitingConfirmation)
|
||||
)
|
||||
return Result.Failure<(PaymentRequest, DateTimeOffset?)>(BillingErrors.RequestNotDecidable);
|
||||
|
||||
if (request.Kind == PaymentRequestKind.RoleChangeTopUp)
|
||||
{
|
||||
request.Confirm(adminId);
|
||||
return Result.Success<(PaymentRequest, DateTimeOffset?)>((request, null));
|
||||
}
|
||||
|
||||
var profile = await identityService.GetProfileAsync(request.UserId, cancellationToken);
|
||||
if (profile is null)
|
||||
return Result.Failure<(PaymentRequest, DateTimeOffset?)>(AuthErrors.Unauthorized);
|
||||
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var baseline = profile.BillingPaidUntil is { } paidUntil && paidUntil > now ? paidUntil : now;
|
||||
var newPaidUntil = baseline.AddMonths(request.Period!.Value.ToMonths());
|
||||
|
||||
var extendResult = await identityService.ExtendBillingPaidUntilAsync(
|
||||
request.UserId,
|
||||
newPaidUntil,
|
||||
cancellationToken
|
||||
);
|
||||
if (!extendResult.IsSuccess)
|
||||
return Result.Failure<(PaymentRequest, DateTimeOffset?)>(extendResult.Error);
|
||||
|
||||
request.Confirm(adminId);
|
||||
return Result.Success<(PaymentRequest, DateTimeOffset?)>((request, newPaidUntil));
|
||||
}
|
||||
}
|
||||
|
||||
+28
-11
@@ -2,6 +2,7 @@ using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using PnvPanel.Application.Auth;
|
||||
using PnvPanel.Application.Billing;
|
||||
using PnvPanel.Application.Common.Concurrency;
|
||||
using PnvPanel.Application.Common.Interfaces;
|
||||
using PnvPanel.Application.Common.Messaging;
|
||||
using PnvPanel.Application.Common.Models;
|
||||
@@ -10,6 +11,9 @@ using PnvPanel.Domain.Billing;
|
||||
|
||||
namespace PnvPanel.Application.Admin.Billing;
|
||||
|
||||
/// <summary>Проверка статуса + Reject() — под AdvisoryLock (по Id заявки), тот же приём, что и в
|
||||
/// ConfirmPaymentRequestCommandHandler — без него отклонение с сайта, гонящееся с отклонением из
|
||||
/// Telegram, могли бы оба пройти проверку "ещё не решена".</summary>
|
||||
public sealed class RejectPaymentRequestCommandHandler(
|
||||
IAppDbContext dbContext,
|
||||
IIdentityService identityService,
|
||||
@@ -28,20 +32,33 @@ public sealed class RejectPaymentRequestCommandHandler(
|
||||
if (currentUser.UserId is not { } adminId)
|
||||
return Result.Failure(AuthErrors.Unauthorized);
|
||||
|
||||
var request = await dbContext.PaymentRequests.FirstOrDefaultAsync(
|
||||
r => r.Id == command.RequestId,
|
||||
var claimed = await AdvisoryLock.RunAsync(
|
||||
dbContext,
|
||||
command.RequestId,
|
||||
async lockedCancellationToken =>
|
||||
{
|
||||
var fresh = await dbContext.PaymentRequests.FirstOrDefaultAsync(
|
||||
r => r.Id == command.RequestId,
|
||||
lockedCancellationToken
|
||||
);
|
||||
if (fresh is null)
|
||||
return Result.Failure<PaymentRequest>(BillingErrors.RequestNotFound);
|
||||
|
||||
if (
|
||||
fresh.Status
|
||||
is not (PaymentRequestStatus.AwaitingPayment or PaymentRequestStatus.AwaitingConfirmation)
|
||||
)
|
||||
return Result.Failure<PaymentRequest>(BillingErrors.RequestNotDecidable);
|
||||
|
||||
fresh.Reject(adminId, command.Reason);
|
||||
return Result.Success(fresh);
|
||||
},
|
||||
cancellationToken
|
||||
);
|
||||
if (request is null)
|
||||
return Result.Failure(BillingErrors.RequestNotFound);
|
||||
if (!claimed.IsSuccess)
|
||||
return Result.Failure(claimed.Error);
|
||||
|
||||
if (
|
||||
request.Status
|
||||
is not (PaymentRequestStatus.AwaitingPayment or PaymentRequestStatus.AwaitingConfirmation)
|
||||
)
|
||||
return Result.Failure(BillingErrors.RequestNotDecidable);
|
||||
|
||||
request.Reject(adminId, command.Reason);
|
||||
var request = claimed.Value;
|
||||
|
||||
// Пока заявка висела на проверке, конфиги могли быть временно "защищены" на панели
|
||||
// (ProtectPendingConfigsAsync — enable/expiresAt подвинуты вперёд без изменения локального
|
||||
|
||||
Reference in New Issue
Block a user