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