diff --git a/backend/src/PnvPanel.Application/Admin/Pricing/GetPricingSettingsQueryHandler.cs b/backend/src/PnvPanel.Application/Admin/Pricing/GetPricingSettingsQueryHandler.cs index 7c7ede0..877c2a6 100644 --- a/backend/src/PnvPanel.Application/Admin/Pricing/GetPricingSettingsQueryHandler.cs +++ b/backend/src/PnvPanel.Application/Admin/Pricing/GetPricingSettingsQueryHandler.cs @@ -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)); } } diff --git a/backend/src/PnvPanel.Application/Admin/Pricing/UpdatePricingSettingsCommand.cs b/backend/src/PnvPanel.Application/Admin/Pricing/UpdatePricingSettingsCommand.cs index f2905c9..3be8123 100644 --- a/backend/src/PnvPanel.Application/Admin/Pricing/UpdatePricingSettingsCommand.cs +++ b/backend/src/PnvPanel.Application/Admin/Pricing/UpdatePricingSettingsCommand.cs @@ -7,5 +7,6 @@ namespace PnvPanel.Application.Admin.Pricing; public sealed record UpdatePricingSettingsCommand( int? PricePerConfigPerQuarter, int? PricePerConfigPerHalfYear, - int? PricePerConfigPerYear + int? PricePerConfigPerYear, + IReadOnlyList DiscountTiers ) : ICommand>; diff --git a/backend/src/PnvPanel.Application/Admin/Pricing/UpdatePricingSettingsCommandHandler.cs b/backend/src/PnvPanel.Application/Admin/Pricing/UpdatePricingSettingsCommandHandler.cs index c9a8e0e..f083a01 100644 --- a/backend/src/PnvPanel.Application/Admin/Pricing/UpdatePricingSettingsCommandHandler.cs +++ b/backend/src/PnvPanel.Application/Admin/Pricing/UpdatePricingSettingsCommandHandler.cs @@ -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)); } } diff --git a/backend/src/PnvPanel.Application/Admin/Pricing/UpdatePricingSettingsCommandValidator.cs b/backend/src/PnvPanel.Application/Admin/Pricing/UpdatePricingSettingsCommandValidator.cs index f07684c..bfec6b8 100644 --- a/backend/src/PnvPanel.Application/Admin/Pricing/UpdatePricingSettingsCommandValidator.cs +++ b/backend/src/PnvPanel.Application/Admin/Pricing/UpdatePricingSettingsCommandValidator.cs @@ -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("Скидка на более высоком пороге не может быть меньше скидки на более низком."); } } diff --git a/backend/src/PnvPanel.Application/Billing/CreatePaymentRequest/CreatePaymentRequestCommandHandler.cs b/backend/src/PnvPanel.Application/Billing/CreatePaymentRequest/CreatePaymentRequestCommandHandler.cs index e8f812c..f4da25a 100644 --- a/backend/src/PnvPanel.Application/Billing/CreatePaymentRequest/CreatePaymentRequestCommandHandler.cs +++ b/backend/src/PnvPanel.Application/Billing/CreatePaymentRequest/CreatePaymentRequestCommandHandler.cs @@ -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(BillingErrors.ActiveRequestExists); var pricing = await dbContext.PricingSettings.AsNoTracking().FirstOrDefaultAsync(cancellationToken); + if (pricing is null) + return Result.Failure(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(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); diff --git a/backend/src/PnvPanel.Application/Common/Interfaces/IAppDbContext.cs b/backend/src/PnvPanel.Application/Common/Interfaces/IAppDbContext.cs index 879d44e..56ba297 100644 --- a/backend/src/PnvPanel.Application/Common/Interfaces/IAppDbContext.cs +++ b/backend/src/PnvPanel.Application/Common/Interfaces/IAppDbContext.cs @@ -49,6 +49,8 @@ public interface IAppDbContext DbSet PricingSettings { get; } + DbSet PricingDiscountTiers { get; } + DbSet BillingSettings { get; } DbSet PaymentRequests { get; } diff --git a/backend/src/PnvPanel.Application/Common/Interfaces/PricingSettingsDto.cs b/backend/src/PnvPanel.Application/Common/Interfaces/PricingSettingsDto.cs index d313c9b..3f3963c 100644 --- a/backend/src/PnvPanel.Application/Common/Interfaces/PricingSettingsDto.cs +++ b/backend/src/PnvPanel.Application/Common/Interfaces/PricingSettingsDto.cs @@ -2,19 +2,31 @@ using PnvPanel.Domain.Pricing; namespace PnvPanel.Application.Common.Interfaces; -/// Все поля — цена за конфиг В МЕСЯЦ при соответствующем тарифе оплаты; итог за период = -/// ставка × число месяцев. Используется и Admin (редактирование), и Support (справка при заявке на -/// роль) — см. PricingSettingsDto.FromDomain. +/// Одна ступень скидочной лесенки за объём — см. PricingDiscount.ResolvePercent для алгоритма +/// выбора действующей ступени. +public sealed record DiscountTierDto(int MinConfigs, int DiscountPercent); + +/// Все ценовые поля — цена за конфиг В МЕСЯЦ при соответствующем тарифе; итог за период = +/// ставка × число месяцев, скидка (DiscountTiers) применяется к этому итогу по квоте роли. Используется +/// и Admin (редактирование), и Support (справка при заявке на роль) — см. PricingSettingsDto.FromDomain. public sealed record PricingSettingsDto( int? PricePerConfigPerQuarter, int? PricePerConfigPerHalfYear, - int? PricePerConfigPerYear + int? PricePerConfigPerYear, + IReadOnlyList DiscountTiers ) { - public static PricingSettingsDto FromDomain(PricingSettings settings) => + public static PricingSettingsDto FromDomain( + PricingSettings settings, + IReadOnlyList discountTiers + ) => new( settings.PricePerConfigPerQuarter, settings.PricePerConfigPerHalfYear, - settings.PricePerConfigPerYear + settings.PricePerConfigPerYear, + discountTiers + .OrderBy(t => t.MinConfigs) + .Select(t => new DiscountTierDto(t.MinConfigs, t.DiscountPercent)) + .ToList() ); } diff --git a/backend/src/PnvPanel.Application/Support/GetSupportPricing/GetSupportPricingQueryHandler.cs b/backend/src/PnvPanel.Application/Support/GetSupportPricing/GetSupportPricingQueryHandler.cs index 527646a..ae826af 100644 --- a/backend/src/PnvPanel.Application/Support/GetSupportPricing/GetSupportPricingQueryHandler.cs +++ b/backend/src/PnvPanel.Application/Support/GetSupportPricing/GetSupportPricingQueryHandler.cs @@ -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)); } } diff --git a/backend/src/PnvPanel.Domain/Pricing/PricingDiscount.cs b/backend/src/PnvPanel.Domain/Pricing/PricingDiscount.cs new file mode 100644 index 0000000..662166b --- /dev/null +++ b/backend/src/PnvPanel.Domain/Pricing/PricingDiscount.cs @@ -0,0 +1,29 @@ +namespace PnvPanel.Domain.Pricing; + +/// +/// Расчёт скидки по лесенке порогов — общий для реальной оплаты (CreatePaymentRequestCommandHandler) +/// и ознакомительной оценки (GetSupportPricingQuery/frontend). Фронт зеркалит тот же алгоритм для +/// превью цены до отправки запроса (см. PricingSettingsDto — итог за период по-прежнему считается +/// на фронте, здесь только процент скидки и применение его к уже посчитанной сумме). +/// +public static class PricingDiscount +{ + /// Действует наивысший порог, квоте не превышающий — например при 5%/3+ и 10%/6+ роль с + /// MaxConfigs=8 получает 10%, а не 5%+10%. 0, если тиров нет или квота ниже всех порогов. + public static int ResolvePercent(IEnumerable 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); + } +} diff --git a/backend/src/PnvPanel.Domain/Pricing/PricingDiscountTier.cs b/backend/src/PnvPanel.Domain/Pricing/PricingDiscountTier.cs new file mode 100644 index 0000000..58187c2 --- /dev/null +++ b/backend/src/PnvPanel.Domain/Pricing/PricingDiscountTier.cs @@ -0,0 +1,31 @@ +using PnvPanel.Domain.Common; + +namespace PnvPanel.Domain.Pricing; + +/// +/// Одна ступень скидочной лесенки за объём: роль с квотой `MaxConfigs >= MinConfigs` получает скидку +/// `DiscountPercent` от итоговой цены периода — стимул брать роль с большим числом конфигов разом. +/// Глобально, не привязано к конкретной роли (как и сам PricingSettings). Плоская таблица с FK на +/// PricingSettingsId, а не навигационная коллекция — см. конвенцию проекта (ср. TicketComment). +/// +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, + }; + } +} diff --git a/backend/src/PnvPanel.Infrastructure/Persistence/AppDbContext.cs b/backend/src/PnvPanel.Infrastructure/Persistence/AppDbContext.cs index f8a62e7..ba4431c 100644 --- a/backend/src/PnvPanel.Infrastructure/Persistence/AppDbContext.cs +++ b/backend/src/PnvPanel.Infrastructure/Persistence/AppDbContext.cs @@ -59,6 +59,8 @@ public class AppDbContext(DbContextOptions options) public DbSet PricingSettings => Set(); + public DbSet PricingDiscountTiers => Set(); + public DbSet BillingSettings => Set(); public DbSet PaymentRequests => Set(); diff --git a/backend/src/PnvPanel.Infrastructure/Persistence/Configurations/PricingDiscountTierConfiguration.cs b/backend/src/PnvPanel.Infrastructure/Persistence/Configurations/PricingDiscountTierConfiguration.cs new file mode 100644 index 0000000..1f7c576 --- /dev/null +++ b/backend/src/PnvPanel.Infrastructure/Persistence/Configurations/PricingDiscountTierConfiguration.cs @@ -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 +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("PricingDiscountTiers"); + builder.HasKey(x => x.Id); + + builder.HasIndex(x => new { x.PricingSettingsId, x.MinConfigs }).IsUnique(); + } +} diff --git a/backend/src/PnvPanel.Infrastructure/Persistence/Migrations/20260719124434_AddPricingDiscountTiers.Designer.cs b/backend/src/PnvPanel.Infrastructure/Persistence/Migrations/20260719124434_AddPricingDiscountTiers.Designer.cs new file mode 100644 index 0000000..0108749 --- /dev/null +++ b/backend/src/PnvPanel.Infrastructure/Persistence/Migrations/20260719124434_AddPricingDiscountTiers.Designer.cs @@ -0,0 +1,1069 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using PnvPanel.Infrastructure.Persistence; + +#nullable disable + +namespace PnvPanel.Infrastructure.Persistence.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20260719124434_AddPricingDiscountTiers")] + partial class AddPricingDiscountTiers + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.9") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("ProviderKey") + .HasColumnType("text"); + + b.Property("ProviderDisplayName") + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("Value") + .HasColumnType("text"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Domain.Activation.ActivationRequest", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Comment") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DecidedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DecidedBy") + .HasColumnType("uuid"); + + b.Property("RejectionReason") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "Status"); + + b.ToTable("ActivationRequests", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Domain.Apps.ClientApp", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Description") + .HasMaxLength(300) + .HasColumnType("character varying(300)"); + + b.Property("DownloadUrl") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("IconUrl") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("IsEnabled") + .HasColumnType("boolean"); + + b.Property("IsRecommended") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("OperatingSystem") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.ToTable("ClientApps", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Domain.Audit.AuditLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Action") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ActorId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Metadata") + .HasColumnType("jsonb"); + + b.Property("Source") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("TargetId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("TargetType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("CreatedAt"); + + b.ToTable("AuditLogs", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Domain.Billing.BillingSettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("DefaultBillingEnabledForNewRoles") + .HasColumnType("boolean"); + + b.Property("GraceDays") + .HasColumnType("integer"); + + b.Property("RequisitesText") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.ToTable("BillingSettings", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Domain.Billing.PaymentRequest", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AmountSnapshot") + .HasColumnType("integer"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DecidedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DecidedBy") + .HasColumnType("uuid"); + + b.Property("Period") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("RejectionReason") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "Status"); + + b.ToTable("PaymentRequests", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Domain.Configs.TrafficSample", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ConfigId") + .HasColumnType("uuid"); + + b.Property("DownBytes") + .HasColumnType("bigint"); + + b.Property("Timestamp") + .HasColumnType("timestamp with time zone"); + + b.Property("UpBytes") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("ConfigId", "Timestamp"); + + b.ToTable("TrafficSamples", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Domain.Configs.VpnConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ClientEmail") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ClientExternalId") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("InboundId") + .HasColumnType("uuid"); + + b.Property("Label") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("LastSyncAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Protocol") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("SubscriptionToken") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("UsedDownBytes") + .HasColumnType("bigint"); + + b.Property("UsedUpBytes") + .HasColumnType("bigint"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("InboundId"); + + b.HasIndex("SubscriptionToken") + .IsUnique(); + + b.HasIndex("UserId", "Status"); + + b.ToTable("VpnConfigs", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Domain.Inbounds.Inbound", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.PrimitiveCollection("AllowedRoleIds") + .IsRequired() + .HasColumnType("uuid[]"); + + b.Property("DisplayName") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("IsAvailable") + .HasColumnType("boolean"); + + b.Property("IsPublished") + .HasColumnType("boolean"); + + b.Property("LastSyncAt") + .HasColumnType("timestamp with time zone"); + + b.Property("NodeId") + .HasColumnType("uuid"); + + b.Property("Port") + .HasColumnType("integer"); + + b.Property("Protocol") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("Remark") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("RemoteInboundId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.HasKey("Id"); + + b.HasIndex("NodeId", "RemoteInboundId") + .IsUnique(); + + b.ToTable("Inbounds", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Domain.Instructions.InstructionIntro", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Body") + .IsRequired() + .HasMaxLength(20000) + .HasColumnType("character varying(20000)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.ToTable("InstructionIntros", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Domain.Instructions.InstructionTab", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Body") + .IsRequired() + .HasMaxLength(20000) + .HasColumnType("character varying(20000)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("SortOrder"); + + b.ToTable("InstructionTabs", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Domain.News.NewsPost", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Body") + .IsRequired() + .HasMaxLength(20000) + .HasColumnType("character varying(20000)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("CreatedAt"); + + b.ToTable("NewsPosts", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Domain.Nodes.Node", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("BaseAddress") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsEnabled") + .HasColumnType("boolean"); + + b.Property("LastSyncAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Location") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.HasKey("Id"); + + b.ToTable("Nodes", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Domain.Pricing.PricingDiscountTier", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("DiscountPercent") + .HasColumnType("integer"); + + b.Property("MinConfigs") + .HasColumnType("integer"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("PricePerConfigPerHalfYear") + .HasColumnType("integer"); + + b.Property("PricePerConfigPerQuarter") + .HasColumnType("integer"); + + b.Property("PricePerConfigPerYear") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.ToTable("PricingSettings", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Domain.Support.SupportTicket", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ProposedMaxConfigs") + .HasColumnType("integer"); + + b.Property("ProposedMaxIpLimit") + .HasColumnType("integer"); + + b.Property("ProposedRoleName") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("RequestedDays") + .HasColumnType("integer"); + + b.Property("RequestedRoleId") + .HasColumnType("uuid"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("Type", "Status"); + + b.HasIndex("UserId", "Status"); + + b.ToTable("SupportTickets", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Domain.Support.TicketAttachment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CommentId") + .HasColumnType("uuid"); + + b.Property("ContentType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("FileName") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("SizeBytes") + .HasColumnType("bigint"); + + b.Property("StoredFileName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("CommentId"); + + b.HasIndex("StoredFileName") + .IsUnique(); + + b.ToTable("TicketAttachments", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Domain.Support.TicketComment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuthorId") + .HasColumnType("uuid"); + + b.Property("Body") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("TicketId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TicketId", "CreatedAt"); + + b.ToTable("TicketComments", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Domain.Telegram.TelegramLinkToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConsumedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Token") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("Token") + .IsUnique(); + + b.ToTable("TelegramLinkTokens", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Domain.Telegram.TelegramLoginRequest", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Context") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.ToTable("TelegramLoginRequests", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Infrastructure.Identity.AppRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("BillingEnabled") + .HasColumnType("boolean"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("IsSystem") + .HasColumnType("boolean"); + + b.Property("MaxConfigs") + .HasColumnType("integer"); + + b.Property("MaxIpLimit") + .HasColumnType("integer"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Infrastructure.Identity.AppUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AccessFailedCount") + .HasColumnType("integer"); + + b.Property("ActivatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ActivatedBy") + .HasColumnType("uuid"); + + b.Property("BillingLastWarnedForPaidUntil") + .HasColumnType("timestamp with time zone"); + + b.Property("BillingPaidUntil") + .HasColumnType("timestamp with time zone"); + + b.Property("BillingSuspended") + .HasColumnType("boolean"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("boolean"); + + b.Property("IsActivated") + .HasColumnType("boolean"); + + b.Property("IsBlocked") + .HasColumnType("boolean"); + + b.Property("LockoutEnabled") + .HasColumnType("boolean"); + + b.Property("LockoutEnd") + .HasColumnType("timestamp with time zone"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("PasswordHash") + .HasColumnType("text"); + + b.Property("PhoneNumber") + .HasColumnType("text"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("boolean"); + + b.Property("SecurityStamp") + .HasColumnType("text"); + + b.Property("SubscriptionToken") + .IsRequired() + .HasColumnType("text"); + + b.Property("TelegramLinkedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("TelegramUserId") + .HasColumnType("bigint"); + + b.Property("TelegramUsername") + .HasColumnType("text"); + + b.Property("TwoFactorEnabled") + .HasColumnType("boolean"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.HasIndex("SubscriptionToken") + .IsUnique(); + + b.HasIndex("TelegramUserId") + .IsUnique(); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Infrastructure.Identity.RefreshToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ReplacedByTokenHash") + .HasColumnType("text"); + + b.Property("RevokedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TokenHash") + .IsUnique(); + + b.HasIndex("UserId"); + + b.ToTable("RefreshTokens", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("PnvPanel.Infrastructure.Identity.AppRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("PnvPanel.Infrastructure.Identity.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("PnvPanel.Infrastructure.Identity.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("PnvPanel.Infrastructure.Identity.AppRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("PnvPanel.Infrastructure.Identity.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("PnvPanel.Infrastructure.Identity.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("PnvPanel.Domain.Nodes.Node", b => + { + b.OwnsOne("PnvPanel.Domain.Nodes.NodeCredentials", "Credentials", b1 => + { + b1.Property("NodeId") + .HasColumnType("uuid"); + + b1.Property("ProtectedPassword") + .IsRequired() + .HasColumnType("text") + .HasColumnName("CredentialsProtectedPassword"); + + b1.Property("Username") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("CredentialsUsername"); + + b1.HasKey("NodeId"); + + b1.ToTable("Nodes"); + + b1.WithOwner() + .HasForeignKey("NodeId"); + }); + + b.Navigation("Credentials") + .IsRequired(); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/backend/src/PnvPanel.Infrastructure/Persistence/Migrations/20260719124434_AddPricingDiscountTiers.cs b/backend/src/PnvPanel.Infrastructure/Persistence/Migrations/20260719124434_AddPricingDiscountTiers.cs new file mode 100644 index 0000000..b113757 --- /dev/null +++ b/backend/src/PnvPanel.Infrastructure/Persistence/Migrations/20260719124434_AddPricingDiscountTiers.cs @@ -0,0 +1,42 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace PnvPanel.Infrastructure.Persistence.Migrations +{ + /// + public partial class AddPricingDiscountTiers : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "PricingDiscountTiers", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + PricingSettingsId = table.Column(type: "uuid", nullable: false), + MinConfigs = table.Column(type: "integer", nullable: false), + DiscountPercent = table.Column(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); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "PricingDiscountTiers"); + } + } +} diff --git a/backend/src/PnvPanel.Infrastructure/Persistence/Migrations/AppDbContextModelSnapshot.cs b/backend/src/PnvPanel.Infrastructure/Persistence/Migrations/AppDbContextModelSnapshot.cs index a038cfa..5b5ebbe 100644 --- a/backend/src/PnvPanel.Infrastructure/Persistence/Migrations/AppDbContextModelSnapshot.cs +++ b/backend/src/PnvPanel.Infrastructure/Persistence/Migrations/AppDbContextModelSnapshot.cs @@ -583,6 +583,29 @@ namespace PnvPanel.Infrastructure.Persistence.Migrations b.ToTable("Nodes", (string)null); }); + modelBuilder.Entity("PnvPanel.Domain.Pricing.PricingDiscountTier", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("DiscountPercent") + .HasColumnType("integer"); + + b.Property("MinConfigs") + .HasColumnType("integer"); + + b.Property("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("Id") diff --git a/backend/tests/PnvPanel.Application.Tests/Admin/Pricing/UpdatePricingSettingsCommandValidatorTests.cs b/backend/tests/PnvPanel.Application.Tests/Admin/Pricing/UpdatePricingSettingsCommandValidatorTests.cs new file mode 100644 index 0000000..7113969 --- /dev/null +++ b/backend/tests/PnvPanel.Application.Tests/Admin/Pricing/UpdatePricingSettingsCommandValidatorTests.cs @@ -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); + } +} diff --git a/backend/tests/PnvPanel.Application.Tests/Billing/CreatePaymentRequest/CreatePaymentRequestCommandHandlerTests.cs b/backend/tests/PnvPanel.Application.Tests/Billing/CreatePaymentRequest/CreatePaymentRequestCommandHandlerTests.cs index dab5cee..92a7bb1 100644 --- a/backend/tests/PnvPanel.Application.Tests/Billing/CreatePaymentRequest/CreatePaymentRequestCommandHandlerTests.cs +++ b/backend/tests/PnvPanel.Application.Tests/Billing/CreatePaymentRequest/CreatePaymentRequestCommandHandlerTests.cs @@ -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()) + .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() { diff --git a/backend/tests/PnvPanel.Domain.Tests/Pricing/PricingDiscountTests.cs b/backend/tests/PnvPanel.Domain.Tests/Pricing/PricingDiscountTests.cs new file mode 100644 index 0000000..e3a77bf --- /dev/null +++ b/backend/tests/PnvPanel.Domain.Tests/Pricing/PricingDiscountTests.cs @@ -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); + } +} diff --git a/docs/api-design.md b/docs/api-design.md index 88f4633..d6f864b 100644 --- a/docs/api-design.md +++ b/docs/api-design.md @@ -169,7 +169,7 @@ status, createdAt }`. `expiresAt` всегда `null` (лимиты по сро | Метод | Путь | Тело запроса | Тело ответа | | ----- | ----------------------------------------- | ---------------------------------------------------------------------------- | ------------- | | 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` (только свои) | | GET | `/api/support/tickets/{id}` | — | `TicketDetailDto` (404, если не свой) | | 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`) | | 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`, новая роль другая, и это единственный админ) | -| GET | `/api/admin/pricing` | admin | — | `PricingSettingsDto` (глобальная справочная цена за конфиг **в месяц**, одна на весь сервис — не per-роль) | -| PUT | `/api/admin/pricing` | admin | `{ pricePerConfigPerQuarter?, pricePerConfigPerHalfYear?, pricePerConfigPerYear? }` | `PricingSettingsDto` (`400`, если итог более длинного тарифа дешевле итога более короткого) | +| GET | `/api/admin/pricing` | admin | — | `PricingSettingsDto` (глобальная справочная цена за конфиг **в месяц** + скидочная лесенка `discountTiers: { minConfigs, discountPercent }[]`, одна на весь сервис — не per-роль) | +| PUT | `/api/admin/pricing` | admin | `{ pricePerConfigPerQuarter?, pricePerConfigPerHalfYear?, pricePerConfigPerYear?, discountTiers: { minConfigs, discountPercent }[] }` | `PricingSettingsDto` (`400`, если итог более длинного тарифа дешевле итога более короткого, либо `discountTiers` не уникальны/не прогрессивны — см. domain-model.md#pricingdiscounttier). `discountTiers` при сохранении полностью заменяет прежний набор | Нет отдельного эндпоинта «активировать напрямую без запроса» — активация только через approve/reject над `ActivationRequest`. diff --git a/docs/domain-model.md b/docs/domain-model.md index 35f0e70..ef610b1 100644 --- a/docs/domain-model.md +++ b/docs/domain-model.md @@ -335,8 +335,8 @@ UI **настойчиво напоминает** привязать его (ед - год = `PricePerConfigPerYear × 12 × MaxConfigs` Например, `user` с `MaxConfigs=3` и одинаковой ставкой 200₽/мес на всех трёх тарифах → 600₽/3мес, -1200₽/полгода, 2400₽/год (линейный рост, скидки нет). Для ролей с `MaxConfigs = -1` (unlimited, в -т.ч. `admin`) итог не считается — отображается как «не задано». +1200₽/полгода, 2400₽/год (линейный рост, скидки за тариф нет). Для ролей с `MaxConfigs = -1` +(unlimited, в т.ч. `admin`) итог не считается — отображается как «не задано». **Инвариант**: `UpdatePricingSettingsCommandValidator` не даёт сохранить более длинный тариф настолько дешёвым, что его итог окажется дешевле итога более короткого — иначе выгоднее купить длинный тариф и @@ -355,6 +355,38 @@ PricePerConfigPerQuarter × 3`). 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 — подписка по сроку Опциональная подсистема: включается per-роль (`AppRole.BillingEnabled`), недоступна для `admin`. @@ -393,7 +425,7 @@ Singleton (как `PricingSettings`) — реквизиты для оплаты | `Id` | `Guid` | PK | | `UserId` | `Guid` | FK → AppUser (заявитель) | | `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` | | `DecidedBy`/`DecidedAt`/`RejectionReason` | | Кто/когда решил, причина отказа (опционально) | | `CreatedAt` | `DateTimeOffset` | | diff --git a/frontend/src/features/admin/pricing/PricingSettingsEditor.tsx b/frontend/src/features/admin/pricing/PricingSettingsEditor.tsx index dd08032..a62d759 100644 --- a/frontend/src/features/admin/pricing/PricingSettingsEditor.tsx +++ b/frontend/src/features/admin/pricing/PricingSettingsEditor.tsx @@ -9,6 +9,8 @@ import { HttpError } from '@/shared/api/client' import type { PricingSettingsDto } from '@/shared/api/types' import { updatePricingSettings } from './api' +type TierRow = { minConfigs: string; discountPercent: string } + export function PricingSettingsEditor({ settings }: { settings: PricingSettingsDto }) { const { t } = useTranslation() const queryClient = useQueryClient() @@ -21,6 +23,31 @@ export function PricingSettingsEditor({ settings }: { settings: PricingSettingsD const [pricePerConfigPerYear, setPricePerConfigPerYear] = useState( settings.pricePerConfigPerYear != null ? String(settings.pricePerConfigPerYear) : '', ) + const [tiers, setTiers] = useState( + settings.discountTiers.map((t) => ({ minConfigs: String(t.minConfigs), discountPercent: String(t.discountPercent) })), + ) + + const updateTier = (index: number, patch: Partial) => + 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), pricePerConfigPerHalfYear === '' ? null : Number(pricePerConfigPerHalfYear), pricePerConfigPerYear === '' ? null : Number(pricePerConfigPerYear), + tiers.map((t) => ({ minConfigs: Number(t.minConfigs), discountPercent: Number(t.discountPercent) })), ), onSuccess: async () => { toast.success(t('admin.pricing.updated')) @@ -106,8 +134,40 @@ export function PricingSettingsEditor({ settings }: { settings: PricingSettingsD

{t('admin.pricing.yearCheaperThanQuarter')}

)} +
+ +

{t('admin.pricing.discountTiersHint')}

+ {tiers.map((tier, index) => ( +
+ updateTier(index, { minConfigs: e.target.value })} + /> + updateTier(index, { discountPercent: e.target.value })} + /> + +
+ ))} + {tierErrors &&

{tierErrors}

} +
+ +
+
-
diff --git a/frontend/src/features/admin/pricing/api.ts b/frontend/src/features/admin/pricing/api.ts index 642d72a..d09cfe9 100644 --- a/frontend/src/features/admin/pricing/api.ts +++ b/frontend/src/features/admin/pricing/api.ts @@ -1,5 +1,5 @@ import { apiRequest } from '@/shared/api/client' -import type { PricingSettingsDto } from '@/shared/api/types' +import type { DiscountTierDto, PricingSettingsDto } from '@/shared/api/types' export function getPricingSettings() { return apiRequest('/admin/pricing') @@ -9,9 +9,10 @@ export function updatePricingSettings( pricePerConfigPerQuarter: number | null, pricePerConfigPerHalfYear: number | null, pricePerConfigPerYear: number | null, + discountTiers: DiscountTierDto[], ) { return apiRequest('/admin/pricing', { method: 'PUT', - body: { pricePerConfigPerQuarter, pricePerConfigPerHalfYear, pricePerConfigPerYear }, + body: { pricePerConfigPerQuarter, pricePerConfigPerHalfYear, pricePerConfigPerYear, discountTiers }, }) } diff --git a/frontend/src/features/support/CreateRoleRequestDialog.tsx b/frontend/src/features/support/CreateRoleRequestDialog.tsx index 39fcac0..80eec63 100644 --- a/frontend/src/features/support/CreateRoleRequestDialog.tsx +++ b/frontend/src/features/support/CreateRoleRequestDialog.tsx @@ -9,6 +9,7 @@ import { Label } from '@/shared/ui/label' import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/shared/ui/dialog' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select' import { HttpError } from '@/shared/api/client' +import { applyDiscount, resolveDiscountPercent } from '@/shared/lib/pricing' import { createRoleRequestTicket, getSupportPricing, listSelectableRoles } from './api' type Mode = 'existing' | 'new' @@ -80,11 +81,19 @@ export function CreateRoleRequestDialog() { ? Number(newRoleMaxConfigs) : undefined - // Ставки — цена за конфиг В МЕСЯЦ; итог за период = ставка × месяцев × квота. - const totalPrice = (monthlyRate: number | null | undefined, months: number) => - monthlyRate == null || maxConfigsForPricing == null || maxConfigsForPricing < 0 - ? t('admin.roles.noPrice') - : `${monthlyRate * months * maxConfigsForPricing} ₽` + // Ставки — цена за конфиг В МЕСЯЦ; итог за период = ставка × месяцев × квота, затем скидка по + // лесенке (см. shared/lib/pricing) — за роль с большей квотой конфигов та же лесенка, что в + // admin/roles.tsx и на реальной оплате (CreatePaymentRequestCommandHandler). + const totalPrice = (monthlyRate: number | null | undefined, months: number) => { + 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 diff --git a/frontend/src/routes/admin/roles.tsx b/frontend/src/routes/admin/roles.tsx index bcc0a22..fef20a5 100644 --- a/frontend/src/routes/admin/roles.tsx +++ b/frontend/src/routes/admin/roles.tsx @@ -8,6 +8,7 @@ import { Badge } from '@/shared/ui/badge' import { listRoles, deleteRole } from '@/features/admin/roles/api' import { RoleFormDialog } from '@/features/admin/roles/RoleFormDialog' import { getPricingSettings } from '@/features/admin/pricing/api' +import { applyDiscount, resolveDiscountPercent } from '@/shared/lib/pricing' import type { RoleDto } from '@/shared/api/types' 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: pricing } = useQuery({ queryKey: ['admin-pricing'], queryFn: getPricingSettings }) - // Ставки — цена за конфиг В МЕСЯЦ при данном тарифе; итог за период = ставка × месяцев × квота. - const totalPrice = (monthlyRate: number | null | undefined, maxConfigs: number, months: number) => - monthlyRate == null || maxConfigs < 0 ? t('admin.roles.noPrice') : `${monthlyRate * months * maxConfigs} ₽` + // Ставки — цена за конфиг В МЕСЯЦ при данном тарифе; итог за период = ставка × месяцев × квота, + // затем скидка по лесенке (см. shared/lib/pricing) — за роль с большей квотой конфигов. + const priceCell = (monthlyRate: number | null | undefined, maxConfigs: number, months: number) => { + if (monthlyRate == null || maxConfigs < 0) return {t('admin.roles.noPrice')} + + const original = monthlyRate * months * maxConfigs + const percent = resolveDiscountPercent(pricing?.discountTiers ?? [], maxConfigs) + if (percent <= 0) return {original} ₽ + + const discounted = applyDiscount(original, percent) + return ( + + {original} ₽ + + {discounted} ₽ + -{percent}% + + + ) + } const deleteMutation = useMutation({ mutationFn: deleteRole, @@ -74,9 +92,9 @@ function AdminRolesPage() { {role.maxConfigs < 0 ? t('unlimited') : role.maxConfigs} {role.maxIpLimit < 0 ? t('unlimited') : role.maxIpLimit} - {totalPrice(pricing?.pricePerConfigPerQuarter, role.maxConfigs, 3)} - {totalPrice(pricing?.pricePerConfigPerHalfYear, role.maxConfigs, 6)} - {totalPrice(pricing?.pricePerConfigPerYear, role.maxConfigs, 12)} + {priceCell(pricing?.pricePerConfigPerQuarter, role.maxConfigs, 3)} + {priceCell(pricing?.pricePerConfigPerHalfYear, role.maxConfigs, 6)} + {priceCell(pricing?.pricePerConfigPerYear, role.maxConfigs, 12)}