- Replaced instances of the previous messaging system with LiteCqrs across various application components, enhancing the CQRS implementation. - Updated dependency injection to register LiteCqrs services and behaviors, streamlining command and query handling. - Adjusted multiple command and query handlers to align with the new messaging framework, ensuring consistent functionality and improved maintainability. - Added LiteCqrs package reference in the project file for better dependency management.
134 lines
5.9 KiB
C#
134 lines
5.9 KiB
C#
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 LiteCqrs;
|
||
using PnvPanel.Application.Common.Models;
|
||
using PnvPanel.Domain.Audit;
|
||
using PnvPanel.Domain.Billing;
|
||
|
||
namespace PnvPanel.Application.Admin.Billing;
|
||
|
||
/// <summary>
|
||
/// Продлевает оплату (max(текущий PaidUntil, сейчас) + период), возвращает в Active конфиги,
|
||
/// приостановленные за неуплату (Expired), и синхронизирует ExpiresAt на все конфиги пользователя —
|
||
/// зеркало UnblockUserCommandHandler, но по статусу Expired (биллинг), а не Disabled (блокировка).
|
||
/// Проверка статуса + продление PaidUntil + Confirm() — под AdvisoryLock (по Id заявки): без неё
|
||
/// конфирм с сайта, гонящийся с конфирмом из Telegram по одной и той же заявке, могли бы оба пройти
|
||
/// проверку "ещё не решена" и оба продлить PaidUntil — двойное начисление за одну оплату.
|
||
/// </summary>
|
||
public sealed class ConfirmPaymentRequestCommandHandler(
|
||
IAppDbContext dbContext,
|
||
IIdentityService identityService,
|
||
IXuiPanelGateway gateway,
|
||
IRealtimeNotifier notifier,
|
||
ITelegramNotifier telegramNotifier,
|
||
ICurrentUser currentUser,
|
||
ILogger<ConfirmPaymentRequestCommandHandler> logger
|
||
) : ICommandHandler<ConfirmPaymentRequestCommand, Result>
|
||
{
|
||
public async Task<Result> Handle(
|
||
ConfirmPaymentRequestCommand command,
|
||
CancellationToken cancellationToken
|
||
)
|
||
{
|
||
if (currentUser.UserId is not { } adminId)
|
||
return Result.Failure(AuthErrors.Unauthorized);
|
||
|
||
var claimed = await AdvisoryLock.RunAsync(
|
||
dbContext,
|
||
command.RequestId,
|
||
lockedCancellationToken => ClaimAndConfirmAsync(command.RequestId, adminId, lockedCancellationToken),
|
||
cancellationToken
|
||
);
|
||
if (!claimed.IsSuccess)
|
||
return Result.Failure(claimed.Error);
|
||
|
||
var (request, newPaidUntil) = claimed.Value;
|
||
|
||
// PlanChangeTopUp — доплата разницы в цене при увеличении тарифа, не покупка времени: подтверждение
|
||
// не возвращает Expired-конфиги (это делает обычная Subscription-оплата/продление).
|
||
if (request.Kind == PaymentRequestKind.Subscription)
|
||
{
|
||
await BillingConfigResumer.ResumeConfigsAsync(
|
||
dbContext,
|
||
gateway,
|
||
notifier,
|
||
logger,
|
||
request.UserId,
|
||
newPaidUntil!.Value,
|
||
cancellationToken
|
||
);
|
||
}
|
||
|
||
dbContext.AuditLogs.Add(
|
||
AuditLog.Create(
|
||
adminId,
|
||
"PaymentConfirmed",
|
||
"PaymentRequest",
|
||
request.Id.ToString(),
|
||
metadata: null,
|
||
AuditSource.Web
|
||
)
|
||
);
|
||
|
||
var message =
|
||
request.Kind == PaymentRequestKind.PlanChangeTopUp
|
||
? "✅ Доплата за смену тарифа подтверждена."
|
||
: $"✅ Оплата подтверждена. Доступ продлён до {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.PlanChangeTopUp)
|
||
{
|
||
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));
|
||
}
|
||
}
|