Implement pricing management functionality and update related components
CI / Backend (build + test) (push) Successful in 1m19s
CI / Frontend (lint + typecheck + build) (push) Successful in 32s

- Added new endpoints for managing global pricing settings, including retrieval and updates for `PricePerConfigPerQuarter` and `PricePerConfigPerYear`.
- Updated `RoleService` and related commands to remove pricing fields from role management, ensuring a clear separation between role configurations and global pricing.
- Enhanced the `FactoryResetCommandHandler` to include seeding of pricing settings during a factory reset.
- Modified frontend components to support new pricing settings, including forms for creating and updating pricing information.
- Updated API documentation to reflect changes in pricing management endpoints and their expected request/response formats.
- Adjusted tests to ensure proper coverage for new pricing functionalities and their integration with existing role management features.
This commit is contained in:
Leonid Pershin
2026-07-18 19:34:29 +03:00
parent 285d8180c8
commit ae379f8e0f
54 changed files with 2516 additions and 268 deletions
@@ -16,6 +16,7 @@ public sealed class FactoryResetCommandHandler(
IFileStorage fileStorage,
IClientAppCatalogSeeder catalogSeeder,
IInstructionIntroSeeder instructionIntroSeeder,
IPricingSettingsSeeder pricingSettingsSeeder,
ICurrentUser currentUser
) : ICommandHandler<FactoryResetCommand, Result>
{
@@ -45,9 +46,11 @@ public sealed class FactoryResetCommandHandler(
foreach (var role in roles.Where(r => !r.IsSystem))
await roleService.DeleteRoleAsync(role.Id, cancellationToken);
// ClientApps/InstructionIntros уже пусты (удалены в WipeApplicationData + сохранено выше) — пересеиваем.
// ClientApps/InstructionIntros/PricingSettings уже пусты (удалены в WipeApplicationData +
// сохранено выше) — пересеиваем.
await catalogSeeder.SeedIfEmptyAsync(cancellationToken);
await instructionIntroSeeder.SeedIfEmptyAsync(cancellationToken);
await pricingSettingsSeeder.SeedIfEmptyAsync(cancellationToken);
// Финальная запись — уже после очистки самого журнала, чтобы отметить факт сброса.
dbContext.AuditLogs.Add(
@@ -120,6 +123,7 @@ public sealed class FactoryResetCommandHandler(
dbContext.ClientApps.RemoveRange(dbContext.ClientApps);
dbContext.InstructionIntros.RemoveRange(dbContext.InstructionIntros);
dbContext.InstructionTabs.RemoveRange(dbContext.InstructionTabs);
dbContext.PricingSettings.RemoveRange(dbContext.PricingSettings);
dbContext.AuditLogs.RemoveRange(dbContext.AuditLogs);
}
}
@@ -0,0 +1,6 @@
using PnvPanel.Application.Common.Messaging;
using PnvPanel.Application.Common.Models;
namespace PnvPanel.Application.Admin.Pricing;
public sealed record GetPricingSettingsQuery : IQuery<Result<PricingSettingsDto>>;
@@ -0,0 +1,27 @@
using Microsoft.EntityFrameworkCore;
using PnvPanel.Application.Common.Interfaces;
using PnvPanel.Application.Common.Messaging;
using PnvPanel.Application.Common.Models;
namespace PnvPanel.Application.Admin.Pricing;
public sealed class GetPricingSettingsQueryHandler(IAppDbContext dbContext)
: IQueryHandler<GetPricingSettingsQuery, Result<PricingSettingsDto>>
{
public async Task<Result<PricingSettingsDto>> Handle(
GetPricingSettingsQuery query,
CancellationToken cancellationToken
)
{
var settings = await dbContext
.PricingSettings.AsNoTracking()
.FirstOrDefaultAsync(cancellationToken);
// Ещё не сидировано/не сохранено ни разу — цена не задана, а не ошибка.
return Result.Success(
settings is null
? new PricingSettingsDto(null, null)
: PricingSettingsDto.FromDomain(settings)
);
}
}
@@ -0,0 +1,9 @@
using PnvPanel.Domain.Pricing;
namespace PnvPanel.Application.Admin.Pricing;
public sealed record PricingSettingsDto(int? PricePerConfigPerQuarter, int? PricePerConfigPerYear)
{
public static PricingSettingsDto FromDomain(PricingSettings settings) =>
new(settings.PricePerConfigPerQuarter, settings.PricePerConfigPerYear);
}
@@ -0,0 +1,9 @@
using PnvPanel.Application.Common.Messaging;
using PnvPanel.Application.Common.Models;
namespace PnvPanel.Application.Admin.Pricing;
public sealed record UpdatePricingSettingsCommand(
int? PricePerConfigPerQuarter,
int? PricePerConfigPerYear
) : ICommand<Result<PricingSettingsDto>>;
@@ -0,0 +1,28 @@
using Microsoft.EntityFrameworkCore;
using PnvPanel.Application.Common.Interfaces;
using PnvPanel.Application.Common.Messaging;
using PnvPanel.Application.Common.Models;
using PnvPanel.Domain.Pricing;
namespace PnvPanel.Application.Admin.Pricing;
public sealed class UpdatePricingSettingsCommandHandler(IAppDbContext dbContext)
: ICommandHandler<UpdatePricingSettingsCommand, Result<PricingSettingsDto>>
{
public async Task<Result<PricingSettingsDto>> Handle(
UpdatePricingSettingsCommand command,
CancellationToken cancellationToken
)
{
var settings = await dbContext.PricingSettings.FirstOrDefaultAsync(cancellationToken);
if (settings is null)
{
settings = PricingSettings.CreateDefault();
dbContext.PricingSettings.Add(settings);
}
settings.Update(command.PricePerConfigPerQuarter, command.PricePerConfigPerYear);
return Result.Success(PricingSettingsDto.FromDomain(settings));
}
}
@@ -0,0 +1,17 @@
using FluentValidation;
namespace PnvPanel.Application.Admin.Pricing;
public sealed class UpdatePricingSettingsCommandValidator
: AbstractValidator<UpdatePricingSettingsCommand>
{
public UpdatePricingSettingsCommandValidator()
{
RuleFor(x => x.PricePerConfigPerQuarter!.Value)
.GreaterThanOrEqualTo(0)
.When(x => x.PricePerConfigPerQuarter.HasValue);
RuleFor(x => x.PricePerConfigPerYear!.Value)
.GreaterThanOrEqualTo(0)
.When(x => x.PricePerConfigPerYear.HasValue);
}
}
@@ -4,10 +4,5 @@ using PnvPanel.Application.Common.Models;
namespace PnvPanel.Application.Admin.Roles;
public sealed record CreateRoleCommand(
string Name,
int MaxConfigs,
int MaxIpLimit,
int? PricePerConfigPerQuarter,
int? PricePerConfigPerYear
) : ICommand<Result<RoleDto>>;
public sealed record CreateRoleCommand(string Name, int MaxConfigs, int MaxIpLimit)
: ICommand<Result<RoleDto>>;
@@ -15,8 +15,6 @@ public sealed class CreateRoleCommandHandler(IRoleService roleService)
command.Name,
command.MaxConfigs,
command.MaxIpLimit,
command.PricePerConfigPerQuarter,
command.PricePerConfigPerYear,
cancellationToken
);
}
@@ -10,12 +10,5 @@ public sealed class CreateRoleCommandValidator : AbstractValidator<CreateRoleCom
RuleFor(x => x.MaxConfigs).GreaterThanOrEqualTo(-1);
RuleFor(x => x.MaxIpLimit).GreaterThanOrEqualTo(-1);
RuleFor(x => x.PricePerConfigPerQuarter!.Value)
.GreaterThanOrEqualTo(0)
.When(x => x.PricePerConfigPerQuarter.HasValue);
RuleFor(x => x.PricePerConfigPerYear!.Value)
.GreaterThanOrEqualTo(0)
.When(x => x.PricePerConfigPerYear.HasValue);
}
}
@@ -4,10 +4,5 @@ using PnvPanel.Application.Common.Models;
namespace PnvPanel.Application.Admin.Roles;
public sealed record UpdateRoleCommand(
Guid RoleId,
int MaxConfigs,
int MaxIpLimit,
int? PricePerConfigPerQuarter,
int? PricePerConfigPerYear
) : ICommand<Result<RoleDto>>;
public sealed record UpdateRoleCommand(Guid RoleId, int MaxConfigs, int MaxIpLimit)
: ICommand<Result<RoleDto>>;
@@ -15,8 +15,6 @@ public sealed class UpdateRoleCommandHandler(IRoleService roleService)
command.RoleId,
command.MaxConfigs,
command.MaxIpLimit,
command.PricePerConfigPerQuarter,
command.PricePerConfigPerYear,
cancellationToken
);
}
@@ -8,12 +8,5 @@ public sealed class UpdateRoleCommandValidator : AbstractValidator<UpdateRoleCom
{
RuleFor(x => x.MaxConfigs).GreaterThanOrEqualTo(-1);
RuleFor(x => x.MaxIpLimit).GreaterThanOrEqualTo(-1);
RuleFor(x => x.PricePerConfigPerQuarter!.Value)
.GreaterThanOrEqualTo(0)
.When(x => x.PricePerConfigPerQuarter.HasValue);
RuleFor(x => x.PricePerConfigPerYear!.Value)
.GreaterThanOrEqualTo(0)
.When(x => x.PricePerConfigPerYear.HasValue);
}
}
@@ -49,8 +49,6 @@ public sealed class ApproveRoleRequestCommandHandler(
ticket.ProposedRoleName!,
ticket.ProposedMaxConfigs!.Value,
ticket.ProposedMaxIpLimit!.Value,
pricePerConfigPerQuarter: null,
pricePerConfigPerYear: null,
cancellationToken
);
if (!createResult.IsSuccess)
@@ -8,6 +8,7 @@ using PnvPanel.Domain.Inbounds;
using PnvPanel.Domain.Instructions;
using PnvPanel.Domain.News;
using PnvPanel.Domain.Nodes;
using PnvPanel.Domain.Pricing;
using PnvPanel.Domain.Support;
using PnvPanel.Domain.Telegram;
@@ -45,6 +46,8 @@ public interface IAppDbContext
DbSet<InstructionTab> InstructionTabs { get; }
DbSet<PricingSettings> PricingSettings { get; }
/// <summary>Нужен для advisory-lock при проверке квоты конфигов (см. CreateVpnConfigCommandHandler).</summary>
DatabaseFacade Database { get; }
@@ -0,0 +1,10 @@
namespace PnvPanel.Application.Common.Interfaces;
/// <summary>
/// Сидинг дефолтной (пустой) строки глобальных настроек цены. Идемпотентно — не трогает таблицу, если
/// в ней уже есть строка (используется и при старте, и после полного сброса панели).
/// </summary>
public interface IPricingSettingsSeeder
{
Task SeedIfEmptyAsync(CancellationToken cancellationToken);
}
@@ -2,15 +2,7 @@ using PnvPanel.Application.Common.Models;
namespace PnvPanel.Application.Common.Interfaces;
public sealed record RoleDto(
Guid Id,
string Name,
int MaxConfigs,
int MaxIpLimit,
bool IsSystem,
int? PricePerConfigPerQuarter = null,
int? PricePerConfigPerYear = null
);
public sealed record RoleDto(Guid Id, string Name, int MaxConfigs, int MaxIpLimit, bool IsSystem);
public interface IRoleService
{
@@ -18,8 +10,6 @@ public interface IRoleService
string name,
int maxConfigs,
int maxIpLimit,
int? pricePerConfigPerQuarter,
int? pricePerConfigPerYear,
CancellationToken cancellationToken
);
@@ -27,8 +17,6 @@ public interface IRoleService
Guid roleId,
int maxConfigs,
int maxIpLimit,
int? pricePerConfigPerQuarter,
int? pricePerConfigPerYear,
CancellationToken cancellationToken
);
@@ -1,3 +1,4 @@
using PnvPanel.Application.Common.Interfaces;
using PnvPanel.Application.Common.Messaging;
using PnvPanel.Application.Common.Models;
@@ -6,5 +7,5 @@ namespace PnvPanel.Application.Support.ListSelectableRoles;
/// <summary>Список ролей для выбора в заявке на роль (без admin) — в отличие от ListRolesQuery
/// (Admin/Roles), доступен любому активированному пользователю.</summary>
public sealed record ListSelectableRolesQuery
: IQuery<Result<IReadOnlyList<SelectableRoleDto>>>,
: IQuery<Result<IReadOnlyList<RoleDto>>>,
IRequiresActivation;
@@ -9,31 +9,29 @@ public sealed class ListSelectableRolesQueryHandler(
IRoleService roleService,
IIdentityService identityService,
ICurrentUser currentUser
) : IQueryHandler<ListSelectableRolesQuery, Result<IReadOnlyList<SelectableRoleDto>>>
) : IQueryHandler<ListSelectableRolesQuery, Result<IReadOnlyList<RoleDto>>>
{
// Совпадает со значением Infrastructure.Identity.RoleNames.Admin — см. пояснение в
// CreateRoleRequestTicketCommandHandler (Application не может ссылаться на Infrastructure).
private const string AdminRoleName = "admin";
public async Task<Result<IReadOnlyList<SelectableRoleDto>>> Handle(
public async Task<Result<IReadOnlyList<RoleDto>>> Handle(
ListSelectableRolesQuery query,
CancellationToken cancellationToken
)
{
if (currentUser.UserId is not { } userId)
return Result.Failure<IReadOnlyList<SelectableRoleDto>>(AuthErrors.Unauthorized);
return Result.Failure<IReadOnlyList<RoleDto>>(AuthErrors.Unauthorized);
var profile = await identityService.GetProfileAsync(userId, cancellationToken);
var roles = await roleService.ListRolesAsync(cancellationToken);
// Без admin (нельзя запросить) и без текущей роли пользователя (уже есть — нечего запрашивать).
// Цена (RoleDto.PricePerConfigPer*) намеренно не пробрасывается — видна только админу.
var selectable = roles
.Where(r => !r.Name.Equals(AdminRoleName, StringComparison.OrdinalIgnoreCase))
.Where(r => profile is null || r.Id != profile.RoleId)
.Select(r => new SelectableRoleDto(r.Id, r.Name, r.MaxConfigs, r.MaxIpLimit))
.ToList();
return Result.Success<IReadOnlyList<SelectableRoleDto>>(selectable);
return Result.Success<IReadOnlyList<RoleDto>>(selectable);
}
}
@@ -1,5 +0,0 @@
namespace PnvPanel.Application.Support.ListSelectableRoles;
/// <summary>Роль для выбора в заявке на роль. Без справочной цены (RoleDto.PricePerConfigPer*) —
/// она видна только админу, этот эндпоинт доступен любому активированному пользователю.</summary>
public sealed record SelectableRoleDto(Guid Id, string Name, int MaxConfigs, int MaxIpLimit);