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.Application.Support; using PnvPanel.Domain.Audit; using PnvPanel.Domain.Billing; using PnvPanel.Domain.Support; namespace PnvPanel.Application.Admin.Support; /// /// Проверка статуса + создание/назначение роли + Resolve() + доплата (если роль подорожала) — под /// AdvisoryLock (по Id тикета): без неё одобрение с сайта, гонящееся с одобрением из Telegram по одной /// и той же заявке, могли бы оба пройти проверку "ещё не решена" и оба создать RoleChangeTopUp — /// двойной счёт за один апгрейд. /// public sealed class ApproveRoleRequestCommandHandler( IAppDbContext dbContext, IRoleService roleService, IIdentityService identityService, IRealtimeNotifier notifier, ITelegramNotifier telegramNotifier, ICurrentUser currentUser ) : ICommandHandler { public async Task Handle( ApproveRoleRequestCommand command, CancellationToken cancellationToken ) { if (currentUser.UserId is not { } adminId) return Result.Failure(AuthErrors.Unauthorized); var claimed = await AdvisoryLock.RunAsync( dbContext, command.TicketId, lockedCancellationToken => ApproveAsync(command.TicketId, adminId, lockedCancellationToken), cancellationToken ); if (!claimed.IsSuccess) return Result.Failure(claimed.Error); var (ticket, topUpAmount) = claimed.Value; await notifier.NotifyTicketUpdatedAsync(ticket.Id, ticket.UserId, cancellationToken); await telegramNotifier.NotifyUserAsync( ticket.UserId, "✅ Ваша заявка на роль одобрена.", $"/support?ticket={ticket.Id}", cancellationToken ); if (topUpAmount is { } amount) { await telegramNotifier.NotifyUserAsync( ticket.UserId, $"💳 Новая роль дороже прежней — требуется доплата {amount} ₽ за оставшуюся часть оплаченного периода.", "/billing", cancellationToken ); } return Result.Success(); } private async Task> ApproveAsync( Guid ticketId, Guid adminId, CancellationToken cancellationToken ) { var ticket = await dbContext.SupportTickets.FirstOrDefaultAsync( t => t.Id == ticketId, cancellationToken ); if (ticket is null) return Result.Failure<(SupportTicket, int?)>(SupportErrors.NotFound); if (ticket.Type != TicketType.RoleRequest) return Result.Failure<(SupportTicket, int?)>(SupportErrors.NotRoleRequest); if (ticket.Status != TicketStatus.Open) return Result.Failure<(SupportTicket, int?)>(SupportErrors.NotOpen); // Снимаем профиль ДО смены роли — нужен старый MaxConfigs/BillingPaidUntil для проратированной // доплаты за апгрейд (см. ниже), после ChangeUserRoleAsync эти данные уже недоступны. var oldProfile = await identityService.GetProfileAsync(ticket.UserId, cancellationToken); RoleDto newRole; if (ticket.RequestedRoleId is { } existingRoleId) { var roles = await roleService.ListRolesAsync(cancellationToken); var found = roles.FirstOrDefault(r => r.Id == existingRoleId); if (found is null) return Result.Failure<(SupportTicket, int?)>(SupportErrors.NotFound); newRole = found; } else { var createResult = await roleService.CreateRoleAsync( ticket.ProposedRoleName!, ticket.ProposedMaxConfigs!.Value, ticket.ProposedMaxIpLimit!.Value, billingEnabled: false, cancellationToken ); if (!createResult.IsSuccess) return Result.Failure<(SupportTicket, int?)>(createResult.Error); newRole = createResult.Value; } var assignResult = await roleService.ChangeUserRoleAsync( ticket.UserId, newRole.Id, cancellationToken ); if (!assignResult.IsSuccess) return Result.Failure<(SupportTicket, int?)>(assignResult.Error); ticket.Resolve(); dbContext.AuditLogs.Add( AuditLog.Create( adminId, "RoleRequestApproved", "SupportTicket", ticket.Id.ToString(), metadata: null, AuditSource.Web ) ); int? topUpAmount = null; if (newRole.BillingEnabled && oldProfile?.BillingPaidUntil is { } paidUntil) { topUpAmount = await CreateTopUpIfNeededAsync( ticket.UserId, oldProfile.MaxConfigs, newRole.MaxConfigs, paidUntil, cancellationToken ); } return Result.Success((ticket, topUpAmount)); } /// Роль подорожала, а оплаченный период ещё активен — по-хорошему пользователь должен /// доплатить разницу, а не доиграть апгрейд бесплатно до конца уже оплаченного срока. Роль меняется /// сразу (см. выше); доплата решается отдельно через обычный флоу PaymentRequest — см. /// domain-model.md#rolechangetopup. Только БД — уведомление шлёт вызывающий код после снятия лока. private async Task CreateTopUpIfNeededAsync( Guid userId, int oldMaxConfigs, int newMaxConfigs, DateTimeOffset paidUntil, CancellationToken cancellationToken ) { var pricing = await dbContext.PricingSettings.AsNoTracking().FirstOrDefaultAsync(cancellationToken); if (pricing?.PricePerConfigPerQuarter is not { } rate) return null; var tiers = await dbContext .PricingDiscountTiers.AsNoTracking() .Where(t => t.PricingSettingsId == pricing.Id) .ToListAsync(cancellationToken); var amount = RoleChangeTopUp.Compute( rate, oldMaxConfigs, newMaxConfigs, tiers, paidUntil, DateTimeOffset.UtcNow ); if (amount is not { } topUpAmount) return null; dbContext.PaymentRequests.Add(PaymentRequest.CreateRoleChangeTopUp(userId, topUpAmount)); return topUpAmount; } }