using Microsoft.EntityFrameworkCore;
using PnvPanel.Application.Auth;
using PnvPanel.Application.Common.Concurrency;
using PnvPanel.Application.Common.Interfaces;
using PnvPanel.Application.Common.Messaging;
using PnvPanel.Application.Common.Models;
using PnvPanel.Domain.Billing;
using PnvPanel.Domain.Pricing;
namespace PnvPanel.Application.Billing.CreatePaymentRequest;
/// Проверка "нет активной заявки" + создание — под AdvisoryLock (по UserId): без неё
/// два одновременных запроса (двойной клик, повтор при таймауте) оба могли бы пройти проверку и
/// создать по заявке каждый — а дальше их обе можно независимо подтвердить, продлив оплату дважды.
public sealed class CreatePaymentRequestCommandHandler(
IAppDbContext dbContext,
IIdentityService identityService,
ICurrentUser currentUser
) : ICommandHandler>
{
public async Task> Handle(
CreatePaymentRequestCommand command,
CancellationToken cancellationToken
)
{
if (currentUser.UserId is not { } userId)
return Result.Failure(AuthErrors.Unauthorized);
var profile = await identityService.GetProfileAsync(userId, cancellationToken);
if (profile is null)
return Result.Failure(AuthErrors.Unauthorized);
if (!profile.BillingEnabled)
return Result.Failure(BillingErrors.NotEnabled);
if (profile.ConfigQuota == RoleQuota.Unlimited)
return Result.Failure(BillingErrors.UnlimitedRoleNotSupported);
var pricing = await dbContext.PricingSettings.AsNoTracking().FirstOrDefaultAsync(cancellationToken);
if (pricing is null)
return Result.Failure(BillingErrors.PricingNotConfigured);
var ratePerMonth = command.Period switch
{
PaymentPeriod.Quarter => pricing.PricePerConfigPerQuarter,
PaymentPeriod.HalfYear => pricing.PricePerConfigPerHalfYear,
PaymentPeriod.Year => pricing.PricePerConfigPerYear,
_ => null,
};
if (ratePerMonth is not { } rate)
return Result.Failure(BillingErrors.PricingNotConfigured);
var discountTiers = await dbContext
.PricingDiscountTiers.AsNoTracking()
.Where(t => t.PricingSettingsId == pricing.Id)
.ToListAsync(cancellationToken);
var discountPercent = PricingDiscount.ResolvePercent(discountTiers, profile.ConfigQuota);
var amount = PricingDiscount.Apply(rate * profile.ConfigQuota * command.Period.ToMonths(), discountPercent);
var claimed = await AdvisoryLock.RunAsync(
dbContext,
userId,
async lockedCancellationToken =>
{
// PlanChangeTopUp не считается активной подписной заявкой — доплата за смену тарифа не
// должна мешать пользователю продлить/оформить обычную подписку.
var hasActiveRequest = await dbContext.PaymentRequests.AnyAsync(
r =>
r.UserId == userId
&& r.Kind == PaymentRequestKind.Subscription
&& (
r.Status == PaymentRequestStatus.AwaitingPayment
|| r.Status == PaymentRequestStatus.AwaitingConfirmation
),
lockedCancellationToken
);
if (hasActiveRequest)
return Result.Failure(BillingErrors.ActiveRequestExists);
var request = PaymentRequest.Create(userId, command.Period, amount);
dbContext.PaymentRequests.Add(request);
return Result.Success(request);
},
cancellationToken
);
return claimed.IsSuccess
? Result.Success(PaymentRequestDto.FromDomain(claimed.Value))
: Result.Failure(claimed.Error);
}
}