Refactor payment request handling to support role change top-ups
CI / Backend (build + test) (push) Successful in 1m19s
CI / Frontend (lint + typecheck + build) (push) Successful in 33s

- Updated the `PaymentRequest` model to include a new `Kind` property, distinguishing between `Subscription` and `RoleChangeTopUp` requests.
- Modified the `TelegramNotifier` to accommodate the new request type, ensuring accurate notifications for role change top-ups.
- Enhanced the `ConfirmPaymentRequestCommandHandler` to handle role change top-ups without extending the billing period, reflecting the new payment logic.
- Updated various application components and tests to support the new payment request structure and ensure proper functionality.
- Revised API documentation to clarify the behavior of role change top-ups and their impact on billing.
This commit is contained in:
Leonid Pershin
2026-07-19 16:45:17 +03:00
parent 0dcaf1203f
commit 5ff5224935
27 changed files with 1656 additions and 76 deletions
@@ -5,6 +5,7 @@ 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;
@@ -12,6 +13,7 @@ namespace PnvPanel.Application.Admin.Support;
public sealed class ApproveRoleRequestCommandHandler(
IAppDbContext dbContext,
IRoleService roleService,
IIdentityService identityService,
IRealtimeNotifier notifier,
ITelegramNotifier telegramNotifier,
ICurrentUser currentUser
@@ -38,10 +40,19 @@ public sealed class ApproveRoleRequestCommandHandler(
if (ticket.Status != TicketStatus.Open)
return Result.Failure(SupportErrors.NotOpen);
Guid roleId;
// Снимаем профиль ДО смены роли — нужен старый MaxConfigs/BillingPaidUntil для проратированной
// доплаты за апгрейд (см. ниже), после ChangeUserRoleAsync эти данные уже недоступны.
var oldProfile = await identityService.GetProfileAsync(ticket.UserId, cancellationToken);
RoleDto newRole;
if (ticket.RequestedRoleId is { } existingRoleId)
{
roleId = existingRoleId;
var roles = await roleService.ListRolesAsync(cancellationToken);
var found = roles.FirstOrDefault(r => r.Id == existingRoleId);
if (found is null)
return Result.Failure(SupportErrors.NotFound);
newRole = found;
}
else
{
@@ -55,12 +66,12 @@ public sealed class ApproveRoleRequestCommandHandler(
if (!createResult.IsSuccess)
return Result.Failure(createResult.Error);
roleId = createResult.Value.Id;
newRole = createResult.Value;
}
var assignResult = await roleService.ChangeUserRoleAsync(
ticket.UserId,
roleId,
newRole.Id,
cancellationToken
);
if (!assignResult.IsSuccess)
@@ -87,6 +98,51 @@ public sealed class ApproveRoleRequestCommandHandler(
cancellationToken
);
if (newRole.BillingEnabled && oldProfile?.BillingPaidUntil is { } paidUntil)
await CreateTopUpIfNeededAsync(ticket.UserId, oldProfile.MaxConfigs, newRole.MaxConfigs, paidUntil, cancellationToken);
return Result.Success();
}
/// <summary>Роль подорожала, а оплаченный период ещё активен — по-хорошему пользователь должен
/// доплатить разницу, а не доиграть апгрейд бесплатно до конца уже оплаченного срока. Роль меняется
/// сразу (см. выше); доплата решается отдельно через обычный флоу PaymentRequest — см.
/// domain-model.md#rolechangetopup.</summary>
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;
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;
dbContext.PaymentRequests.Add(PaymentRequest.CreateRoleChangeTopUp(userId, topUpAmount));
await telegramNotifier.NotifyUserAsync(
userId,
$"💳 Новая роль дороже прежней — требуется доплата {topUpAmount} ₽ за оставшуюся часть оплаченного периода.",
"/billing",
cancellationToken
);
}
}