Implement discount tiers for pricing settings and enhance related functionalities
CI / Backend (build + test) (push) Successful in 1m20s
CI / Frontend (lint + typecheck + build) (push) Successful in 32s

- 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:
Leonid Pershin
2026-07-19 15:51:01 +03:00
parent 6a2d2d2318
commit 0dcaf1203f
27 changed files with 1645 additions and 43 deletions
@@ -18,10 +18,14 @@ public sealed class GetPricingSettingsQueryHandler(IAppDbContext dbContext)
.FirstOrDefaultAsync(cancellationToken);
// Ещё не сидировано/не сохранено ни разу — цена не задана, а не ошибка.
return Result.Success(
settings is null
? new PricingSettingsDto(null, null, null)
: PricingSettingsDto.FromDomain(settings)
);
if (settings is null)
return Result.Success(new PricingSettingsDto(null, null, null, []));
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(
int? PricePerConfigPerQuarter,
int? PricePerConfigPerHalfYear,
int? PricePerConfigPerYear
int? PricePerConfigPerYear,
IReadOnlyList<DiscountTierDto> DiscountTiers
) : ICommand<Result<PricingSettingsDto>>;
@@ -27,6 +27,16 @@ public sealed class UpdatePricingSettingsCommandHandler(IAppDbContext dbContext)
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));
}
}
@@ -55,5 +55,30 @@ public sealed class UpdatePricingSettingsCommandValidator
&& x.PricePerConfigPerQuarter.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("Скидка на более высоком пороге не может быть меньше скидки на более низком.");
}
}
@@ -4,6 +4,7 @@ using PnvPanel.Application.Common.Interfaces;
using PnvPanel.Application.Common.Messaging;
using PnvPanel.Application.Common.Models;
using PnvPanel.Domain.Billing;
using PnvPanel.Domain.Pricing;
namespace PnvPanel.Application.Billing.CreatePaymentRequest;
@@ -44,17 +45,26 @@ public sealed class CreatePaymentRequestCommandHandler(
return Result.Failure<PaymentRequestDto>(BillingErrors.ActiveRequestExists);
var pricing = await dbContext.PricingSettings.AsNoTracking().FirstOrDefaultAsync(cancellationToken);
if (pricing is null)
return Result.Failure<PaymentRequestDto>(BillingErrors.PricingNotConfigured);
var ratePerMonth = command.Period switch
{
PaymentPeriod.Quarter => pricing?.PricePerConfigPerQuarter,
PaymentPeriod.HalfYear => pricing?.PricePerConfigPerHalfYear,
PaymentPeriod.Year => pricing?.PricePerConfigPerYear,
PaymentPeriod.Quarter => pricing.PricePerConfigPerQuarter,
PaymentPeriod.HalfYear => pricing.PricePerConfigPerHalfYear,
PaymentPeriod.Year => pricing.PricePerConfigPerYear,
_ => null,
};
if (ratePerMonth is not { } rate)
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);
dbContext.PaymentRequests.Add(request);
@@ -49,6 +49,8 @@ public interface IAppDbContext
DbSet<PricingSettings> PricingSettings { get; }
DbSet<PricingDiscountTier> PricingDiscountTiers { get; }
DbSet<BillingSettings> BillingSettings { get; }
DbSet<PaymentRequest> PaymentRequests { get; }
@@ -2,19 +2,31 @@ using PnvPanel.Domain.Pricing;
namespace PnvPanel.Application.Common.Interfaces;
/// <summary>Все поля — цена за конфиг В МЕСЯЦ при соответствующем тарифе оплаты; итог за период =
/// ставка × число месяцев. Используется и Admin (редактирование), и Support (справка при заявке на
/// роль) — см. PricingSettingsDto.FromDomain.</summary>
/// <summary>Одна ступень скидочной лесенки за объём — см. PricingDiscount.ResolvePercent для алгоритма
/// выбора действующей ступени.</summary>
public sealed record DiscountTierDto(int MinConfigs, int DiscountPercent);
/// <summary>Все ценовые поля — цена за конфиг В МЕСЯЦ при соответствующем тарифе; итог за период =
/// ставка × число месяцев, скидка (DiscountTiers) применяется к этому итогу по квоте роли. Используется
/// и Admin (редактирование), и Support (справка при заявке на роль) — см. PricingSettingsDto.FromDomain.</summary>
public sealed record PricingSettingsDto(
int? PricePerConfigPerQuarter,
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(
settings.PricePerConfigPerQuarter,
settings.PricePerConfigPerHalfYear,
settings.PricePerConfigPerYear
settings.PricePerConfigPerYear,
discountTiers
.OrderBy(t => t.MinConfigs)
.Select(t => new DiscountTierDto(t.MinConfigs, t.DiscountPercent))
.ToList()
);
}
@@ -18,10 +18,14 @@ public sealed class GetSupportPricingQueryHandler(IAppDbContext dbContext)
.FirstOrDefaultAsync(cancellationToken);
// Ещё не сидировано/не сохранено ни разу — цена не задана, а не ошибка.
return Result.Success(
settings is null
? new PricingSettingsDto(null, null, null)
: PricingSettingsDto.FromDomain(settings)
);
if (settings is null)
return Result.Success(new PricingSettingsDto(null, null, null, []));
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<PricingDiscountTier> PricingDiscountTiers => Set<PricingDiscountTier>();
public DbSet<BillingSettings> BillingSettings => Set<BillingSettings>();
public DbSet<PaymentRequest> PaymentRequests => Set<PaymentRequest>();
@@ -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();
}
}
@@ -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");
}
}
}
@@ -583,6 +583,29 @@ namespace PnvPanel.Infrastructure.Persistence.Migrations
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 =>
{
b.Property<Guid>("Id")
@@ -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);
}
}
@@ -65,6 +65,38 @@ public class CreatePaymentRequestCommandHandlerTests
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]
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);
}
}