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
@@ -270,7 +270,8 @@ internal sealed class TelegramNotifier(
public async Task NotifyAdminsPaymentRequestedAsync(
Guid requestId,
string userName,
PaymentPeriod period,
PaymentRequestKind kind,
PaymentPeriod? period,
int amount,
CancellationToken cancellationToken
)
@@ -278,8 +279,8 @@ internal sealed class TelegramNotifier(
if (string.IsNullOrWhiteSpace(options.Value.BotToken))
return;
var text =
$"💰 <b>{Escape(userName)}</b> заявляет об оплате за {PeriodLabel(period)} — {amount} ₽\nПроверьте поступление и подтвердите.";
var reason = kind == PaymentRequestKind.RoleChangeTopUp ? "доплате за смену роли" : $"оплате за {PeriodLabel(period!.Value)}";
var text = $"💰 <b>{Escape(userName)}</b> заявляет о {reason} — {amount} ₽\nПроверьте поступление и подтвердите.";
var keyboard = new InlineKeyboardMarkup(
new[]
@@ -6,7 +6,8 @@ public sealed record AdminPaymentRequestDto(
Guid Id,
Guid UserId,
string UserName,
PaymentPeriod Period,
PaymentRequestKind Kind,
PaymentPeriod? Period,
int AmountSnapshot,
PaymentRequestStatus Status,
DateTimeOffset CreatedAt
@@ -50,9 +50,37 @@ public sealed class ConfirmPaymentRequestCommandHandler(
if (profile is null)
return Result.Failure(AuthErrors.Unauthorized);
// RoleChangeTopUp — доплата разницы в цене при апгрейде роли, не покупка времени: подтверждение
// не трогает BillingPaidUntil и не возвращает Expired-конфиги (это делает обычная Subscription-
// оплата/продление). Period не задан для этого Kind — ToMonths() здесь неприменим.
if (request.Kind == PaymentRequestKind.RoleChangeTopUp)
{
request.Confirm(adminId);
dbContext.AuditLogs.Add(
AuditLog.Create(
adminId,
"PaymentConfirmed",
"PaymentRequest",
request.Id.ToString(),
metadata: null,
AuditSource.Web
)
);
await telegramNotifier.NotifyUserAsync(
request.UserId,
"✅ Доплата за смену роли подтверждена.",
null,
cancellationToken
);
return Result.Success();
}
var now = DateTimeOffset.UtcNow;
var baseline = profile.BillingPaidUntil is { } paidUntil && paidUntil > now ? paidUntil : now;
var newPaidUntil = baseline.AddMonths(request.Period.ToMonths());
var newPaidUntil = baseline.AddMonths(request.Period!.Value.ToMonths());
var extendResult = await identityService.ExtendBillingPaidUntilAsync(
request.UserId,
@@ -36,6 +36,7 @@ public sealed class ListPaymentRequestsQueryHandler(
r.Id,
r.UserId,
userNames.GetValueOrDefault(r.UserId, "?"),
r.Kind,
r.Period,
r.AmountSnapshot,
r.Status,
@@ -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
);
}
}
@@ -32,9 +32,12 @@ public sealed class CreatePaymentRequestCommandHandler(
if (profile.MaxConfigs == RoleQuota.Unlimited)
return Result.Failure<PaymentRequestDto>(BillingErrors.UnlimitedRoleNotSupported);
// RoleChangeTopUp не считается активной подписной заявкой — доплата за смену роли не должна
// мешать пользователю продлить/оформить обычную подписку.
var hasActiveRequest = await dbContext.PaymentRequests.AnyAsync(
r =>
r.UserId == userId
&& r.Kind == PaymentRequestKind.Subscription
&& (
r.Status == PaymentRequestStatus.AwaitingPayment
|| r.Status == PaymentRequestStatus.AwaitingConfirmation
@@ -35,6 +35,7 @@ public sealed class MarkPaymentSentCommandHandler(
await telegramNotifier.NotifyAdminsPaymentRequestedAsync(
request.Id,
profile?.UserName ?? userId.ToString(),
request.Kind,
request.Period,
request.AmountSnapshot,
cancellationToken
@@ -4,12 +4,13 @@ namespace PnvPanel.Application.Billing;
public sealed record PaymentRequestDto(
Guid Id,
PaymentPeriod Period,
PaymentRequestKind Kind,
PaymentPeriod? Period,
int AmountSnapshot,
PaymentRequestStatus Status,
DateTimeOffset CreatedAt
)
{
public static PaymentRequestDto FromDomain(PaymentRequest request) =>
new(request.Id, request.Period, request.AmountSnapshot, request.Status, request.CreatedAt);
new(request.Id, request.Kind, request.Period, request.AmountSnapshot, request.Status, request.CreatedAt);
}
@@ -38,13 +38,16 @@ public sealed class SendRequisitesToTelegramCommandHandler(
.FirstOrDefaultAsync(cancellationToken);
var text =
$"💳 Реквизиты для оплаты ({PeriodLabel(request.Period)}, {request.AmountSnapshot} ₽):\n{settings?.RequisitesText}";
$"💳 Реквизиты для оплаты ({DescribeRequest(request)}, {request.AmountSnapshot} ₽):\n{settings?.RequisitesText}";
await telegramNotifier.NotifyUserAsync(userId, text, null, cancellationToken);
return Result.Success();
}
private static string DescribeRequest(PaymentRequest request) =>
request.Kind == PaymentRequestKind.RoleChangeTopUp ? "доплата за смену роли" : PeriodLabel(request.Period!.Value);
private static string PeriodLabel(PaymentPeriod period) =>
period switch
{
@@ -54,11 +54,13 @@ public interface ITelegramNotifier
);
/// <summary>Пользователь нажал «Я оплатил» — инлайн-кнопки «Подтвердить/Отклонить», решается
/// полностью в Telegram (аналогично заявке на роль).</summary>
/// полностью в Telegram (аналогично заявке на роль). Period задан только для Kind.Subscription —
/// для Kind.RoleChangeTopUp он null (доплата не привязана к тарифному периоду).</summary>
Task NotifyAdminsPaymentRequestedAsync(
Guid requestId,
string userName,
PaymentPeriod period,
PaymentRequestKind kind,
PaymentPeriod? period,
int amount,
CancellationToken cancellationToken
);
@@ -4,15 +4,21 @@ using PnvPanel.Domain.Exceptions;
namespace PnvPanel.Domain.Billing;
/// <summary>
/// Заявка пользователя на оплату подписки за период (квартал/полгода/год). Сумма замораживается на
/// момент создания (по действовавшей на тот момент ставке PricingSettings) — последующее изменение
/// прайса админом не меняет уже созданные заявки. Не более одной активной (AwaitingPayment/
/// AwaitingConfirmation) заявки на пользователя — инвариант проверяется на уровне Application.
/// Заявка пользователя на оплату. Kind.Subscription — оплата подписки за период (квартал/полгода/год,
/// Period задан), продлевает BillingPaidUntil при подтверждении. Kind.RoleChangeTopUp — доплата разницы
/// в цене при апгрейде роли с активным оплаченным периодом (Period не задан), подтверждение НЕ меняет
/// BillingPaidUntil — см. RoleChangeTopUp.Compute и ConfirmPaymentRequestCommandHandler. Сумма
/// замораживается на момент создания (по действовавшей на тот момент ставке PricingSettings) —
/// последующее изменение прайса админом не меняет уже созданные заявки. Не более одной активной
/// (AwaitingPayment/AwaitingConfirmation) заявки Kind.Subscription на пользователя — инвариант
/// проверяется на уровне Application; RoleChangeTopUp создаётся системой при одобрении заявки на роль
/// и этим инвариантом не ограничен.
/// </summary>
public sealed class PaymentRequest : Entity
{
public Guid UserId { get; private set; }
public PaymentPeriod Period { get; private set; }
public PaymentRequestKind Kind { get; private set; }
public PaymentPeriod? Period { get; private set; }
public int AmountSnapshot { get; private set; }
public PaymentRequestStatus Status { get; private set; }
public Guid? DecidedBy { get; private set; }
@@ -28,6 +34,7 @@ public sealed class PaymentRequest : Entity
{
Id = Guid.NewGuid(),
UserId = userId,
Kind = PaymentRequestKind.Subscription,
Period = period,
AmountSnapshot = amountSnapshot,
Status = PaymentRequestStatus.AwaitingPayment,
@@ -35,6 +42,20 @@ public sealed class PaymentRequest : Entity
};
}
public static PaymentRequest CreateRoleChangeTopUp(Guid userId, int amountSnapshot)
{
return new PaymentRequest
{
Id = Guid.NewGuid(),
UserId = userId,
Kind = PaymentRequestKind.RoleChangeTopUp,
Period = null,
AmountSnapshot = amountSnapshot,
Status = PaymentRequestStatus.AwaitingPayment,
CreatedAt = DateTimeOffset.UtcNow,
};
}
/// <summary>Пользователь нажал «Я оплатил» — уходит на подтверждение админом.</summary>
public void MarkPaymentSent()
{
@@ -0,0 +1,10 @@
namespace PnvPanel.Domain.Billing;
/// <summary>Subscription — обычная оплата за период (Period задан). RoleChangeTopUp — доплата разницы
/// в цене при апгрейде роли с активным оплаченным периодом (Period не задан, не продлевает
/// BillingPaidUntil) — см. RoleChangeTopUp.Compute.</summary>
public enum PaymentRequestKind
{
Subscription,
RoleChangeTopUp,
}
@@ -0,0 +1,53 @@
using PnvPanel.Domain.Pricing;
namespace PnvPanel.Domain.Billing;
/// <summary>
/// Проратированная доплата при апгрейде роли с активным оплаченным периодом: пользователь платит
/// разницу в месячной стоимости между старой и новой ролью за оставшиеся дни BillingPaidUntil, а не
/// выкупает период заново — так более выгодная роль не достаётся бесплатно до конца уже оплаченного
/// периода. Базовая ставка — PricePerConfigPerQuarter (минимальный/справочный тариф), с той же
/// скидочной лесенкой (PricingDiscountTier), что и обычная оплата — см. domain-model.md#rolechangetopup.
/// </summary>
public static class RoleChangeTopUp
{
private const int DaysPerMonth = 30;
/// <summary>Null, если доплата не нужна: роль не подорожала, оплаченный период уже истёк, или
/// у старой/новой роли нет квоты (unlimited — цена для неё не считается).</summary>
public static int? Compute(
int pricePerConfigPerMonth,
int oldMaxConfigs,
int newMaxConfigs,
IReadOnlyCollection<PricingDiscountTier> discountTiers,
DateTimeOffset paidUntil,
DateTimeOffset now
)
{
if (oldMaxConfigs < 0 || newMaxConfigs < 0)
return null;
var remainingDays = (paidUntil - now).TotalDays;
if (remainingDays <= 0)
return null;
var oldMonthly = MonthlyTotal(pricePerConfigPerMonth, oldMaxConfigs, discountTiers);
var newMonthly = MonthlyTotal(pricePerConfigPerMonth, newMaxConfigs, discountTiers);
if (newMonthly <= oldMonthly)
return null;
var amount = (newMonthly - oldMonthly) / (decimal)DaysPerMonth * (decimal)remainingDays;
return (int)Math.Round(amount, MidpointRounding.AwayFromZero);
}
private static int MonthlyTotal(
int pricePerConfigPerMonth,
int maxConfigs,
IReadOnlyCollection<PricingDiscountTier> tiers
)
{
var raw = pricePerConfigPerMonth * maxConfigs;
var discountPercent = PricingDiscount.ResolvePercent(tiers, maxConfigs);
return PricingDiscount.Apply(raw, discountPercent);
}
}
@@ -11,6 +11,7 @@ public class PaymentRequestConfiguration : IEntityTypeConfiguration<PaymentReque
builder.ToTable("PaymentRequests");
builder.HasKey(x => x.Id);
builder.Property(x => x.Kind).HasConversion<string>().HasMaxLength(32);
builder.Property(x => x.Period).HasConversion<string>().HasMaxLength(32);
builder.Property(x => x.Status).HasConversion<string>().HasMaxLength(32);
builder.Property(x => x.RejectionReason).HasMaxLength(500);
@@ -0,0 +1,52 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace PnvPanel.Infrastructure.Persistence.Migrations
{
/// <inheritdoc />
public partial class AddPaymentRequestKind : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AlterColumn<string>(
name: "Period",
table: "PaymentRequests",
type: "character varying(32)",
maxLength: 32,
nullable: true,
oldClrType: typeof(string),
oldType: "character varying(32)",
oldMaxLength: 32);
migrationBuilder.AddColumn<string>(
name: "Kind",
table: "PaymentRequests",
type: "character varying(32)",
maxLength: 32,
nullable: false,
defaultValue: "Subscription");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "Kind",
table: "PaymentRequests");
migrationBuilder.AlterColumn<string>(
name: "Period",
table: "PaymentRequests",
type: "character varying(32)",
maxLength: 32,
nullable: false,
defaultValue: "",
oldClrType: typeof(string),
oldType: "character varying(32)",
oldMaxLength: 32,
oldNullable: true);
}
}
}
@@ -293,11 +293,15 @@ namespace PnvPanel.Infrastructure.Persistence.Migrations
b.Property<Guid?>("DecidedBy")
.HasColumnType("uuid");
b.Property<string>("Period")
b.Property<string>("Kind")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)");
b.Property<string>("Period")
.HasMaxLength(32)
.HasColumnType("character varying(32)");
b.Property<string>("RejectionReason")
.HasMaxLength(500)
.HasColumnType("character varying(500)");
@@ -205,6 +205,48 @@ public class ConfirmPaymentRequestCommandHandlerTests
);
}
[Fact]
public async Task Handle_WhenRoleChangeTopUp_ConfirmsWithoutExtendingPaidUntil()
{
using var dbContext = InMemoryDbContextFactory.Create();
var adminId = Guid.NewGuid();
var userId = Guid.NewGuid();
var existingPaidUntil = DateTimeOffset.UtcNow.AddDays(20);
var request = PaymentRequest.CreateRoleChangeTopUp(userId, 1200);
request.MarkPaymentSent();
dbContext.PaymentRequests.Add(request);
await dbContext.SaveChangesAsync(CancellationToken.None);
_identityService
.GetProfileAsync(userId, Arg.Any<CancellationToken>())
.Returns(Profile(userId, paidUntil: existingPaidUntil));
var handler = new ConfirmPaymentRequestCommandHandler(
dbContext,
_identityService,
_gateway,
_notifier,
_telegramNotifier,
FakeCurrentUser.Authenticated(adminId, "admin"),
_logger
);
var result = await handler.Handle(
new ConfirmPaymentRequestCommand(request.Id),
CancellationToken.None
);
Assert.True(result.IsSuccess);
Assert.Equal(PaymentRequestStatus.Confirmed, request.Status);
await _identityService
.DidNotReceive()
.ExtendBillingPaidUntilAsync(
Arg.Any<Guid>(),
Arg.Any<DateTimeOffset>(),
Arg.Any<CancellationToken>()
);
}
[Fact]
public async Task Handle_WhenRequestAlreadyDecided_ReturnsNotDecidable()
{
@@ -2,9 +2,11 @@ using NSubstitute;
using PnvPanel.Application.Admin.Roles;
using PnvPanel.Application.Admin.Support;
using PnvPanel.Application.Common.Interfaces;
using PnvPanel.Application.Common.Messaging;
using PnvPanel.Application.Common.Models;
using PnvPanel.Application.Support;
using PnvPanel.Application.Tests.TestSupport;
using PnvPanel.Domain.Billing;
using PnvPanel.Domain.Support;
using Xunit;
@@ -13,9 +15,33 @@ namespace PnvPanel.Application.Tests.Admin.Support;
public class ApproveRoleRequestCommandHandlerTests
{
private readonly IRoleService _roleService = Substitute.For<IRoleService>();
private readonly IIdentityService _identityService = Substitute.For<IIdentityService>();
private readonly IRealtimeNotifier _notifier = Substitute.For<IRealtimeNotifier>();
private readonly ITelegramNotifier _telegramNotifier = Substitute.For<ITelegramNotifier>();
private ApproveRoleRequestCommandHandler CreateHandler(IAppDbContext dbContext, ICurrentUser currentUser) =>
new(dbContext, _roleService, _identityService, _notifier, _telegramNotifier, currentUser);
private static CurrentUserProfile Profile(
Guid userId,
int maxConfigs,
DateTimeOffset? billingPaidUntil = null
) =>
new(
userId,
"alice",
Guid.NewGuid(),
"old-role",
IsActivated: true,
IsBlocked: false,
MaxConfigs: maxConfigs,
MaxIpLimit: 3,
SubscriptionToken: "sub-token",
BillingEnabled: billingPaidUntil != null,
BillingPaidUntil: billingPaidUntil,
BillingSuspended: false
);
[Fact]
public async Task Handle_ForNewRoleRequest_CreatesRoleAssignsAndResolves()
{
@@ -34,13 +60,7 @@ public class ApproveRoleRequestCommandHandlerTests
.Returns(Result.Success());
var currentUser = FakeCurrentUser.Authenticated(Guid.NewGuid(), "admin");
var handler = new ApproveRoleRequestCommandHandler(
dbContext,
_roleService,
_notifier,
_telegramNotifier,
currentUser
);
var handler = CreateHandler(dbContext, currentUser);
var result = await handler.Handle(
new ApproveRoleRequestCommand(ticket.Id),
@@ -75,18 +95,15 @@ public class ApproveRoleRequestCommandHandlerTests
dbContext.SupportTickets.Add(ticket);
await dbContext.SaveChangesAsync(CancellationToken.None);
_roleService
.ListRolesAsync(Arg.Any<CancellationToken>())
.Returns(new List<RoleDto> { new(roleId, "premium", 10, 5, false, false) });
_roleService
.ChangeUserRoleAsync(userId, roleId, Arg.Any<CancellationToken>())
.Returns(Result.Success());
var currentUser = FakeCurrentUser.Authenticated(Guid.NewGuid());
var handler = new ApproveRoleRequestCommandHandler(
dbContext,
_roleService,
_notifier,
_telegramNotifier,
currentUser
);
var handler = CreateHandler(dbContext, currentUser);
var result = await handler.Handle(
new ApproveRoleRequestCommand(ticket.Id),
@@ -114,13 +131,7 @@ public class ApproveRoleRequestCommandHandlerTests
await dbContext.SaveChangesAsync(CancellationToken.None);
var currentUser = FakeCurrentUser.Authenticated(Guid.NewGuid());
var handler = new ApproveRoleRequestCommandHandler(
dbContext,
_roleService,
_notifier,
_telegramNotifier,
currentUser
);
var handler = CreateHandler(dbContext, currentUser);
var result = await handler.Handle(
new ApproveRoleRequestCommand(ticket.Id),
@@ -143,18 +154,15 @@ public class ApproveRoleRequestCommandHandlerTests
dbContext.SupportTickets.Add(ticket);
await dbContext.SaveChangesAsync(CancellationToken.None);
_roleService
.ListRolesAsync(Arg.Any<CancellationToken>())
.Returns(new List<RoleDto> { new(roleId, "premium", 10, 5, false, false) });
_roleService
.ChangeUserRoleAsync(adminId, roleId, Arg.Any<CancellationToken>())
.Returns(Result.Success());
var currentUser = FakeCurrentUser.Authenticated(adminId, "admin");
var handler = new ApproveRoleRequestCommandHandler(
dbContext,
_roleService,
_notifier,
_telegramNotifier,
currentUser
);
var handler = CreateHandler(dbContext, currentUser);
var result = await handler.Handle(
new ApproveRoleRequestCommand(ticket.Id),
@@ -175,18 +183,15 @@ public class ApproveRoleRequestCommandHandlerTests
dbContext.SupportTickets.Add(ticket);
await dbContext.SaveChangesAsync(CancellationToken.None);
_roleService
.ListRolesAsync(Arg.Any<CancellationToken>())
.Returns(new List<RoleDto> { new(roleId, "premium", 10, 5, false, false) });
_roleService
.ChangeUserRoleAsync(adminId, roleId, Arg.Any<CancellationToken>())
.Returns(Result.Failure(RoleErrors.CannotRemoveLastAdmin));
var currentUser = FakeCurrentUser.Authenticated(adminId, "admin");
var handler = new ApproveRoleRequestCommandHandler(
dbContext,
_roleService,
_notifier,
_telegramNotifier,
currentUser
);
var handler = CreateHandler(dbContext, currentUser);
var result = await handler.Handle(
new ApproveRoleRequestCommand(ticket.Id),
@@ -198,4 +203,63 @@ public class ApproveRoleRequestCommandHandlerTests
// Тикет остаётся Open — можно повторить попытку после назначения второго админа.
Assert.Equal(TicketStatus.Open, ticket.Status);
}
[Fact]
public async Task Handle_WhenNewRoleMoreExpensiveWithActivePaidPeriod_CreatesRoleChangeTopUp()
{
using var dbContext = InMemoryDbContextFactory.Create();
var userId = Guid.NewGuid();
var roleId = Guid.NewGuid();
var ticket = SupportTicket.CreateRoleRequestForExistingRole(userId, roleId);
dbContext.SupportTickets.Add(ticket);
var pricing = PnvPanel.Domain.Pricing.PricingSettings.CreateDefault();
pricing.Update(500, 450, 400);
dbContext.PricingSettings.Add(pricing);
await dbContext.SaveChangesAsync(CancellationToken.None);
_roleService
.ListRolesAsync(Arg.Any<CancellationToken>())
.Returns(new List<RoleDto> { new(roleId, "premium", MaxConfigs: 10, 5, false, BillingEnabled: true) });
_roleService.ChangeUserRoleAsync(userId, roleId, Arg.Any<CancellationToken>()).Returns(Result.Success());
_identityService
.GetProfileAsync(userId, Arg.Any<CancellationToken>())
.Returns(Profile(userId, maxConfigs: 3, billingPaidUntil: DateTimeOffset.UtcNow.AddDays(30)));
var handler = CreateHandler(dbContext, FakeCurrentUser.Authenticated(Guid.NewGuid(), "admin"));
var result = await handler.Handle(new ApproveRoleRequestCommand(ticket.Id), CancellationToken.None);
Assert.True(result.IsSuccess);
var topUp = Assert.Single(dbContext.PaymentRequests.Local);
Assert.Equal(PaymentRequestKind.RoleChangeTopUp, topUp.Kind);
Assert.Null(topUp.Period);
Assert.True(topUp.AmountSnapshot > 0);
}
[Fact]
public async Task Handle_WhenNoActivePaidPeriod_DoesNotCreateTopUp()
{
using var dbContext = InMemoryDbContextFactory.Create();
var userId = Guid.NewGuid();
var roleId = Guid.NewGuid();
var ticket = SupportTicket.CreateRoleRequestForExistingRole(userId, roleId);
dbContext.SupportTickets.Add(ticket);
await dbContext.SaveChangesAsync(CancellationToken.None);
_roleService
.ListRolesAsync(Arg.Any<CancellationToken>())
.Returns(new List<RoleDto> { new(roleId, "premium", MaxConfigs: 10, 5, false, BillingEnabled: true) });
_roleService.ChangeUserRoleAsync(userId, roleId, Arg.Any<CancellationToken>()).Returns(Result.Success());
_identityService
.GetProfileAsync(userId, Arg.Any<CancellationToken>())
.Returns(Profile(userId, maxConfigs: 3, billingPaidUntil: null));
var handler = CreateHandler(dbContext, FakeCurrentUser.Authenticated(Guid.NewGuid(), "admin"));
var result = await handler.Handle(new ApproveRoleRequestCommand(ticket.Id), CancellationToken.None);
Assert.True(result.IsSuccess);
Assert.Empty(dbContext.PaymentRequests.Local);
}
}
@@ -41,6 +41,7 @@ public class MarkPaymentSentCommandHandlerTests
.NotifyAdminsPaymentRequestedAsync(
request.Id,
Arg.Any<string>(),
PaymentRequestKind.Subscription,
PaymentPeriod.Year,
6000,
Arg.Any<CancellationToken>()
@@ -0,0 +1,107 @@
using PnvPanel.Domain.Billing;
using PnvPanel.Domain.Pricing;
using Xunit;
namespace PnvPanel.Domain.Tests.Billing;
public class RoleChangeTopUpTests
{
private static readonly DateTimeOffset Now = new(2026, 1, 1, 0, 0, 0, TimeSpan.Zero);
[Fact]
public void Compute_WhenNewRoleCheaper_ReturnsNull()
{
var amount = RoleChangeTopUp.Compute(
pricePerConfigPerMonth: 200,
oldMaxConfigs: 10,
newMaxConfigs: 3,
discountTiers: [],
paidUntil: Now.AddDays(30),
now: Now
);
Assert.Null(amount);
}
[Fact]
public void Compute_WhenPaidUntilAlreadyExpired_ReturnsNull()
{
var amount = RoleChangeTopUp.Compute(
pricePerConfigPerMonth: 200,
oldMaxConfigs: 3,
newMaxConfigs: 10,
discountTiers: [],
paidUntil: Now.AddDays(-1),
now: Now
);
Assert.Null(amount);
}
[Fact]
public void Compute_WhenEitherRoleUnlimited_ReturnsNull()
{
var amount = RoleChangeTopUp.Compute(
pricePerConfigPerMonth: 200,
oldMaxConfigs: 3,
newMaxConfigs: -1,
discountTiers: [],
paidUntil: Now.AddDays(30),
now: Now
);
Assert.Null(amount);
}
[Fact]
public void Compute_WhenUpgrading_ReturnsProratedDifference()
{
// Старая роль: 200 * 3 = 600 ₽/мес. Новая: 200 * 10 = 2000 ₽/мес. Разница 1400 ₽/мес,
// за 30 дней (ровно месяц) — 1400 ₽.
var amount = RoleChangeTopUp.Compute(
pricePerConfigPerMonth: 200,
oldMaxConfigs: 3,
newMaxConfigs: 10,
discountTiers: [],
paidUntil: Now.AddDays(30),
now: Now
);
Assert.Equal(1400, amount);
}
[Fact]
public void Compute_ProratesToRemainingDaysOnly()
{
// Та же разница 1400 ₽/мес, но остаётся только 15 из 30 дней — половина.
var amount = RoleChangeTopUp.Compute(
pricePerConfigPerMonth: 200,
oldMaxConfigs: 3,
newMaxConfigs: 10,
discountTiers: [],
paidUntil: Now.AddDays(15),
now: Now
);
Assert.Equal(700, amount);
}
[Fact]
public void Compute_AppliesDiscountTiersToBothRoles()
{
var tiers = new[] { PricingDiscountTier.Create(Guid.NewGuid(), minConfigs: 10, discountPercent: 10) };
// Старая роль (3 конфига, без скидки): 200*3 = 600. Новая (10 конфигов, порог скидки достигнут):
// 200*10 = 2000, -10% = 1800. Разница 1200 ₽/мес, за 30 дней — 1200 ₽.
var amount = RoleChangeTopUp.Compute(
pricePerConfigPerMonth: 200,
oldMaxConfigs: 3,
newMaxConfigs: 10,
discountTiers: tiers,
paidUntil: Now.AddDays(30),
now: Now
);
Assert.Equal(1200, amount);
}
}