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);
|
||||
|
||||
// Ещё не сидировано/не сохранено ни разу — цена не задана, а не ошибка.
|
||||
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>>;
|
||||
|
||||
+11
-1
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
+25
@@ -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("Скидка на более высоком пороге не может быть меньше скидки на более низком.");
|
||||
}
|
||||
}
|
||||
|
||||
+14
-4
@@ -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()
|
||||
);
|
||||
}
|
||||
|
||||
+9
-5
@@ -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>();
|
||||
|
||||
+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);
|
||||
});
|
||||
|
||||
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")
|
||||
|
||||
+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);
|
||||
}
|
||||
|
||||
[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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user