Files
PnvPanel/backend/src/PnvPanel.Application/Admin/Billing/ConfirmPaymentRequestCommandHandler.cs
T
Leonid Pershin b05b76f32f
CI / Backend (build + test) (push) Failing after 1m28s
CI / Frontend (lint + typecheck + build) (push) Successful in 47s
Refactor messaging system to utilize LiteCqrs library
- 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.
2026-07-24 04:16:38 +03:00

134 lines
5.9 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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));
}
}