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; /// /// Продлевает оплату (max(текущий PaidUntil, сейчас) + период), возвращает в Active конфиги, /// приостановленные за неуплату (Expired), и синхронизирует ExpiresAt на все конфиги пользователя — /// зеркало UnblockUserCommandHandler, но по статусу Expired (биллинг), а не Disabled (блокировка). /// Проверка статуса + продление PaidUntil + Confirm() — под AdvisoryLock (по Id заявки): без неё /// конфирм с сайта, гонящийся с конфирмом из Telegram по одной и той же заявке, могли бы оба пройти /// проверку "ещё не решена" и оба продлить PaidUntil — двойное начисление за одну оплату. /// public sealed class ConfirmPaymentRequestCommandHandler( IAppDbContext dbContext, IIdentityService identityService, IXuiPanelGateway gateway, IRealtimeNotifier notifier, ITelegramNotifier telegramNotifier, ICurrentUser currentUser, ILogger logger ) : ICommandHandler { public async Task 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(); } /// Критическая секция под локом: проверка статуса, продление PaidUntil (для Subscription) /// и сам Confirm() — всё атомарно вместе, чтобы гонка не могла продлить PaidUntil дважды. Внешний /// I/O (гейтвей, Telegram) сюда намеренно не входит — см. AdvisoryLock. private async Task> 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)); } }