Implement discount tiers for pricing settings and enhance related functionalities
- Introduced a new `DiscountTierDto` to represent volume discount tiers, allowing roles with a config quota at or above specified thresholds to receive discounts on pricing. - Updated the `PricingSettingsDto` to include a list of discount tiers, enhancing the pricing model to support more flexible pricing strategies. - Modified the `GetPricingSettingsQueryHandler` and `UpdatePricingSettingsCommandHandler` to handle discount tiers, ensuring they are correctly retrieved and updated in the database. - Enhanced validation in `UpdatePricingSettingsCommandValidator` to enforce uniqueness and progressive discount tiers, preventing invalid configurations. - Updated frontend components to support the new discount tier functionality, including forms for adding and managing discount tiers in the admin interface. - Revised API documentation to reflect the new discount tier features and their usage in pricing settings.
This commit is contained in:
@@ -18,10 +18,14 @@ public sealed class GetPricingSettingsQueryHandler(IAppDbContext dbContext)
|
|||||||
.FirstOrDefaultAsync(cancellationToken);
|
.FirstOrDefaultAsync(cancellationToken);
|
||||||
|
|
||||||
// Ещё не сидировано/не сохранено ни разу — цена не задана, а не ошибка.
|
// Ещё не сидировано/не сохранено ни разу — цена не задана, а не ошибка.
|
||||||
return Result.Success(
|
if (settings is null)
|
||||||
settings is null
|
return Result.Success(new PricingSettingsDto(null, null, null, []));
|
||||||
? new PricingSettingsDto(null, null, null)
|
|
||||||
: PricingSettingsDto.FromDomain(settings)
|
var discountTiers = await dbContext
|
||||||
);
|
.PricingDiscountTiers.AsNoTracking()
|
||||||
|
.Where(t => t.PricingSettingsId == settings.Id)
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
return Result.Success(PricingSettingsDto.FromDomain(settings, discountTiers));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,5 +7,6 @@ namespace PnvPanel.Application.Admin.Pricing;
|
|||||||
public sealed record UpdatePricingSettingsCommand(
|
public sealed record UpdatePricingSettingsCommand(
|
||||||
int? PricePerConfigPerQuarter,
|
int? PricePerConfigPerQuarter,
|
||||||
int? PricePerConfigPerHalfYear,
|
int? PricePerConfigPerHalfYear,
|
||||||
int? PricePerConfigPerYear
|
int? PricePerConfigPerYear,
|
||||||
|
IReadOnlyList<DiscountTierDto> DiscountTiers
|
||||||
) : ICommand<Result<PricingSettingsDto>>;
|
) : ICommand<Result<PricingSettingsDto>>;
|
||||||
|
|||||||
+11
-1
@@ -27,6 +27,16 @@ public sealed class UpdatePricingSettingsCommandHandler(IAppDbContext dbContext)
|
|||||||
command.PricePerConfigPerYear
|
command.PricePerConfigPerYear
|
||||||
);
|
);
|
||||||
|
|
||||||
return Result.Success(PricingSettingsDto.FromDomain(settings));
|
var existingTiers = await dbContext
|
||||||
|
.PricingDiscountTiers.Where(t => t.PricingSettingsId == settings.Id)
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
dbContext.PricingDiscountTiers.RemoveRange(existingTiers);
|
||||||
|
|
||||||
|
var newTiers = command
|
||||||
|
.DiscountTiers.Select(t => PricingDiscountTier.Create(settings.Id, t.MinConfigs, t.DiscountPercent))
|
||||||
|
.ToList();
|
||||||
|
dbContext.PricingDiscountTiers.AddRange(newTiers);
|
||||||
|
|
||||||
|
return Result.Success(PricingSettingsDto.FromDomain(settings, newTiers));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+25
@@ -55,5 +55,30 @@ public sealed class UpdatePricingSettingsCommandValidator
|
|||||||
&& x.PricePerConfigPerQuarter.HasValue
|
&& x.PricePerConfigPerQuarter.HasValue
|
||||||
&& x.PricePerConfigPerYear.HasValue
|
&& x.PricePerConfigPerYear.HasValue
|
||||||
);
|
);
|
||||||
|
|
||||||
|
RuleForEach(x => x.DiscountTiers)
|
||||||
|
.ChildRules(tier =>
|
||||||
|
{
|
||||||
|
tier.RuleFor(t => t.MinConfigs).GreaterThanOrEqualTo(1);
|
||||||
|
tier.RuleFor(t => t.DiscountPercent).InclusiveBetween(1, 99);
|
||||||
|
});
|
||||||
|
|
||||||
|
RuleFor(x => x.DiscountTiers)
|
||||||
|
.Must(tiers => tiers.Select(t => t.MinConfigs).Distinct().Count() == tiers.Count)
|
||||||
|
.WithMessage("Пороги скидочной лесенки не должны повторяться.");
|
||||||
|
|
||||||
|
// Лесенка должна быть прогрессивной: на более высоком пороге скидка не меньше, чем на более
|
||||||
|
// низком — иначе взять роль с бОльшей квотой может оказаться менее выгодно, что противоречит
|
||||||
|
// смыслу скидки за объём.
|
||||||
|
RuleFor(x => x.DiscountTiers)
|
||||||
|
.Must(tiers =>
|
||||||
|
{
|
||||||
|
var sorted = tiers.OrderBy(t => t.MinConfigs).ToList();
|
||||||
|
for (var i = 1; i < sorted.Count; i++)
|
||||||
|
if (sorted[i].DiscountPercent < sorted[i - 1].DiscountPercent)
|
||||||
|
return false;
|
||||||
|
return true;
|
||||||
|
})
|
||||||
|
.WithMessage("Скидка на более высоком пороге не может быть меньше скидки на более низком.");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+14
-4
@@ -4,6 +4,7 @@ using PnvPanel.Application.Common.Interfaces;
|
|||||||
using PnvPanel.Application.Common.Messaging;
|
using PnvPanel.Application.Common.Messaging;
|
||||||
using PnvPanel.Application.Common.Models;
|
using PnvPanel.Application.Common.Models;
|
||||||
using PnvPanel.Domain.Billing;
|
using PnvPanel.Domain.Billing;
|
||||||
|
using PnvPanel.Domain.Pricing;
|
||||||
|
|
||||||
namespace PnvPanel.Application.Billing.CreatePaymentRequest;
|
namespace PnvPanel.Application.Billing.CreatePaymentRequest;
|
||||||
|
|
||||||
@@ -44,17 +45,26 @@ public sealed class CreatePaymentRequestCommandHandler(
|
|||||||
return Result.Failure<PaymentRequestDto>(BillingErrors.ActiveRequestExists);
|
return Result.Failure<PaymentRequestDto>(BillingErrors.ActiveRequestExists);
|
||||||
|
|
||||||
var pricing = await dbContext.PricingSettings.AsNoTracking().FirstOrDefaultAsync(cancellationToken);
|
var pricing = await dbContext.PricingSettings.AsNoTracking().FirstOrDefaultAsync(cancellationToken);
|
||||||
|
if (pricing is null)
|
||||||
|
return Result.Failure<PaymentRequestDto>(BillingErrors.PricingNotConfigured);
|
||||||
|
|
||||||
var ratePerMonth = command.Period switch
|
var ratePerMonth = command.Period switch
|
||||||
{
|
{
|
||||||
PaymentPeriod.Quarter => pricing?.PricePerConfigPerQuarter,
|
PaymentPeriod.Quarter => pricing.PricePerConfigPerQuarter,
|
||||||
PaymentPeriod.HalfYear => pricing?.PricePerConfigPerHalfYear,
|
PaymentPeriod.HalfYear => pricing.PricePerConfigPerHalfYear,
|
||||||
PaymentPeriod.Year => pricing?.PricePerConfigPerYear,
|
PaymentPeriod.Year => pricing.PricePerConfigPerYear,
|
||||||
_ => null,
|
_ => null,
|
||||||
};
|
};
|
||||||
if (ratePerMonth is not { } rate)
|
if (ratePerMonth is not { } rate)
|
||||||
return Result.Failure<PaymentRequestDto>(BillingErrors.PricingNotConfigured);
|
return Result.Failure<PaymentRequestDto>(BillingErrors.PricingNotConfigured);
|
||||||
|
|
||||||
var amount = rate * profile.MaxConfigs * command.Period.ToMonths();
|
var discountTiers = await dbContext
|
||||||
|
.PricingDiscountTiers.AsNoTracking()
|
||||||
|
.Where(t => t.PricingSettingsId == pricing.Id)
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
var discountPercent = PricingDiscount.ResolvePercent(discountTiers, profile.MaxConfigs);
|
||||||
|
|
||||||
|
var amount = PricingDiscount.Apply(rate * profile.MaxConfigs * command.Period.ToMonths(), discountPercent);
|
||||||
|
|
||||||
var request = PaymentRequest.Create(userId, command.Period, amount);
|
var request = PaymentRequest.Create(userId, command.Period, amount);
|
||||||
dbContext.PaymentRequests.Add(request);
|
dbContext.PaymentRequests.Add(request);
|
||||||
|
|||||||
@@ -49,6 +49,8 @@ public interface IAppDbContext
|
|||||||
|
|
||||||
DbSet<PricingSettings> PricingSettings { get; }
|
DbSet<PricingSettings> PricingSettings { get; }
|
||||||
|
|
||||||
|
DbSet<PricingDiscountTier> PricingDiscountTiers { get; }
|
||||||
|
|
||||||
DbSet<BillingSettings> BillingSettings { get; }
|
DbSet<BillingSettings> BillingSettings { get; }
|
||||||
|
|
||||||
DbSet<PaymentRequest> PaymentRequests { get; }
|
DbSet<PaymentRequest> PaymentRequests { get; }
|
||||||
|
|||||||
@@ -2,19 +2,31 @@ using PnvPanel.Domain.Pricing;
|
|||||||
|
|
||||||
namespace PnvPanel.Application.Common.Interfaces;
|
namespace PnvPanel.Application.Common.Interfaces;
|
||||||
|
|
||||||
/// <summary>Все поля — цена за конфиг В МЕСЯЦ при соответствующем тарифе оплаты; итог за период =
|
/// <summary>Одна ступень скидочной лесенки за объём — см. PricingDiscount.ResolvePercent для алгоритма
|
||||||
/// ставка × число месяцев. Используется и Admin (редактирование), и Support (справка при заявке на
|
/// выбора действующей ступени.</summary>
|
||||||
/// роль) — см. PricingSettingsDto.FromDomain.</summary>
|
public sealed record DiscountTierDto(int MinConfigs, int DiscountPercent);
|
||||||
|
|
||||||
|
/// <summary>Все ценовые поля — цена за конфиг В МЕСЯЦ при соответствующем тарифе; итог за период =
|
||||||
|
/// ставка × число месяцев, скидка (DiscountTiers) применяется к этому итогу по квоте роли. Используется
|
||||||
|
/// и Admin (редактирование), и Support (справка при заявке на роль) — см. PricingSettingsDto.FromDomain.</summary>
|
||||||
public sealed record PricingSettingsDto(
|
public sealed record PricingSettingsDto(
|
||||||
int? PricePerConfigPerQuarter,
|
int? PricePerConfigPerQuarter,
|
||||||
int? PricePerConfigPerHalfYear,
|
int? PricePerConfigPerHalfYear,
|
||||||
int? PricePerConfigPerYear
|
int? PricePerConfigPerYear,
|
||||||
|
IReadOnlyList<DiscountTierDto> DiscountTiers
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
public static PricingSettingsDto FromDomain(PricingSettings settings) =>
|
public static PricingSettingsDto FromDomain(
|
||||||
|
PricingSettings settings,
|
||||||
|
IReadOnlyList<PricingDiscountTier> discountTiers
|
||||||
|
) =>
|
||||||
new(
|
new(
|
||||||
settings.PricePerConfigPerQuarter,
|
settings.PricePerConfigPerQuarter,
|
||||||
settings.PricePerConfigPerHalfYear,
|
settings.PricePerConfigPerHalfYear,
|
||||||
settings.PricePerConfigPerYear
|
settings.PricePerConfigPerYear,
|
||||||
|
discountTiers
|
||||||
|
.OrderBy(t => t.MinConfigs)
|
||||||
|
.Select(t => new DiscountTierDto(t.MinConfigs, t.DiscountPercent))
|
||||||
|
.ToList()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+9
-5
@@ -18,10 +18,14 @@ public sealed class GetSupportPricingQueryHandler(IAppDbContext dbContext)
|
|||||||
.FirstOrDefaultAsync(cancellationToken);
|
.FirstOrDefaultAsync(cancellationToken);
|
||||||
|
|
||||||
// Ещё не сидировано/не сохранено ни разу — цена не задана, а не ошибка.
|
// Ещё не сидировано/не сохранено ни разу — цена не задана, а не ошибка.
|
||||||
return Result.Success(
|
if (settings is null)
|
||||||
settings is null
|
return Result.Success(new PricingSettingsDto(null, null, null, []));
|
||||||
? new PricingSettingsDto(null, null, null)
|
|
||||||
: PricingSettingsDto.FromDomain(settings)
|
var discountTiers = await dbContext
|
||||||
);
|
.PricingDiscountTiers.AsNoTracking()
|
||||||
|
.Where(t => t.PricingSettingsId == settings.Id)
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
return Result.Success(PricingSettingsDto.FromDomain(settings, discountTiers));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
namespace PnvPanel.Domain.Pricing;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Расчёт скидки по лесенке порогов — общий для реальной оплаты (CreatePaymentRequestCommandHandler)
|
||||||
|
/// и ознакомительной оценки (GetSupportPricingQuery/frontend). Фронт зеркалит тот же алгоритм для
|
||||||
|
/// превью цены до отправки запроса (см. PricingSettingsDto — итог за период по-прежнему считается
|
||||||
|
/// на фронте, здесь только процент скидки и применение его к уже посчитанной сумме).
|
||||||
|
/// </summary>
|
||||||
|
public static class PricingDiscount
|
||||||
|
{
|
||||||
|
/// <summary>Действует наивысший порог, квоте не превышающий — например при 5%/3+ и 10%/6+ роль с
|
||||||
|
/// MaxConfigs=8 получает 10%, а не 5%+10%. 0, если тиров нет или квота ниже всех порогов.</summary>
|
||||||
|
public static int ResolvePercent(IEnumerable<PricingDiscountTier> tiers, int maxConfigs)
|
||||||
|
{
|
||||||
|
return tiers
|
||||||
|
.Where(t => maxConfigs >= t.MinConfigs)
|
||||||
|
.OrderByDescending(t => t.MinConfigs)
|
||||||
|
.Select(t => t.DiscountPercent)
|
||||||
|
.FirstOrDefault();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static int Apply(int amount, int discountPercent)
|
||||||
|
{
|
||||||
|
if (discountPercent <= 0)
|
||||||
|
return amount;
|
||||||
|
|
||||||
|
return (int)Math.Round(amount * (100 - discountPercent) / 100m, MidpointRounding.AwayFromZero);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
using PnvPanel.Domain.Common;
|
||||||
|
|
||||||
|
namespace PnvPanel.Domain.Pricing;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Одна ступень скидочной лесенки за объём: роль с квотой `MaxConfigs >= MinConfigs` получает скидку
|
||||||
|
/// `DiscountPercent` от итоговой цены периода — стимул брать роль с большим числом конфигов разом.
|
||||||
|
/// Глобально, не привязано к конкретной роли (как и сам PricingSettings). Плоская таблица с FK на
|
||||||
|
/// PricingSettingsId, а не навигационная коллекция — см. конвенцию проекта (ср. TicketComment).
|
||||||
|
/// </summary>
|
||||||
|
public sealed class PricingDiscountTier : Entity
|
||||||
|
{
|
||||||
|
public Guid PricingSettingsId { get; private set; }
|
||||||
|
|
||||||
|
public int MinConfigs { get; private set; }
|
||||||
|
|
||||||
|
public int DiscountPercent { get; private set; }
|
||||||
|
|
||||||
|
private PricingDiscountTier() { }
|
||||||
|
|
||||||
|
public static PricingDiscountTier Create(Guid pricingSettingsId, int minConfigs, int discountPercent)
|
||||||
|
{
|
||||||
|
return new PricingDiscountTier
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
PricingSettingsId = pricingSettingsId,
|
||||||
|
MinConfigs = minConfigs,
|
||||||
|
DiscountPercent = discountPercent,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -59,6 +59,8 @@ public class AppDbContext(DbContextOptions<AppDbContext> options)
|
|||||||
|
|
||||||
public DbSet<PricingSettings> PricingSettings => Set<PricingSettings>();
|
public DbSet<PricingSettings> PricingSettings => Set<PricingSettings>();
|
||||||
|
|
||||||
|
public DbSet<PricingDiscountTier> PricingDiscountTiers => Set<PricingDiscountTier>();
|
||||||
|
|
||||||
public DbSet<BillingSettings> BillingSettings => Set<BillingSettings>();
|
public DbSet<BillingSettings> BillingSettings => Set<BillingSettings>();
|
||||||
|
|
||||||
public DbSet<PaymentRequest> PaymentRequests => Set<PaymentRequest>();
|
public DbSet<PaymentRequest> PaymentRequests => Set<PaymentRequest>();
|
||||||
|
|||||||
+16
@@ -0,0 +1,16 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||||
|
using PnvPanel.Domain.Pricing;
|
||||||
|
|
||||||
|
namespace PnvPanel.Infrastructure.Persistence.Configurations;
|
||||||
|
|
||||||
|
public class PricingDiscountTierConfiguration : IEntityTypeConfiguration<PricingDiscountTier>
|
||||||
|
{
|
||||||
|
public void Configure(EntityTypeBuilder<PricingDiscountTier> builder)
|
||||||
|
{
|
||||||
|
builder.ToTable("PricingDiscountTiers");
|
||||||
|
builder.HasKey(x => x.Id);
|
||||||
|
|
||||||
|
builder.HasIndex(x => new { x.PricingSettingsId, x.MinConfigs }).IsUnique();
|
||||||
|
}
|
||||||
|
}
|
||||||
+1069
File diff suppressed because it is too large
Load Diff
+42
@@ -0,0 +1,42 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace PnvPanel.Infrastructure.Persistence.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddPricingDiscountTiers : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "PricingDiscountTiers",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
|
PricingSettingsId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
|
MinConfigs = table.Column<int>(type: "integer", nullable: false),
|
||||||
|
DiscountPercent = table.Column<int>(type: "integer", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_PricingDiscountTiers", x => x.Id);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_PricingDiscountTiers_PricingSettingsId_MinConfigs",
|
||||||
|
table: "PricingDiscountTiers",
|
||||||
|
columns: new[] { "PricingSettingsId", "MinConfigs" },
|
||||||
|
unique: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "PricingDiscountTiers");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+23
@@ -583,6 +583,29 @@ namespace PnvPanel.Infrastructure.Persistence.Migrations
|
|||||||
b.ToTable("Nodes", (string)null);
|
b.ToTable("Nodes", (string)null);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("PnvPanel.Domain.Pricing.PricingDiscountTier", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<int>("DiscountPercent")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<int>("MinConfigs")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<Guid>("PricingSettingsId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("PricingSettingsId", "MinConfigs")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.ToTable("PricingDiscountTiers", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("PnvPanel.Domain.Pricing.PricingSettings", b =>
|
modelBuilder.Entity("PnvPanel.Domain.Pricing.PricingSettings", b =>
|
||||||
{
|
{
|
||||||
b.Property<Guid>("Id")
|
b.Property<Guid>("Id")
|
||||||
|
|||||||
+61
@@ -0,0 +1,61 @@
|
|||||||
|
using PnvPanel.Application.Admin.Pricing;
|
||||||
|
using PnvPanel.Application.Common.Interfaces;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace PnvPanel.Application.Tests.Admin.Pricing;
|
||||||
|
|
||||||
|
public class UpdatePricingSettingsCommandValidatorTests
|
||||||
|
{
|
||||||
|
private readonly UpdatePricingSettingsCommandValidator _validator = new();
|
||||||
|
|
||||||
|
private static UpdatePricingSettingsCommand Command(params DiscountTierDto[] tiers) =>
|
||||||
|
new(500, 450, 400, tiers);
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Validate_WithEmptyTiers_IsValid()
|
||||||
|
{
|
||||||
|
var result = _validator.Validate(Command());
|
||||||
|
|
||||||
|
Assert.True(result.IsValid);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Validate_WithProgressiveTiers_IsValid()
|
||||||
|
{
|
||||||
|
var result = _validator.Validate(Command(new DiscountTierDto(3, 5), new DiscountTierDto(6, 10)));
|
||||||
|
|
||||||
|
Assert.True(result.IsValid);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Validate_WithDuplicateThreshold_IsInvalid()
|
||||||
|
{
|
||||||
|
var result = _validator.Validate(Command(new DiscountTierDto(3, 5), new DiscountTierDto(3, 10)));
|
||||||
|
|
||||||
|
Assert.False(result.IsValid);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Validate_WhenHigherThresholdHasSmallerDiscount_IsInvalid()
|
||||||
|
{
|
||||||
|
var result = _validator.Validate(Command(new DiscountTierDto(3, 10), new DiscountTierDto(6, 5)));
|
||||||
|
|
||||||
|
Assert.False(result.IsValid);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Validate_WithDiscountPercentOutOfRange_IsInvalid()
|
||||||
|
{
|
||||||
|
var result = _validator.Validate(Command(new DiscountTierDto(3, 100)));
|
||||||
|
|
||||||
|
Assert.False(result.IsValid);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Validate_WithNonPositiveMinConfigs_IsInvalid()
|
||||||
|
{
|
||||||
|
var result = _validator.Validate(Command(new DiscountTierDto(0, 5)));
|
||||||
|
|
||||||
|
Assert.False(result.IsValid);
|
||||||
|
}
|
||||||
|
}
|
||||||
+32
@@ -65,6 +65,38 @@ public class CreatePaymentRequestCommandHandlerTests
|
|||||||
Assert.Equal(PaymentRequestStatus.AwaitingPayment, result.Value.Status);
|
Assert.Equal(PaymentRequestStatus.AwaitingPayment, result.Value.Status);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Handle_WithApplicableDiscountTier_AppliesDiscountToAmount()
|
||||||
|
{
|
||||||
|
using var dbContext = InMemoryDbContextFactory.Create();
|
||||||
|
var userId = Guid.NewGuid();
|
||||||
|
var pricing = PricingSettings.CreateDefault();
|
||||||
|
pricing.Update(500, 450, 400);
|
||||||
|
dbContext.PricingSettings.Add(pricing);
|
||||||
|
dbContext.PricingDiscountTiers.Add(PricingDiscountTier.Create(pricing.Id, minConfigs: 3, discountPercent: 10));
|
||||||
|
dbContext.PricingDiscountTiers.Add(PricingDiscountTier.Create(pricing.Id, minConfigs: 6, discountPercent: 20));
|
||||||
|
await dbContext.SaveChangesAsync(CancellationToken.None);
|
||||||
|
|
||||||
|
_identityService
|
||||||
|
.GetProfileAsync(userId, Arg.Any<CancellationToken>())
|
||||||
|
.Returns(Profile(userId, maxConfigs: 5));
|
||||||
|
|
||||||
|
var handler = new CreatePaymentRequestCommandHandler(
|
||||||
|
dbContext,
|
||||||
|
_identityService,
|
||||||
|
FakeCurrentUser.Authenticated(userId)
|
||||||
|
);
|
||||||
|
|
||||||
|
var result = await handler.Handle(
|
||||||
|
new CreatePaymentRequestCommand(PaymentPeriod.Quarter),
|
||||||
|
CancellationToken.None
|
||||||
|
);
|
||||||
|
|
||||||
|
Assert.True(result.IsSuccess);
|
||||||
|
// 500 * 5 * 3 = 7500, скидка 10% (порог 6 не достигнут при 5 конфигах) → 6750.
|
||||||
|
Assert.Equal(6750, result.Value.AmountSnapshot);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Handle_WhenBillingNotEnabled_ReturnsNotEnabled()
|
public async Task Handle_WhenBillingNotEnabled_ReturnsNotEnabled()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
using PnvPanel.Domain.Pricing;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace PnvPanel.Domain.Tests.Pricing;
|
||||||
|
|
||||||
|
public class PricingDiscountTests
|
||||||
|
{
|
||||||
|
private static PricingDiscountTier Tier(int minConfigs, int percent) =>
|
||||||
|
PricingDiscountTier.Create(Guid.NewGuid(), minConfigs, percent);
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ResolvePercent_WhenNoTiers_ReturnsZero()
|
||||||
|
{
|
||||||
|
var percent = PricingDiscount.ResolvePercent([], maxConfigs: 10);
|
||||||
|
|
||||||
|
Assert.Equal(0, percent);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ResolvePercent_WhenBelowAllThresholds_ReturnsZero()
|
||||||
|
{
|
||||||
|
var tiers = new[] { Tier(3, 5), Tier(6, 10) };
|
||||||
|
|
||||||
|
var percent = PricingDiscount.ResolvePercent(tiers, maxConfigs: 2);
|
||||||
|
|
||||||
|
Assert.Equal(0, percent);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ResolvePercent_PicksHighestApplicableTier_NotCumulative()
|
||||||
|
{
|
||||||
|
var tiers = new[] { Tier(3, 5), Tier(6, 10), Tier(12, 15) };
|
||||||
|
|
||||||
|
var percent = PricingDiscount.ResolvePercent(tiers, maxConfigs: 8);
|
||||||
|
|
||||||
|
Assert.Equal(10, percent);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ResolvePercent_ExactlyOnThreshold_Applies()
|
||||||
|
{
|
||||||
|
var tiers = new[] { Tier(3, 5) };
|
||||||
|
|
||||||
|
var percent = PricingDiscount.ResolvePercent(tiers, maxConfigs: 3);
|
||||||
|
|
||||||
|
Assert.Equal(5, percent);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Apply_WithZeroPercent_ReturnsAmountUnchanged()
|
||||||
|
{
|
||||||
|
var amount = PricingDiscount.Apply(1000, 0);
|
||||||
|
|
||||||
|
Assert.Equal(1000, amount);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Apply_WithPercent_RoundsToNearestInteger()
|
||||||
|
{
|
||||||
|
var amount = PricingDiscount.Apply(999, 10);
|
||||||
|
|
||||||
|
Assert.Equal(899, amount);
|
||||||
|
}
|
||||||
|
}
|
||||||
+3
-3
@@ -169,7 +169,7 @@ status, createdAt }`. `expiresAt` всегда `null` (лимиты по сро
|
|||||||
| Метод | Путь | Тело запроса | Тело ответа |
|
| Метод | Путь | Тело запроса | Тело ответа |
|
||||||
| ----- | ----------------------------------------- | ---------------------------------------------------------------------------- | ------------- |
|
| ----- | ----------------------------------------- | ---------------------------------------------------------------------------- | ------------- |
|
||||||
| GET | `/api/support/roles` | — | `RoleDto[]` (без `admin` и без текущей роли пользователя) — для выбора существующей роли в заявке |
|
| GET | `/api/support/roles` | — | `RoleDto[]` (без `admin` и без текущей роли пользователя) — для выбора существующей роли в заявке |
|
||||||
| GET | `/api/support/pricing` | — | `PricingSettingsDto` — та же цена, что и `/api/admin/pricing`, для справки в диалоге заявки на роль |
|
| GET | `/api/support/pricing` | — | `PricingSettingsDto` — та же цена (включая скидочную лесенку `discountTiers`), что и `/api/admin/pricing`, для справки в диалоге заявки на роль |
|
||||||
| GET | `/api/support/tickets` | query: `type?, status?, page=1, pageSize=20` | `PagedList<TicketSummaryDto>` (только свои) |
|
| GET | `/api/support/tickets` | query: `type?, status?, page=1, pageSize=20` | `PagedList<TicketSummaryDto>` (только свои) |
|
||||||
| GET | `/api/support/tickets/{id}` | — | `TicketDetailDto` (404, если не свой) |
|
| GET | `/api/support/tickets/{id}` | — | `TicketDetailDto` (404, если не свой) |
|
||||||
| POST | `/api/support/tickets/bug-reports` | multipart: `message` + `files[]` (до 5, изображения до 5 МБ) | `TicketDetailDto` |
|
| POST | `/api/support/tickets/bug-reports` | multipart: `message` + `files[]` (до 5, изображения до 5 МБ) | `TicketDetailDto` |
|
||||||
@@ -266,8 +266,8 @@ reject/approve владением тикета не ограничены. Еди
|
|||||||
| PUT | `/api/admin/roles/{id}` | admin | `{ maxConfigs, maxIpLimit, billingEnabled }` | `RoleDto` (то же ограничение на `admin`; включение `billingEnabled` ретроактивно выдаёт грейс-период уже назначенным пользователям без `PaidUntil`) |
|
| PUT | `/api/admin/roles/{id}` | admin | `{ maxConfigs, maxIpLimit, billingEnabled }` | `RoleDto` (то же ограничение на `admin`; включение `billingEnabled` ретроактивно выдаёт грейс-период уже назначенным пользователям без `PaidUntil`) |
|
||||||
| DELETE | `/api/admin/roles/{id}` | admin | — | `204 No Content` (системные `admin`/`user` удалить нельзя) |
|
| DELETE | `/api/admin/roles/{id}` | admin | — | `204 No Content` (системные `admin`/`user` удалить нельзя) |
|
||||||
| PATCH | `/api/admin/users/{id}/role` | admin | `{ roleId }` | `204 No Content` (`409 Roles.CannotRemoveLastAdmin`, если у цели сейчас `admin`, новая роль другая, и это единственный админ) |
|
| PATCH | `/api/admin/users/{id}/role` | admin | `{ roleId }` | `204 No Content` (`409 Roles.CannotRemoveLastAdmin`, если у цели сейчас `admin`, новая роль другая, и это единственный админ) |
|
||||||
| GET | `/api/admin/pricing` | admin | — | `PricingSettingsDto` (глобальная справочная цена за конфиг **в месяц**, одна на весь сервис — не per-роль) |
|
| GET | `/api/admin/pricing` | admin | — | `PricingSettingsDto` (глобальная справочная цена за конфиг **в месяц** + скидочная лесенка `discountTiers: { minConfigs, discountPercent }[]`, одна на весь сервис — не per-роль) |
|
||||||
| PUT | `/api/admin/pricing` | admin | `{ pricePerConfigPerQuarter?, pricePerConfigPerHalfYear?, pricePerConfigPerYear? }` | `PricingSettingsDto` (`400`, если итог более длинного тарифа дешевле итога более короткого) |
|
| PUT | `/api/admin/pricing` | admin | `{ pricePerConfigPerQuarter?, pricePerConfigPerHalfYear?, pricePerConfigPerYear?, discountTiers: { minConfigs, discountPercent }[] }` | `PricingSettingsDto` (`400`, если итог более длинного тарифа дешевле итога более короткого, либо `discountTiers` не уникальны/не прогрессивны — см. domain-model.md#pricingdiscounttier). `discountTiers` при сохранении полностью заменяет прежний набор |
|
||||||
|
|
||||||
Нет отдельного эндпоинта «активировать напрямую без запроса» — активация только через
|
Нет отдельного эндпоинта «активировать напрямую без запроса» — активация только через
|
||||||
approve/reject над `ActivationRequest`.
|
approve/reject над `ActivationRequest`.
|
||||||
|
|||||||
+35
-3
@@ -335,8 +335,8 @@ UI **настойчиво напоминает** привязать его (ед
|
|||||||
- год = `PricePerConfigPerYear × 12 × MaxConfigs`
|
- год = `PricePerConfigPerYear × 12 × MaxConfigs`
|
||||||
|
|
||||||
Например, `user` с `MaxConfigs=3` и одинаковой ставкой 200₽/мес на всех трёх тарифах → 600₽/3мес,
|
Например, `user` с `MaxConfigs=3` и одинаковой ставкой 200₽/мес на всех трёх тарифах → 600₽/3мес,
|
||||||
1200₽/полгода, 2400₽/год (линейный рост, скидки нет). Для ролей с `MaxConfigs = -1` (unlimited, в
|
1200₽/полгода, 2400₽/год (линейный рост, скидки за тариф нет). Для ролей с `MaxConfigs = -1`
|
||||||
т.ч. `admin`) итог не считается — отображается как «не задано».
|
(unlimited, в т.ч. `admin`) итог не считается — отображается как «не задано».
|
||||||
|
|
||||||
**Инвариант**: `UpdatePricingSettingsCommandValidator` не даёт сохранить более длинный тариф настолько
|
**Инвариант**: `UpdatePricingSettingsCommandValidator` не даёт сохранить более длинный тариф настолько
|
||||||
дешёвым, что его итог окажется дешевле итога более короткого — иначе выгоднее купить длинный тариф и
|
дешёвым, что его итог окажется дешевле итога более короткого — иначе выгоднее купить длинный тариф и
|
||||||
@@ -355,6 +355,38 @@ PricePerConfigPerQuarter × 3`).
|
|||||||
admin- и user-facing путями безопасно. Сидируется пустой строкой при старте (`IPricingSettingsSeeder`,
|
admin- и user-facing путями безопасно. Сидируется пустой строкой при старте (`IPricingSettingsSeeder`,
|
||||||
если таблица пуста) и заново после полного сброса панели (см. «Полный сброс панели» выше).
|
если таблица пуста) и заново после полного сброса панели (см. «Полный сброс панели» выше).
|
||||||
|
|
||||||
|
#### PricingDiscountTier — скидка за объём (лесенка порогов)
|
||||||
|
|
||||||
|
Стимул брать роль с бОльшей квотой конфигов разом: плоская таблица (не навигационная коллекция —
|
||||||
|
см. конвенцию проекта на TicketComment) с FK на `PricingSettingsId`, глобальная, не привязана к
|
||||||
|
конкретной роли — как и сам `PricingSettings`.
|
||||||
|
|
||||||
|
| Поле | Тип | Заметки |
|
||||||
|
| ------------------- | -------- | ------------------------------------------------------------------ |
|
||||||
|
| `Id` | `Guid` | PK |
|
||||||
|
| `PricingSettingsId` | `Guid` | FK → PricingSettings |
|
||||||
|
| `MinConfigs` | `int` | Порог: скидка действует при `AppRole.MaxConfigs >= MinConfigs` |
|
||||||
|
| `DiscountPercent` | `int` | Скидка в процентах от итоговой цены периода, 1–99 |
|
||||||
|
|
||||||
|
Действует **наивысший подходящий порог** (не суммируется с другими) — `PricingDiscount.ResolvePercent`
|
||||||
|
(`Domain/Pricing`): из тиров с `MinConfigs <= MaxConfigs` берётся тот, у которого `MinConfigs`
|
||||||
|
максимален. Например, при порогах `3+ → 5%` и `6+ → 10%` роль с `MaxConfigs=8` получает 10%, а не 15%.
|
||||||
|
Скидка применяется к уже посчитанному итогу периода: `PricingDiscount.Apply(итог, процент)`, округление
|
||||||
|
до целого рубля (`MidpointRounding.AwayFromZero`). Роли с `MaxConfigs = -1` (unlimited) скидку не
|
||||||
|
получают — как и обычный расчёт цены, для них итог не считается.
|
||||||
|
|
||||||
|
**Инвариант**: `UpdatePricingSettingsCommandValidator` требует уникальности порогов и прогрессивности
|
||||||
|
лесенки — на более высоком пороге скидка не может быть меньше, чем на более низком (иначе взять роль с
|
||||||
|
бОльшей квотой может оказаться менее выгодно, что противоречит смыслу скидки за объём).
|
||||||
|
|
||||||
|
Применяется в двух местах, зеркалящих друг друга: реальная оплата (`CreatePaymentRequestCommandHandler`
|
||||||
|
— `AmountSnapshot` уже с учётом скидки) и ознакомительная оценка (`PricingSettingsDto.DiscountTiers` +
|
||||||
|
`resolveDiscountPercent`/`applyDiscount` на фронте, `frontend/src/shared/lib/pricing.ts`) — используется
|
||||||
|
и в списке ролей в админке (`admin/roles.tsx`), и в оценке стоимости при смене роли тикетом
|
||||||
|
(`CreateRoleRequestDialog.tsx`). `UpdatePricingSettingsCommand` при сохранении полностью заменяет набор
|
||||||
|
тиров (удаляет старые, вставляет новые) — операция редкая (правит только `admin`), сложность
|
||||||
|
инкрементального diff не оправдана.
|
||||||
|
|
||||||
### Billing — подписка по сроку
|
### Billing — подписка по сроку
|
||||||
|
|
||||||
Опциональная подсистема: включается per-роль (`AppRole.BillingEnabled`), недоступна для `admin`.
|
Опциональная подсистема: включается per-роль (`AppRole.BillingEnabled`), недоступна для `admin`.
|
||||||
@@ -393,7 +425,7 @@ Singleton (как `PricingSettings`) — реквизиты для оплаты
|
|||||||
| `Id` | `Guid` | PK |
|
| `Id` | `Guid` | PK |
|
||||||
| `UserId` | `Guid` | FK → AppUser (заявитель) |
|
| `UserId` | `Guid` | FK → AppUser (заявитель) |
|
||||||
| `Period` | `PaymentPeriod` | `Quarter` (3 мес) / `HalfYear` (6 мес) / `Year` (12 мес) |
|
| `Period` | `PaymentPeriod` | `Quarter` (3 мес) / `HalfYear` (6 мес) / `Year` (12 мес) |
|
||||||
| `AmountSnapshot` | `int` | Сумма, замороженная на момент создания: `ставка PricingSettings за период × MaxConfigs роли × число месяцев`. Последующее изменение прайса админом не меняет уже созданные заявки |
|
| `AmountSnapshot` | `int` | Сумма, замороженная на момент создания: `ставка PricingSettings за период × MaxConfigs роли × число месяцев`, затем скидка по лесенке `PricingDiscountTier` (см. выше), если применима. Последующее изменение прайса/лесенки админом не меняет уже созданные заявки |
|
||||||
| `Status` | `PaymentRequestStatus` | `AwaitingPayment` → `AwaitingConfirmation` → `Confirmed`/`Rejected`, либо `Cancelled` из `AwaitingPayment` |
|
| `Status` | `PaymentRequestStatus` | `AwaitingPayment` → `AwaitingConfirmation` → `Confirmed`/`Rejected`, либо `Cancelled` из `AwaitingPayment` |
|
||||||
| `DecidedBy`/`DecidedAt`/`RejectionReason` | | Кто/когда решил, причина отказа (опционально) |
|
| `DecidedBy`/`DecidedAt`/`RejectionReason` | | Кто/когда решил, причина отказа (опционально) |
|
||||||
| `CreatedAt` | `DateTimeOffset` | |
|
| `CreatedAt` | `DateTimeOffset` | |
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ import { HttpError } from '@/shared/api/client'
|
|||||||
import type { PricingSettingsDto } from '@/shared/api/types'
|
import type { PricingSettingsDto } from '@/shared/api/types'
|
||||||
import { updatePricingSettings } from './api'
|
import { updatePricingSettings } from './api'
|
||||||
|
|
||||||
|
type TierRow = { minConfigs: string; discountPercent: string }
|
||||||
|
|
||||||
export function PricingSettingsEditor({ settings }: { settings: PricingSettingsDto }) {
|
export function PricingSettingsEditor({ settings }: { settings: PricingSettingsDto }) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
@@ -21,6 +23,31 @@ export function PricingSettingsEditor({ settings }: { settings: PricingSettingsD
|
|||||||
const [pricePerConfigPerYear, setPricePerConfigPerYear] = useState(
|
const [pricePerConfigPerYear, setPricePerConfigPerYear] = useState(
|
||||||
settings.pricePerConfigPerYear != null ? String(settings.pricePerConfigPerYear) : '',
|
settings.pricePerConfigPerYear != null ? String(settings.pricePerConfigPerYear) : '',
|
||||||
)
|
)
|
||||||
|
const [tiers, setTiers] = useState<TierRow[]>(
|
||||||
|
settings.discountTiers.map((t) => ({ minConfigs: String(t.minConfigs), discountPercent: String(t.discountPercent) })),
|
||||||
|
)
|
||||||
|
|
||||||
|
const updateTier = (index: number, patch: Partial<TierRow>) =>
|
||||||
|
setTiers((rows) => rows.map((row, i) => (i === index ? { ...row, ...patch } : row)))
|
||||||
|
const removeTier = (index: number) => setTiers((rows) => rows.filter((_, i) => i !== index))
|
||||||
|
const addTier = () => setTiers((rows) => [...rows, { minConfigs: '', discountPercent: '' }])
|
||||||
|
|
||||||
|
// Зеркалит бэкенд-инварианты UpdatePricingSettingsCommandValidator: пороги не повторяются, скидка
|
||||||
|
// на большем пороге не меньше скидки на меньшем (иначе лесенка не прогрессивная).
|
||||||
|
const tierErrors = (() => {
|
||||||
|
if (tiers.some((t) => t.minConfigs === '' || t.discountPercent === '')) return null
|
||||||
|
const parsed = tiers.map((t) => ({ minConfigs: Number(t.minConfigs), discountPercent: Number(t.discountPercent) }))
|
||||||
|
if (parsed.some((t) => !Number.isInteger(t.minConfigs) || t.minConfigs < 1)) return t('admin.pricing.discountTierInvalidMinConfigs')
|
||||||
|
if (parsed.some((t) => !Number.isInteger(t.discountPercent) || t.discountPercent < 1 || t.discountPercent > 99))
|
||||||
|
return t('admin.pricing.discountTierInvalidPercent')
|
||||||
|
if (new Set(parsed.map((t) => t.minConfigs)).size !== parsed.length) return t('admin.pricing.discountTiersDuplicate')
|
||||||
|
const sorted = [...parsed].sort((a, b) => a.minConfigs - b.minConfigs)
|
||||||
|
for (let i = 1; i < sorted.length; i++) {
|
||||||
|
if (sorted[i].discountPercent < sorted[i - 1].discountPercent) return t('admin.pricing.discountTiersNotProgressive')
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
})()
|
||||||
|
const hasIncompleteTier = tiers.some((t) => t.minConfigs === '' || t.discountPercent === '')
|
||||||
|
|
||||||
// Все ставки — цена за конфиг в месяц; итог за более длинный период (ставка × месяцев) не должен
|
// Все ставки — цена за конфиг в месяц; итог за более длинный период (ставка × месяцев) не должен
|
||||||
// быть дешевле итога за более короткий, иначе выгоднее купить длинный тариф и не продлевать.
|
// быть дешевле итога за более короткий, иначе выгоднее купить длинный тариф и не продлевать.
|
||||||
@@ -48,6 +75,7 @@ export function PricingSettingsEditor({ settings }: { settings: PricingSettingsD
|
|||||||
pricePerConfigPerQuarter === '' ? null : Number(pricePerConfigPerQuarter),
|
pricePerConfigPerQuarter === '' ? null : Number(pricePerConfigPerQuarter),
|
||||||
pricePerConfigPerHalfYear === '' ? null : Number(pricePerConfigPerHalfYear),
|
pricePerConfigPerHalfYear === '' ? null : Number(pricePerConfigPerHalfYear),
|
||||||
pricePerConfigPerYear === '' ? null : Number(pricePerConfigPerYear),
|
pricePerConfigPerYear === '' ? null : Number(pricePerConfigPerYear),
|
||||||
|
tiers.map((t) => ({ minConfigs: Number(t.minConfigs), discountPercent: Number(t.discountPercent) })),
|
||||||
),
|
),
|
||||||
onSuccess: async () => {
|
onSuccess: async () => {
|
||||||
toast.success(t('admin.pricing.updated'))
|
toast.success(t('admin.pricing.updated'))
|
||||||
@@ -106,8 +134,40 @@ export function PricingSettingsEditor({ settings }: { settings: PricingSettingsD
|
|||||||
<p className="text-xs text-red-600">{t('admin.pricing.yearCheaperThanQuarter')}</p>
|
<p className="text-xs text-red-600">{t('admin.pricing.yearCheaperThanQuarter')}</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label>{t('admin.pricing.discountTiersTitle')}</Label>
|
||||||
|
<p className="text-xs text-muted-foreground">{t('admin.pricing.discountTiersHint')}</p>
|
||||||
|
{tiers.map((tier, index) => (
|
||||||
|
<div key={index} className="flex items-center gap-2">
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
placeholder={t('admin.pricing.discountTierMinConfigs')}
|
||||||
|
value={tier.minConfigs}
|
||||||
|
onChange={(e) => updateTier(index, { minConfigs: e.target.value })}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
max={99}
|
||||||
|
placeholder={t('admin.pricing.discountTierPercent')}
|
||||||
|
value={tier.discountPercent}
|
||||||
|
onChange={(e) => updateTier(index, { discountPercent: e.target.value })}
|
||||||
|
/>
|
||||||
|
<Button type="button" variant="ghost" size="sm" onClick={() => removeTier(index)}>
|
||||||
|
{t('admin.pricing.removeDiscountTier')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{tierErrors && <p className="text-xs text-red-600">{tierErrors}</p>}
|
||||||
|
<div>
|
||||||
|
<Button type="button" variant="outline" size="sm" onClick={addTier}>
|
||||||
|
{t('admin.pricing.addDiscountTier')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<Button type="submit" disabled={mutation.isPending || hasInvalidCombo}>
|
<Button type="submit" disabled={mutation.isPending || hasInvalidCombo || !!tierErrors || hasIncompleteTier}>
|
||||||
{t('admin.roles.save')}
|
{t('admin.roles.save')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { apiRequest } from '@/shared/api/client'
|
import { apiRequest } from '@/shared/api/client'
|
||||||
import type { PricingSettingsDto } from '@/shared/api/types'
|
import type { DiscountTierDto, PricingSettingsDto } from '@/shared/api/types'
|
||||||
|
|
||||||
export function getPricingSettings() {
|
export function getPricingSettings() {
|
||||||
return apiRequest<PricingSettingsDto>('/admin/pricing')
|
return apiRequest<PricingSettingsDto>('/admin/pricing')
|
||||||
@@ -9,9 +9,10 @@ export function updatePricingSettings(
|
|||||||
pricePerConfigPerQuarter: number | null,
|
pricePerConfigPerQuarter: number | null,
|
||||||
pricePerConfigPerHalfYear: number | null,
|
pricePerConfigPerHalfYear: number | null,
|
||||||
pricePerConfigPerYear: number | null,
|
pricePerConfigPerYear: number | null,
|
||||||
|
discountTiers: DiscountTierDto[],
|
||||||
) {
|
) {
|
||||||
return apiRequest<PricingSettingsDto>('/admin/pricing', {
|
return apiRequest<PricingSettingsDto>('/admin/pricing', {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
body: { pricePerConfigPerQuarter, pricePerConfigPerHalfYear, pricePerConfigPerYear },
|
body: { pricePerConfigPerQuarter, pricePerConfigPerHalfYear, pricePerConfigPerYear, discountTiers },
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { Label } from '@/shared/ui/label'
|
|||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/shared/ui/dialog'
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/shared/ui/dialog'
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select'
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select'
|
||||||
import { HttpError } from '@/shared/api/client'
|
import { HttpError } from '@/shared/api/client'
|
||||||
|
import { applyDiscount, resolveDiscountPercent } from '@/shared/lib/pricing'
|
||||||
import { createRoleRequestTicket, getSupportPricing, listSelectableRoles } from './api'
|
import { createRoleRequestTicket, getSupportPricing, listSelectableRoles } from './api'
|
||||||
|
|
||||||
type Mode = 'existing' | 'new'
|
type Mode = 'existing' | 'new'
|
||||||
@@ -80,11 +81,19 @@ export function CreateRoleRequestDialog() {
|
|||||||
? Number(newRoleMaxConfigs)
|
? Number(newRoleMaxConfigs)
|
||||||
: undefined
|
: undefined
|
||||||
|
|
||||||
// Ставки — цена за конфиг В МЕСЯЦ; итог за период = ставка × месяцев × квота.
|
// Ставки — цена за конфиг В МЕСЯЦ; итог за период = ставка × месяцев × квота, затем скидка по
|
||||||
const totalPrice = (monthlyRate: number | null | undefined, months: number) =>
|
// лесенке (см. shared/lib/pricing) — за роль с большей квотой конфигов та же лесенка, что в
|
||||||
monthlyRate == null || maxConfigsForPricing == null || maxConfigsForPricing < 0
|
// admin/roles.tsx и на реальной оплате (CreatePaymentRequestCommandHandler).
|
||||||
? t('admin.roles.noPrice')
|
const totalPrice = (monthlyRate: number | null | undefined, months: number) => {
|
||||||
: `${monthlyRate * months * maxConfigsForPricing} ₽`
|
if (monthlyRate == null || maxConfigsForPricing == null || maxConfigsForPricing < 0) return t('admin.roles.noPrice')
|
||||||
|
|
||||||
|
const original = monthlyRate * months * maxConfigsForPricing
|
||||||
|
const percent = resolveDiscountPercent(pricingQuery.data?.discountTiers ?? [], maxConfigsForPricing)
|
||||||
|
if (percent <= 0) return `${original} ₽`
|
||||||
|
|
||||||
|
const discounted = applyDiscount(original, percent)
|
||||||
|
return t('support.pricingDiscounted', { price: discounted, original, percent })
|
||||||
|
}
|
||||||
|
|
||||||
const showPricing = maxConfigsForPricing != null && maxConfigsForPricing >= 0
|
const showPricing = maxConfigsForPricing != null && maxConfigsForPricing >= 0
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { Badge } from '@/shared/ui/badge'
|
|||||||
import { listRoles, deleteRole } from '@/features/admin/roles/api'
|
import { listRoles, deleteRole } from '@/features/admin/roles/api'
|
||||||
import { RoleFormDialog } from '@/features/admin/roles/RoleFormDialog'
|
import { RoleFormDialog } from '@/features/admin/roles/RoleFormDialog'
|
||||||
import { getPricingSettings } from '@/features/admin/pricing/api'
|
import { getPricingSettings } from '@/features/admin/pricing/api'
|
||||||
|
import { applyDiscount, resolveDiscountPercent } from '@/shared/lib/pricing'
|
||||||
import type { RoleDto } from '@/shared/api/types'
|
import type { RoleDto } from '@/shared/api/types'
|
||||||
|
|
||||||
export const Route = createFileRoute('/admin/roles')({ component: AdminRolesPage })
|
export const Route = createFileRoute('/admin/roles')({ component: AdminRolesPage })
|
||||||
@@ -20,9 +21,26 @@ function AdminRolesPage() {
|
|||||||
const { data, isLoading, isError, refetch } = useQuery({ queryKey: ['admin-roles'], queryFn: listRoles })
|
const { data, isLoading, isError, refetch } = useQuery({ queryKey: ['admin-roles'], queryFn: listRoles })
|
||||||
const { data: pricing } = useQuery({ queryKey: ['admin-pricing'], queryFn: getPricingSettings })
|
const { data: pricing } = useQuery({ queryKey: ['admin-pricing'], queryFn: getPricingSettings })
|
||||||
|
|
||||||
// Ставки — цена за конфиг В МЕСЯЦ при данном тарифе; итог за период = ставка × месяцев × квота.
|
// Ставки — цена за конфиг В МЕСЯЦ при данном тарифе; итог за период = ставка × месяцев × квота,
|
||||||
const totalPrice = (monthlyRate: number | null | undefined, maxConfigs: number, months: number) =>
|
// затем скидка по лесенке (см. shared/lib/pricing) — за роль с большей квотой конфигов.
|
||||||
monthlyRate == null || maxConfigs < 0 ? t('admin.roles.noPrice') : `${monthlyRate * months * maxConfigs} ₽`
|
const priceCell = (monthlyRate: number | null | undefined, maxConfigs: number, months: number) => {
|
||||||
|
if (monthlyRate == null || maxConfigs < 0) return <span>{t('admin.roles.noPrice')}</span>
|
||||||
|
|
||||||
|
const original = monthlyRate * months * maxConfigs
|
||||||
|
const percent = resolveDiscountPercent(pricing?.discountTiers ?? [], maxConfigs)
|
||||||
|
if (percent <= 0) return <span>{original} ₽</span>
|
||||||
|
|
||||||
|
const discounted = applyDiscount(original, percent)
|
||||||
|
return (
|
||||||
|
<span className="flex flex-col">
|
||||||
|
<span className="text-xs text-muted-foreground line-through">{original} ₽</span>
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
{discounted} ₽
|
||||||
|
<Badge variant="success">-{percent}%</Badge>
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
const deleteMutation = useMutation({
|
const deleteMutation = useMutation({
|
||||||
mutationFn: deleteRole,
|
mutationFn: deleteRole,
|
||||||
@@ -74,9 +92,9 @@ function AdminRolesPage() {
|
|||||||
</td>
|
</td>
|
||||||
<td className="py-2">{role.maxConfigs < 0 ? t('unlimited') : role.maxConfigs}</td>
|
<td className="py-2">{role.maxConfigs < 0 ? t('unlimited') : role.maxConfigs}</td>
|
||||||
<td className="py-2">{role.maxIpLimit < 0 ? t('unlimited') : role.maxIpLimit}</td>
|
<td className="py-2">{role.maxIpLimit < 0 ? t('unlimited') : role.maxIpLimit}</td>
|
||||||
<td className="py-2">{totalPrice(pricing?.pricePerConfigPerQuarter, role.maxConfigs, 3)}</td>
|
<td className="py-2">{priceCell(pricing?.pricePerConfigPerQuarter, role.maxConfigs, 3)}</td>
|
||||||
<td className="py-2">{totalPrice(pricing?.pricePerConfigPerHalfYear, role.maxConfigs, 6)}</td>
|
<td className="py-2">{priceCell(pricing?.pricePerConfigPerHalfYear, role.maxConfigs, 6)}</td>
|
||||||
<td className="py-2">{totalPrice(pricing?.pricePerConfigPerYear, role.maxConfigs, 12)}</td>
|
<td className="py-2">{priceCell(pricing?.pricePerConfigPerYear, role.maxConfigs, 12)}</td>
|
||||||
<td className="py-2 text-right">
|
<td className="py-2 text-right">
|
||||||
<Button size="sm" variant="outline" onClick={() => setEditing(role)}>
|
<Button size="sm" variant="outline" onClick={() => setEditing(role)}>
|
||||||
{t('admin.roles.edit')}
|
{t('admin.roles.edit')}
|
||||||
|
|||||||
@@ -178,12 +178,21 @@ export type RoleDto = {
|
|||||||
billingEnabled: boolean
|
billingEnabled: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Одна ступень скидочной лесенки за объём: роль с квотой maxConfigs >= minConfigs получает скидку
|
||||||
|
* discountPercent от итоговой цены периода. Действует наивысший подходящий порог (не суммируется). */
|
||||||
|
export type DiscountTierDto = {
|
||||||
|
minConfigs: number
|
||||||
|
discountPercent: number
|
||||||
|
}
|
||||||
|
|
||||||
/** Глобальная справочная цена за конфиг (видна только админу) — одна на весь сервис, не per-роль.
|
/** Глобальная справочная цена за конфиг (видна только админу) — одна на весь сервис, не per-роль.
|
||||||
* Все поля — цена за конфиг В МЕСЯЦ при данном тарифе; итог за период = ставка × месяцы. */
|
* Все поля — цена за конфиг В МЕСЯЦ при данном тарифе; итог за период = ставка × месяцы, скидка
|
||||||
|
* (discountTiers) применяется к этому итогу по квоте роли — см. resolveDiscountPercent/applyDiscount. */
|
||||||
export type PricingSettingsDto = {
|
export type PricingSettingsDto = {
|
||||||
pricePerConfigPerQuarter: number | null
|
pricePerConfigPerQuarter: number | null
|
||||||
pricePerConfigPerHalfYear: number | null
|
pricePerConfigPerHalfYear: number | null
|
||||||
pricePerConfigPerYear: number | null
|
pricePerConfigPerYear: number | null
|
||||||
|
discountTiers: DiscountTierDto[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export type PaymentPeriod = 'Quarter' | 'HalfYear' | 'Year'
|
export type PaymentPeriod = 'Quarter' | 'HalfYear' | 'Year'
|
||||||
|
|||||||
@@ -188,6 +188,7 @@ const resources = {
|
|||||||
pricingHalfYear: 'Полгода: {{price}}',
|
pricingHalfYear: 'Полгода: {{price}}',
|
||||||
pricingYear: 'Год: {{price}}',
|
pricingYear: 'Год: {{price}}',
|
||||||
pricingDisclaimer: 'Цены на данный момент ознакомительные.',
|
pricingDisclaimer: 'Цены на данный момент ознакомительные.',
|
||||||
|
pricingDiscounted: '{{price}} ₽ (вместо {{original}} ₽, скидка {{percent}}%)',
|
||||||
justification: 'Обоснование',
|
justification: 'Обоснование',
|
||||||
ticketCreated: 'Обращение отправлено.',
|
ticketCreated: 'Обращение отправлено.',
|
||||||
roleRequestPending: 'У вас уже есть необработанная заявка на роль.',
|
roleRequestPending: 'У вас уже есть необработанная заявка на роль.',
|
||||||
@@ -340,6 +341,16 @@ const resources = {
|
|||||||
yearCheaperThanHalfYear: 'Цена за год (в пересчёте на 12 месяцев) не может быть меньше цены за полгода.',
|
yearCheaperThanHalfYear: 'Цена за год (в пересчёте на 12 месяцев) не может быть меньше цены за полгода.',
|
||||||
yearCheaperThanQuarter: 'Цена за год (в пересчёте на 12 месяцев) не может быть меньше цены за 3 месяца.',
|
yearCheaperThanQuarter: 'Цена за год (в пересчёте на 12 месяцев) не может быть меньше цены за 3 месяца.',
|
||||||
updated: 'Цена обновлена.',
|
updated: 'Цена обновлена.',
|
||||||
|
discountTiersTitle: 'Скидка за объём',
|
||||||
|
discountTiersHint: 'Роль с квотой конфигов не меньше порога получает указанную скидку от итоговой цены за период. Действует наивысший подходящий порог, скидки не суммируются.',
|
||||||
|
discountTierMinConfigs: 'От скольки конфигов',
|
||||||
|
discountTierPercent: 'Скидка, %',
|
||||||
|
addDiscountTier: 'Добавить ступень',
|
||||||
|
removeDiscountTier: 'Удалить',
|
||||||
|
discountTierInvalidMinConfigs: 'Порог — целое число конфигов, не меньше 1.',
|
||||||
|
discountTierInvalidPercent: 'Скидка — целое число от 1 до 99.',
|
||||||
|
discountTiersDuplicate: 'Пороги скидочной лесенки не должны повторяться.',
|
||||||
|
discountTiersNotProgressive: 'Скидка на более высоком пороге не может быть меньше скидки на более низком.',
|
||||||
},
|
},
|
||||||
billing: {
|
billing: {
|
||||||
settingsTitle: 'Настройки биллинга',
|
settingsTitle: 'Настройки биллинга',
|
||||||
@@ -715,6 +726,7 @@ const resources = {
|
|||||||
pricingHalfYear: '6 months: {{price}}',
|
pricingHalfYear: '6 months: {{price}}',
|
||||||
pricingYear: 'Year: {{price}}',
|
pricingYear: 'Year: {{price}}',
|
||||||
pricingDisclaimer: 'Prices are indicative only at this time.',
|
pricingDisclaimer: 'Prices are indicative only at this time.',
|
||||||
|
pricingDiscounted: '{{price}} ₽ (instead of {{original}} ₽, {{percent}}% off)',
|
||||||
justification: 'Justification',
|
justification: 'Justification',
|
||||||
ticketCreated: 'Ticket submitted.',
|
ticketCreated: 'Ticket submitted.',
|
||||||
roleRequestPending: 'You already have a pending role request.',
|
roleRequestPending: 'You already have a pending role request.',
|
||||||
@@ -867,6 +879,16 @@ const resources = {
|
|||||||
yearCheaperThanHalfYear: 'The annual price (over 12 months) cannot be lower than the 6-month price.',
|
yearCheaperThanHalfYear: 'The annual price (over 12 months) cannot be lower than the 6-month price.',
|
||||||
yearCheaperThanQuarter: 'The annual price (over 12 months) cannot be lower than the 3-month price.',
|
yearCheaperThanQuarter: 'The annual price (over 12 months) cannot be lower than the 3-month price.',
|
||||||
updated: 'Pricing updated.',
|
updated: 'Pricing updated.',
|
||||||
|
discountTiersTitle: 'Volume discount',
|
||||||
|
discountTiersHint: 'A role with a config quota at or above the threshold gets the listed discount off the period total. The highest applicable threshold wins — discounts do not stack.',
|
||||||
|
discountTierMinConfigs: 'From how many configs',
|
||||||
|
discountTierPercent: 'Discount, %',
|
||||||
|
addDiscountTier: 'Add tier',
|
||||||
|
removeDiscountTier: 'Remove',
|
||||||
|
discountTierInvalidMinConfigs: 'Threshold must be a whole number of configs, at least 1.',
|
||||||
|
discountTierInvalidPercent: 'Discount must be a whole number from 1 to 99.',
|
||||||
|
discountTiersDuplicate: 'Discount tier thresholds cannot repeat.',
|
||||||
|
discountTiersNotProgressive: 'A higher threshold cannot have a smaller discount than a lower one.',
|
||||||
},
|
},
|
||||||
billing: {
|
billing: {
|
||||||
settingsTitle: 'Billing settings',
|
settingsTitle: 'Billing settings',
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import type { DiscountTierDto } from '@/shared/api/types'
|
||||||
|
|
||||||
|
/** Зеркалит backend PricingDiscount.ResolvePercent — действует наивысший порог, квоте не
|
||||||
|
* превышающий (не суммируется с другими). 0, если тиров нет или квота ниже всех порогов. */
|
||||||
|
export function resolveDiscountPercent(tiers: DiscountTierDto[], maxConfigs: number): number {
|
||||||
|
const applicable = tiers.filter((t) => maxConfigs >= t.minConfigs).sort((a, b) => b.minConfigs - a.minConfigs)
|
||||||
|
return applicable[0]?.discountPercent ?? 0
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Зеркалит backend PricingDiscount.Apply — округление к ближайшему целому, .5 от нуля. */
|
||||||
|
export function applyDiscount(amount: number, discountPercent: number): number {
|
||||||
|
if (discountPercent <= 0) return amount
|
||||||
|
return Math.round((amount * (100 - discountPercent)) / 100)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user