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));
}
}