diff --git a/backend/src/PnvPanel.Application/Admin/Pricing/GetPricingSettingsQueryHandler.cs b/backend/src/PnvPanel.Application/Admin/Pricing/GetPricingSettingsQueryHandler.cs index fa95b6b..7c7ede0 100644 --- a/backend/src/PnvPanel.Application/Admin/Pricing/GetPricingSettingsQueryHandler.cs +++ b/backend/src/PnvPanel.Application/Admin/Pricing/GetPricingSettingsQueryHandler.cs @@ -20,7 +20,7 @@ public sealed class GetPricingSettingsQueryHandler(IAppDbContext dbContext) // Ещё не сидировано/не сохранено ни разу — цена не задана, а не ошибка. return Result.Success( settings is null - ? new PricingSettingsDto(null, null) + ? new PricingSettingsDto(null, null, null) : PricingSettingsDto.FromDomain(settings) ); } diff --git a/backend/src/PnvPanel.Application/Admin/Pricing/PricingSettingsDto.cs b/backend/src/PnvPanel.Application/Admin/Pricing/PricingSettingsDto.cs index e13f387..0e989a9 100644 --- a/backend/src/PnvPanel.Application/Admin/Pricing/PricingSettingsDto.cs +++ b/backend/src/PnvPanel.Application/Admin/Pricing/PricingSettingsDto.cs @@ -2,8 +2,16 @@ using PnvPanel.Domain.Pricing; namespace PnvPanel.Application.Admin.Pricing; -public sealed record PricingSettingsDto(int? PricePerConfigPerQuarter, int? PricePerConfigPerYear) +public sealed record PricingSettingsDto( + int? PricePerConfigPerQuarter, + int? PricePerConfigPerHalfYear, + int? PricePerConfigPerYear +) { public static PricingSettingsDto FromDomain(PricingSettings settings) => - new(settings.PricePerConfigPerQuarter, settings.PricePerConfigPerYear); + new( + settings.PricePerConfigPerQuarter, + settings.PricePerConfigPerHalfYear, + settings.PricePerConfigPerYear + ); } diff --git a/backend/src/PnvPanel.Application/Admin/Pricing/UpdatePricingSettingsCommand.cs b/backend/src/PnvPanel.Application/Admin/Pricing/UpdatePricingSettingsCommand.cs index e11fcba..81e36c8 100644 --- a/backend/src/PnvPanel.Application/Admin/Pricing/UpdatePricingSettingsCommand.cs +++ b/backend/src/PnvPanel.Application/Admin/Pricing/UpdatePricingSettingsCommand.cs @@ -5,5 +5,6 @@ namespace PnvPanel.Application.Admin.Pricing; public sealed record UpdatePricingSettingsCommand( int? PricePerConfigPerQuarter, + int? PricePerConfigPerHalfYear, int? PricePerConfigPerYear ) : ICommand>; diff --git a/backend/src/PnvPanel.Application/Admin/Pricing/UpdatePricingSettingsCommandHandler.cs b/backend/src/PnvPanel.Application/Admin/Pricing/UpdatePricingSettingsCommandHandler.cs index 6bc3197..c9a8e0e 100644 --- a/backend/src/PnvPanel.Application/Admin/Pricing/UpdatePricingSettingsCommandHandler.cs +++ b/backend/src/PnvPanel.Application/Admin/Pricing/UpdatePricingSettingsCommandHandler.cs @@ -21,7 +21,11 @@ public sealed class UpdatePricingSettingsCommandHandler(IAppDbContext dbContext) dbContext.PricingSettings.Add(settings); } - settings.Update(command.PricePerConfigPerQuarter, command.PricePerConfigPerYear); + settings.Update( + command.PricePerConfigPerQuarter, + command.PricePerConfigPerHalfYear, + command.PricePerConfigPerYear + ); return Result.Success(PricingSettingsDto.FromDomain(settings)); } diff --git a/backend/src/PnvPanel.Application/Admin/Pricing/UpdatePricingSettingsCommandValidator.cs b/backend/src/PnvPanel.Application/Admin/Pricing/UpdatePricingSettingsCommandValidator.cs index 47f23a5..f07684c 100644 --- a/backend/src/PnvPanel.Application/Admin/Pricing/UpdatePricingSettingsCommandValidator.cs +++ b/backend/src/PnvPanel.Application/Admin/Pricing/UpdatePricingSettingsCommandValidator.cs @@ -10,14 +10,38 @@ public sealed class UpdatePricingSettingsCommandValidator RuleFor(x => x.PricePerConfigPerQuarter!.Value) .GreaterThanOrEqualTo(0) .When(x => x.PricePerConfigPerQuarter.HasValue); + RuleFor(x => x.PricePerConfigPerHalfYear!.Value) + .GreaterThanOrEqualTo(0) + .When(x => x.PricePerConfigPerHalfYear.HasValue); RuleFor(x => x.PricePerConfigPerYear!.Value) .GreaterThanOrEqualTo(0) .When(x => x.PricePerConfigPerYear.HasValue); - // Обе ставки — цена за конфиг В МЕСЯЦ при соответствующем тарифе оплаты; итог за период = - // ставка × число месяцев. Годовая ставка может быть ниже квартальной (скидка за годовую - // оплату), но итог за год (ставка×12) не должен быть дешевле итога за квартал (ставка×3) — - // иначе выгоднее купить "год" и не продлевать, чем платить за квартал. + // Все три ставки — цена за конфиг В МЕСЯЦ при соответствующем тарифе оплаты; итог за период = + // ставка × число месяцев. Более длинный период может быть дешевле "в пересчёте на месяц" + // (скидка за предоплату), но итог за него не должен быть дешевле итога за более короткий + // период — иначе выгоднее купить длинный тариф и не продлевать, чем платить за короткий. + RuleFor(x => x.PricePerConfigPerHalfYear) + .Must( + (command, halfYear) => + !halfYear.HasValue + || !command.PricePerConfigPerQuarter.HasValue + || (decimal)halfYear.Value * 6 >= (decimal)command.PricePerConfigPerQuarter.Value * 3 + ) + .WithMessage("Цена за полгода (в пересчёте на 6 месяцев) не может быть меньше цены за 3 месяца.") + .When(x => x.PricePerConfigPerQuarter.HasValue && x.PricePerConfigPerHalfYear.HasValue); + + RuleFor(x => x.PricePerConfigPerYear) + .Must( + (command, year) => + !year.HasValue + || !command.PricePerConfigPerHalfYear.HasValue + || (decimal)year.Value * 12 >= (decimal)command.PricePerConfigPerHalfYear.Value * 6 + ) + .WithMessage("Цена за год (в пересчёте на 12 месяцев) не может быть меньше цены за полгода.") + .When(x => x.PricePerConfigPerHalfYear.HasValue && x.PricePerConfigPerYear.HasValue); + + // Если полугодовая ставка не задана, год всё равно не должен быть дешевле квартала. RuleFor(x => x.PricePerConfigPerYear) .Must( (command, year) => @@ -26,6 +50,10 @@ public sealed class UpdatePricingSettingsCommandValidator || (decimal)year.Value * 12 >= (decimal)command.PricePerConfigPerQuarter.Value * 3 ) .WithMessage("Цена за год (в пересчёте на 12 месяцев) не может быть меньше цены за 3 месяца.") - .When(x => x.PricePerConfigPerQuarter.HasValue && x.PricePerConfigPerYear.HasValue); + .When(x => + !x.PricePerConfigPerHalfYear.HasValue + && x.PricePerConfigPerQuarter.HasValue + && x.PricePerConfigPerYear.HasValue + ); } } diff --git a/backend/src/PnvPanel.Domain/Pricing/PricingSettings.cs b/backend/src/PnvPanel.Domain/Pricing/PricingSettings.cs index eb9ac2a..c2caf54 100644 --- a/backend/src/PnvPanel.Domain/Pricing/PricingSettings.cs +++ b/backend/src/PnvPanel.Domain/Pricing/PricingSettings.cs @@ -12,9 +12,14 @@ public sealed class PricingSettings : Entity /// Цена за конфиг В МЕСЯЦ при оплате раз в 3 месяца (квартальный тариф). public int? PricePerConfigPerQuarter { get; private set; } + /// Цена за конфиг В МЕСЯЦ при оплате раз в полгода. Может быть ниже квартальной ставки + /// (скидка за оплату на полгода вперёд), но не настолько, чтобы итог за полгода (×6) оказался + /// дешевле итога за квартал (×3) — см. UpdatePricingSettingsCommandValidator. + public int? PricePerConfigPerHalfYear { get; private set; } + /// Цена за конфиг В МЕСЯЦ при оплате раз в год (годовой тариф). Может быть ниже - /// квартальной ставки (скидка за годовую оплату), но не настолько, чтобы итог за год (×12) - /// оказался дешевле итога за квартал (×3) — см. UpdatePricingSettingsCommandValidator. + /// полугодовой ставки (скидка за годовую оплату), но не настолько, чтобы итог за год (×12) + /// оказался дешевле итога за полгода (×6) — см. UpdatePricingSettingsCommandValidator. public int? PricePerConfigPerYear { get; private set; } public DateTimeOffset UpdatedAt { get; private set; } @@ -26,9 +31,14 @@ public sealed class PricingSettings : Entity return new PricingSettings { Id = Guid.NewGuid(), UpdatedAt = DateTimeOffset.UtcNow }; } - public void Update(int? pricePerConfigPerQuarter, int? pricePerConfigPerYear) + public void Update( + int? pricePerConfigPerQuarter, + int? pricePerConfigPerHalfYear, + int? pricePerConfigPerYear + ) { PricePerConfigPerQuarter = pricePerConfigPerQuarter; + PricePerConfigPerHalfYear = pricePerConfigPerHalfYear; PricePerConfigPerYear = pricePerConfigPerYear; UpdatedAt = DateTimeOffset.UtcNow; } diff --git a/backend/src/PnvPanel.Infrastructure/Persistence/Migrations/20260718171109_AddPricingHalfYear.Designer.cs b/backend/src/PnvPanel.Infrastructure/Persistence/Migrations/20260718171109_AddPricingHalfYear.Designer.cs new file mode 100644 index 0000000..bb4457a --- /dev/null +++ b/backend/src/PnvPanel.Infrastructure/Persistence/Migrations/20260718171109_AddPricingHalfYear.Designer.cs @@ -0,0 +1,961 @@ +// +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("20260718171109_AddPricingHalfYear")] + partial class AddPricingHalfYear + { + /// + 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.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("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.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("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("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("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/20260718171109_AddPricingHalfYear.cs b/backend/src/PnvPanel.Infrastructure/Persistence/Migrations/20260718171109_AddPricingHalfYear.cs new file mode 100644 index 0000000..a549213 --- /dev/null +++ b/backend/src/PnvPanel.Infrastructure/Persistence/Migrations/20260718171109_AddPricingHalfYear.cs @@ -0,0 +1,28 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace PnvPanel.Infrastructure.Persistence.Migrations +{ + /// + public partial class AddPricingHalfYear : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "PricePerConfigPerHalfYear", + table: "PricingSettings", + type: "integer", + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "PricePerConfigPerHalfYear", + table: "PricingSettings"); + } + } +} diff --git a/backend/src/PnvPanel.Infrastructure/Persistence/Migrations/AppDbContextModelSnapshot.cs b/backend/src/PnvPanel.Infrastructure/Persistence/Migrations/AppDbContextModelSnapshot.cs index a3a9f1b..c7667f4 100644 --- a/backend/src/PnvPanel.Infrastructure/Persistence/Migrations/AppDbContextModelSnapshot.cs +++ b/backend/src/PnvPanel.Infrastructure/Persistence/Migrations/AppDbContextModelSnapshot.cs @@ -519,6 +519,9 @@ namespace PnvPanel.Infrastructure.Persistence.Migrations .ValueGeneratedOnAdd() .HasColumnType("uuid"); + b.Property("PricePerConfigPerHalfYear") + .HasColumnType("integer"); + b.Property("PricePerConfigPerQuarter") .HasColumnType("integer"); diff --git a/docs/api-design.md b/docs/api-design.md index 33be0cc..374b08f 100644 --- a/docs/api-design.md +++ b/docs/api-design.md @@ -249,7 +249,7 @@ reject/approve владением тикета не ограничены. Еди | 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?, pricePerConfigPerYear? }` | `PricingSettingsDto` (`409`/`400`, если годовая ставка×12 дешевле квартальной×3) | +| PUT | `/api/admin/pricing` | admin | `{ pricePerConfigPerQuarter?, pricePerConfigPerHalfYear?, pricePerConfigPerYear? }` | `PricingSettingsDto` (`400`, если итог более длинного тарифа дешевле итога более короткого) | Нет отдельного эндпоинта «активировать напрямую без запроса» — активация только через approve/reject над `ActivationRequest`. diff --git a/docs/domain-model.md b/docs/domain-model.md index ef76079..3d5d032 100644 --- a/docs/domain-model.md +++ b/docs/domain-model.md @@ -302,28 +302,31 @@ UI **настойчиво напоминает** привязать его (ед к роли: одна цена на весь сервис. Не биллинг — без статусов оплаты, дат окончания, интеграций с платёжными системами. -| Поле | Тип | Заметки | -| --------------------------- | ----------------- | --------------------------------------------------- | -| `Id` | `Guid` | PK | -| `PricePerConfigPerQuarter` | `int?` | Цена за конфиг **в месяц** при оплате раз в 3 месяца (минимальный период), руб. | -| `PricePerConfigPerYear` | `int?` | Цена за конфиг **в месяц** при оплате раз в год, руб. Может быть ниже квартальной (скидка за годовую оплату) | -| `UpdatedAt` | `DateTimeOffset` | | +| Поле | Тип | Заметки | +| ---------------------------- | ----------------- | --------------------------------------------------- | +| `Id` | `Guid` | PK | +| `PricePerConfigPerQuarter` | `int?` | Цена за конфиг **в месяц** при оплате раз в 3 месяца (минимальный период), руб. | +| `PricePerConfigPerHalfYear` | `int?` | Цена за конфиг **в месяц** при оплате раз в полгода, руб. Может быть ниже квартальной (скидка за оплату на полгода вперёд) | +| `PricePerConfigPerYear` | `int?` | Цена за конфиг **в месяц** при оплате раз в год, руб. Может быть ниже полугодовой (скидка за годовую оплату) | +| `UpdatedAt` | `DateTimeOffset` | | -Оба поля — ставка **за месяц**, не за весь период целиком. Итог за период = `ставка × число_месяцев × -AppRole.MaxConfigs`, считается на фронте (таблица ролей в админке), нигде не хранится: +Все три поля — ставка **за месяц**, не за весь период целиком. Итог за период = `ставка × +число_месяцев × AppRole.MaxConfigs`, считается на фронте (таблица ролей в админке), нигде не +хранится: - 3 месяца = `PricePerConfigPerQuarter × 3 × MaxConfigs` -- полгода = `PricePerConfigPerQuarter × 6 × MaxConfigs` (используется квартальная ставка — отдельного - полугодового тарифа нет, это просто два квартальных периода подряд) +- полгода = `PricePerConfigPerHalfYear × 6 × MaxConfigs` - год = `PricePerConfigPerYear × 12 × MaxConfigs` -Например, `user` с `MaxConfigs=3`, ставка 200₽/мес по квартальному тарифу → 600₽/3мес, 1200₽/полгода; -при равной годовой ставке (200₽/мес) → 2400₽/год (линейный рост, скидки нет). Для ролей с -`MaxConfigs = -1` (unlimited, в т.ч. `admin`) итог не считается — отображается как «не задано». +Например, `user` с `MaxConfigs=3` и одинаковой ставкой 200₽/мес на всех трёх тарифах → 600₽/3мес, +1200₽/полгода, 2400₽/год (линейный рост, скидки нет). Для ролей с `MaxConfigs = -1` (unlimited, в +т.ч. `admin`) итог не считается — отображается как «не задано». -**Инвариант**: `UpdatePricingSettingsCommandValidator` не даёт сохранить годовую ставку настолько -низкой, что итог за год (`×12`) окажется дешевле итога за квартал (`×3`) — иначе выгоднее купить «год» -и не продлевать, чем платить за квартал. Формально: `PricePerConfigPerYear × 12 ≥ -PricePerConfigPerQuarter × 3`. +**Инвариант**: `UpdatePricingSettingsCommandValidator` не даёт сохранить более длинный тариф настолько +дешёвым, что его итог окажется дешевле итога более короткого — иначе выгоднее купить длинный тариф и +не продлевать, чем платить за короткий. Формально: `PricePerConfigPerHalfYear × 6 ≥ +PricePerConfigPerQuarter × 3` и `PricePerConfigPerYear × 12 ≥ PricePerConfigPerHalfYear × 6` (если +полугодовая ставка не задана — год сверяется напрямую с кварталом: `PricePerConfigPerYear × 12 ≥ +PricePerConfigPerQuarter × 3`). `GET/PUT /api/admin/pricing` — только `admin` (в отличие от `RoleDto`, цена никогда не попадает в `GET /api/support/roles`, доступный любому активированному пользователю, — это два независимых DTO). diff --git a/frontend/src/features/admin/pricing/PricingSettingsEditor.tsx b/frontend/src/features/admin/pricing/PricingSettingsEditor.tsx index 0823bae..dd08032 100644 --- a/frontend/src/features/admin/pricing/PricingSettingsEditor.tsx +++ b/frontend/src/features/admin/pricing/PricingSettingsEditor.tsx @@ -15,21 +15,38 @@ export function PricingSettingsEditor({ settings }: { settings: PricingSettingsD const [pricePerConfigPerQuarter, setPricePerConfigPerQuarter] = useState( settings.pricePerConfigPerQuarter != null ? String(settings.pricePerConfigPerQuarter) : '', ) + const [pricePerConfigPerHalfYear, setPricePerConfigPerHalfYear] = useState( + settings.pricePerConfigPerHalfYear != null ? String(settings.pricePerConfigPerHalfYear) : '', + ) const [pricePerConfigPerYear, setPricePerConfigPerYear] = useState( settings.pricePerConfigPerYear != null ? String(settings.pricePerConfigPerYear) : '', ) - // Обе ставки — цена за конфиг в месяц; итог за год (ставка×12) не должен быть дешевле итога за - // квартал (ставка×3), иначе выгоднее купить "год" и не продлевать, чем платить за квартал. + // Все ставки — цена за конфиг в месяц; итог за более длинный период (ставка × месяцев) не должен + // быть дешевле итога за более короткий, иначе выгоднее купить длинный тариф и не продлевать. + const isHalfYearCheaperThanQuarter = + pricePerConfigPerQuarter !== '' && + pricePerConfigPerHalfYear !== '' && + Number(pricePerConfigPerHalfYear) * 6 < Number(pricePerConfigPerQuarter) * 3 + + const isYearCheaperThanHalfYear = + pricePerConfigPerHalfYear !== '' && + pricePerConfigPerYear !== '' && + Number(pricePerConfigPerYear) * 12 < Number(pricePerConfigPerHalfYear) * 6 + const isYearCheaperThanQuarter = + pricePerConfigPerHalfYear === '' && pricePerConfigPerQuarter !== '' && pricePerConfigPerYear !== '' && Number(pricePerConfigPerYear) * 12 < Number(pricePerConfigPerQuarter) * 3 + const hasInvalidCombo = isHalfYearCheaperThanQuarter || isYearCheaperThanHalfYear || isYearCheaperThanQuarter + const mutation = useMutation({ mutationFn: () => updatePricingSettings( pricePerConfigPerQuarter === '' ? null : Number(pricePerConfigPerQuarter), + pricePerConfigPerHalfYear === '' ? null : Number(pricePerConfigPerHalfYear), pricePerConfigPerYear === '' ? null : Number(pricePerConfigPerYear), ), onSuccess: async () => { @@ -58,6 +75,20 @@ export function PricingSettingsEditor({ settings }: { settings: PricingSettingsD />

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

+
+ + setPricePerConfigPerHalfYear(e.target.value)} + /> +

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

+ {isHalfYearCheaperThanQuarter && ( +

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

+ )} +
setPricePerConfigPerYear(e.target.value)} />

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

+ {isYearCheaperThanHalfYear && ( +

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

+ )} {isYearCheaperThanQuarter && (

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

)}
-
diff --git a/frontend/src/features/admin/pricing/api.ts b/frontend/src/features/admin/pricing/api.ts index 811bf6f..642d72a 100644 --- a/frontend/src/features/admin/pricing/api.ts +++ b/frontend/src/features/admin/pricing/api.ts @@ -5,9 +5,13 @@ export function getPricingSettings() { return apiRequest('/admin/pricing') } -export function updatePricingSettings(pricePerConfigPerQuarter: number | null, pricePerConfigPerYear: number | null) { +export function updatePricingSettings( + pricePerConfigPerQuarter: number | null, + pricePerConfigPerHalfYear: number | null, + pricePerConfigPerYear: number | null, +) { return apiRequest('/admin/pricing', { method: 'PUT', - body: { pricePerConfigPerQuarter, pricePerConfigPerYear }, + body: { pricePerConfigPerQuarter, pricePerConfigPerHalfYear, pricePerConfigPerYear }, }) } diff --git a/frontend/src/routes/admin/roles.tsx b/frontend/src/routes/admin/roles.tsx index 64ca125..cc54022 100644 --- a/frontend/src/routes/admin/roles.tsx +++ b/frontend/src/routes/admin/roles.tsx @@ -74,7 +74,7 @@ function AdminRolesPage() { {role.maxConfigs < 0 ? t('unlimited') : role.maxConfigs} {role.maxIpLimit < 0 ? t('unlimited') : role.maxIpLimit} {totalPrice(pricing?.pricePerConfigPerQuarter, role.maxConfigs, 3)} - {totalPrice(pricing?.pricePerConfigPerQuarter, role.maxConfigs, 6)} + {totalPrice(pricing?.pricePerConfigPerHalfYear, role.maxConfigs, 6)} {totalPrice(pricing?.pricePerConfigPerYear, role.maxConfigs, 12)}