Update pricing model to include half-year pricing and enhance validation
CI / Backend (build + test) (push) Successful in 1m24s
CI / Frontend (lint + typecheck + build) (push) Failing after 15s

- Introduced a new pricing field, `PricePerConfigPerHalfYear`, to the `PricingSettings` model, allowing for more flexible pricing options.
- Updated the `UpdatePricingSettingsCommand` and its validator to include the new half-year pricing, ensuring proper validation against the quarterly and yearly rates.
- Modified the `PricingSettingsDto` and related frontend components to accommodate the new half-year pricing field, including validation logic to prevent pricing discrepancies.
- Enhanced API documentation and frontend forms to reflect the updated pricing structure and validation rules.
This commit is contained in:
Leonid Pershin
2026-07-18 20:17:32 +03:00
parent 6dfd51ae7c
commit 32221af503
16 changed files with 1133 additions and 39 deletions
@@ -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)
);
}
@@ -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
);
}
@@ -5,5 +5,6 @@ namespace PnvPanel.Application.Admin.Pricing;
public sealed record UpdatePricingSettingsCommand(
int? PricePerConfigPerQuarter,
int? PricePerConfigPerHalfYear,
int? PricePerConfigPerYear
) : ICommand<Result<PricingSettingsDto>>;
@@ -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));
}
@@ -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
);
}
}
@@ -12,9 +12,14 @@ public sealed class PricingSettings : Entity
/// <summary>Цена за конфиг В МЕСЯЦ при оплате раз в 3 месяца (квартальный тариф).</summary>
public int? PricePerConfigPerQuarter { get; private set; }
/// <summary>Цена за конфиг В МЕСЯЦ при оплате раз в полгода. Может быть ниже квартальной ставки
/// (скидка за оплату на полгода вперёд), но не настолько, чтобы итог за полгода (×6) оказался
/// дешевле итога за квартал (×3) — см. UpdatePricingSettingsCommandValidator.</summary>
public int? PricePerConfigPerHalfYear { get; private set; }
/// <summary>Цена за конфиг В МЕСЯЦ при оплате раз в год (годовой тариф). Может быть ниже
/// квартальной ставки (скидка за годовую оплату), но не настолько, чтобы итог за год (×12)
/// оказался дешевле итога за квартал (×3) — см. UpdatePricingSettingsCommandValidator.</summary>
/// полугодовой ставки (скидка за годовую оплату), но не настолько, чтобы итог за год (×12)
/// оказался дешевле итога за полгода (×6) — см. UpdatePricingSettingsCommandValidator.</summary>
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;
}
@@ -0,0 +1,961 @@
// <auto-generated />
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
{
/// <inheritdoc />
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<System.Guid>", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("ClaimType")
.HasColumnType("text");
b.Property<string>("ClaimValue")
.HasColumnType("text");
b.Property<Guid>("RoleId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("RoleId");
b.ToTable("AspNetRoleClaims", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<System.Guid>", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("ClaimType")
.HasColumnType("text");
b.Property<string>("ClaimValue")
.HasColumnType("text");
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("UserId");
b.ToTable("AspNetUserClaims", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<System.Guid>", b =>
{
b.Property<string>("LoginProvider")
.HasColumnType("text");
b.Property<string>("ProviderKey")
.HasColumnType("text");
b.Property<string>("ProviderDisplayName")
.HasColumnType("text");
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.HasKey("LoginProvider", "ProviderKey");
b.HasIndex("UserId");
b.ToTable("AspNetUserLogins", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<System.Guid>", b =>
{
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.Property<Guid>("RoleId")
.HasColumnType("uuid");
b.HasKey("UserId", "RoleId");
b.HasIndex("RoleId");
b.ToTable("AspNetUserRoles", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<System.Guid>", b =>
{
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.Property<string>("LoginProvider")
.HasColumnType("text");
b.Property<string>("Name")
.HasColumnType("text");
b.Property<string>("Value")
.HasColumnType("text");
b.HasKey("UserId", "LoginProvider", "Name");
b.ToTable("AspNetUserTokens", (string)null);
});
modelBuilder.Entity("PnvPanel.Domain.Activation.ActivationRequest", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("Comment")
.HasMaxLength(500)
.HasColumnType("character varying(500)");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<DateTimeOffset?>("DecidedAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid?>("DecidedBy")
.HasColumnType("uuid");
b.Property<string>("RejectionReason")
.HasMaxLength(500)
.HasColumnType("character varying(500)");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)");
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("UserId", "Status");
b.ToTable("ActivationRequests", (string)null);
});
modelBuilder.Entity("PnvPanel.Domain.Apps.ClientApp", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("Description")
.HasMaxLength(300)
.HasColumnType("character varying(300)");
b.Property<string>("DownloadUrl")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("character varying(500)");
b.Property<string>("IconUrl")
.HasMaxLength(500)
.HasColumnType("character varying(500)");
b.Property<bool>("IsEnabled")
.HasColumnType("boolean");
b.Property<bool>("IsRecommended")
.HasColumnType("boolean");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<string>("OperatingSystem")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)");
b.Property<int>("SortOrder")
.HasColumnType("integer");
b.HasKey("Id");
b.ToTable("ClientApps", (string)null);
});
modelBuilder.Entity("PnvPanel.Domain.Audit.AuditLog", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<string>("Action")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<Guid?>("ActorId")
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Metadata")
.HasColumnType("jsonb");
b.Property<string>("Source")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)");
b.Property<string>("TargetId")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<string>("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<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<Guid>("ConfigId")
.HasColumnType("uuid");
b.Property<long>("DownBytes")
.HasColumnType("bigint");
b.Property<DateTimeOffset>("Timestamp")
.HasColumnType("timestamp with time zone");
b.Property<long>("UpBytes")
.HasColumnType("bigint");
b.HasKey("Id");
b.HasIndex("ConfigId", "Timestamp");
b.ToTable("TrafficSamples", (string)null);
});
modelBuilder.Entity("PnvPanel.Domain.Configs.VpnConfig", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("ClientEmail")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<string>("ClientExternalId")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<DateTimeOffset?>("ExpiresAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("InboundId")
.HasColumnType("uuid");
b.Property<string>("Label")
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<DateTimeOffset?>("LastSyncAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Protocol")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)");
b.Property<string>("SubscriptionToken")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<long>("UsedDownBytes")
.HasColumnType("bigint");
b.Property<long>("UsedUpBytes")
.HasColumnType("bigint");
b.Property<Guid>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.PrimitiveCollection<Guid[]>("AllowedRoleIds")
.IsRequired()
.HasColumnType("uuid[]");
b.Property<string>("DisplayName")
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<bool>("IsPublished")
.HasColumnType("boolean");
b.Property<DateTimeOffset?>("LastSyncAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("NodeId")
.HasColumnType("uuid");
b.Property<int>("Port")
.HasColumnType("integer");
b.Property<string>("Protocol")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)");
b.Property<string>("Remark")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<string>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("Body")
.IsRequired()
.HasMaxLength(20000)
.HasColumnType("character varying(20000)");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.ToTable("InstructionIntros", (string)null);
});
modelBuilder.Entity("PnvPanel.Domain.Instructions.InstructionTab", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("Body")
.IsRequired()
.HasMaxLength(20000)
.HasColumnType("character varying(20000)");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<int>("SortOrder")
.HasColumnType("integer");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<DateTimeOffset?>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("Body")
.IsRequired()
.HasMaxLength(20000)
.HasColumnType("character varying(20000)");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<DateTimeOffset?>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("BaseAddress")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("character varying(500)");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<bool>("IsEnabled")
.HasColumnType("boolean");
b.Property<DateTimeOffset?>("LastSyncAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Location")
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<string>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<int?>("PricePerConfigPerHalfYear")
.HasColumnType("integer");
b.Property<int?>("PricePerConfigPerQuarter")
.HasColumnType("integer");
b.Property<int?>("PricePerConfigPerYear")
.HasColumnType("integer");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.ToTable("PricingSettings", (string)null);
});
modelBuilder.Entity("PnvPanel.Domain.Support.SupportTicket", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<int?>("ProposedMaxConfigs")
.HasColumnType("integer");
b.Property<int?>("ProposedMaxIpLimit")
.HasColumnType("integer");
b.Property<string>("ProposedRoleName")
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<Guid?>("RequestedRoleId")
.HasColumnType("uuid");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)");
b.Property<string>("Type")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)");
b.Property<Guid>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<Guid>("CommentId")
.HasColumnType("uuid");
b.Property<string>("ContentType")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("FileName")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b.Property<long>("SizeBytes")
.HasColumnType("bigint");
b.Property<string>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<Guid>("AuthorId")
.HasColumnType("uuid");
b.Property<string>("Body")
.IsRequired()
.HasMaxLength(4000)
.HasColumnType("character varying(4000)");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("TicketId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("TicketId", "CreatedAt");
b.ToTable("TicketComments", (string)null);
});
modelBuilder.Entity("PnvPanel.Domain.Telegram.TelegramLinkToken", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTimeOffset?>("ConsumedAt")
.HasColumnType("timestamp with time zone");
b.Property<DateTimeOffset>("ExpiresAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Token")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("Token")
.IsUnique();
b.ToTable("TelegramLinkTokens", (string)null);
});
modelBuilder.Entity("PnvPanel.Domain.Telegram.TelegramLoginRequest", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("Context")
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<DateTimeOffset>("ExpiresAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)");
b.Property<Guid?>("UserId")
.HasColumnType("uuid");
b.HasKey("Id");
b.ToTable("TelegramLoginRequests", (string)null);
});
modelBuilder.Entity("PnvPanel.Infrastructure.Identity.AppRole", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("ConcurrencyStamp")
.IsConcurrencyToken()
.HasColumnType("text");
b.Property<bool>("IsSystem")
.HasColumnType("boolean");
b.Property<int>("MaxConfigs")
.HasColumnType("integer");
b.Property<int>("MaxIpLimit")
.HasColumnType("integer");
b.Property<string>("Name")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<int>("AccessFailedCount")
.HasColumnType("integer");
b.Property<DateTimeOffset?>("ActivatedAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid?>("ActivatedBy")
.HasColumnType("uuid");
b.Property<string>("ConcurrencyStamp")
.IsConcurrencyToken()
.HasColumnType("text");
b.Property<string>("Email")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<bool>("EmailConfirmed")
.HasColumnType("boolean");
b.Property<bool>("IsActivated")
.HasColumnType("boolean");
b.Property<bool>("IsBlocked")
.HasColumnType("boolean");
b.Property<bool>("LockoutEnabled")
.HasColumnType("boolean");
b.Property<DateTimeOffset?>("LockoutEnd")
.HasColumnType("timestamp with time zone");
b.Property<string>("NormalizedEmail")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("NormalizedUserName")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("PasswordHash")
.HasColumnType("text");
b.Property<string>("PhoneNumber")
.HasColumnType("text");
b.Property<bool>("PhoneNumberConfirmed")
.HasColumnType("boolean");
b.Property<string>("SecurityStamp")
.HasColumnType("text");
b.Property<string>("SubscriptionToken")
.IsRequired()
.HasColumnType("text");
b.Property<DateTimeOffset?>("TelegramLinkedAt")
.HasColumnType("timestamp with time zone");
b.Property<long?>("TelegramUserId")
.HasColumnType("bigint");
b.Property<string>("TelegramUsername")
.HasColumnType("text");
b.Property<bool>("TwoFactorEnabled")
.HasColumnType("boolean");
b.Property<string>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<DateTimeOffset>("ExpiresAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("ReplacedByTokenHash")
.HasColumnType("text");
b.Property<DateTimeOffset?>("RevokedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("TokenHash")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("TokenHash")
.IsUnique();
b.HasIndex("UserId");
b.ToTable("RefreshTokens", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<System.Guid>", b =>
{
b.HasOne("PnvPanel.Infrastructure.Identity.AppRole", null)
.WithMany()
.HasForeignKey("RoleId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<System.Guid>", b =>
{
b.HasOne("PnvPanel.Infrastructure.Identity.AppUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<System.Guid>", b =>
{
b.HasOne("PnvPanel.Infrastructure.Identity.AppUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<System.Guid>", 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<System.Guid>", 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<Guid>("NodeId")
.HasColumnType("uuid");
b1.Property<string>("ProtectedPassword")
.IsRequired()
.HasColumnType("text")
.HasColumnName("CredentialsProtectedPassword");
b1.Property<string>("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
}
}
}
@@ -0,0 +1,28 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace PnvPanel.Infrastructure.Persistence.Migrations
{
/// <inheritdoc />
public partial class AddPricingHalfYear : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<int>(
name: "PricePerConfigPerHalfYear",
table: "PricingSettings",
type: "integer",
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "PricePerConfigPerHalfYear",
table: "PricingSettings");
}
}
}
@@ -519,6 +519,9 @@ namespace PnvPanel.Infrastructure.Persistence.Migrations
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<int?>("PricePerConfigPerHalfYear")
.HasColumnType("integer");
b.Property<int?>("PricePerConfigPerQuarter")
.HasColumnType("integer");
+1 -1
View File
@@ -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`.
+20 -17
View File
@@ -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).
@@ -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
/>
<p className="text-xs text-muted-foreground">{t('admin.pricing.pricePerConfigPerQuarterHint')}</p>
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="pricePerConfigPerHalfYear">{t('admin.pricing.pricePerConfigPerHalfYear')}</Label>
<Input
id="pricePerConfigPerHalfYear"
type="number"
min={0}
value={pricePerConfigPerHalfYear}
onChange={(e) => setPricePerConfigPerHalfYear(e.target.value)}
/>
<p className="text-xs text-muted-foreground">{t('admin.pricing.pricePerConfigPerHalfYearHint')}</p>
{isHalfYearCheaperThanQuarter && (
<p className="text-xs text-red-600">{t('admin.pricing.halfYearCheaperThanQuarter')}</p>
)}
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="pricePerConfigPerYear">{t('admin.pricing.pricePerConfigPerYear')}</Label>
<Input
@@ -68,12 +99,15 @@ export function PricingSettingsEditor({ settings }: { settings: PricingSettingsD
onChange={(e) => setPricePerConfigPerYear(e.target.value)}
/>
<p className="text-xs text-muted-foreground">{t('admin.pricing.pricePerConfigPerYearHint')}</p>
{isYearCheaperThanHalfYear && (
<p className="text-xs text-red-600">{t('admin.pricing.yearCheaperThanHalfYear')}</p>
)}
{isYearCheaperThanQuarter && (
<p className="text-xs text-red-600">{t('admin.pricing.yearCheaperThanQuarter')}</p>
)}
</div>
<div>
<Button type="submit" disabled={mutation.isPending || isYearCheaperThanQuarter}>
<Button type="submit" disabled={mutation.isPending || hasInvalidCombo}>
{t('admin.roles.save')}
</Button>
</div>
+6 -2
View File
@@ -5,9 +5,13 @@ export function getPricingSettings() {
return apiRequest<PricingSettingsDto>('/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<PricingSettingsDto>('/admin/pricing', {
method: 'PUT',
body: { pricePerConfigPerQuarter, pricePerConfigPerYear },
body: { pricePerConfigPerQuarter, pricePerConfigPerHalfYear, pricePerConfigPerYear },
})
}
+1 -1
View File
@@ -74,7 +74,7 @@ function AdminRolesPage() {
<td className="py-2">{role.maxConfigs < 0 ? t('unlimited') : role.maxConfigs}</td>
<td className="py-2">{role.maxIpLimit < 0 ? t('unlimited') : role.maxIpLimit}</td>
<td className="py-2">{totalPrice(pricing?.pricePerConfigPerQuarter, role.maxConfigs, 3)}</td>
<td className="py-2">{totalPrice(pricing?.pricePerConfigPerQuarter, role.maxConfigs, 6)}</td>
<td className="py-2">{totalPrice(pricing?.pricePerConfigPerHalfYear, role.maxConfigs, 6)}</td>
<td className="py-2">{totalPrice(pricing?.pricePerConfigPerYear, role.maxConfigs, 12)}</td>
<td className="py-2 text-right">
<Button size="sm" variant="outline" onClick={() => setEditing(role)}>
+3 -1
View File
@@ -175,9 +175,11 @@ export type RoleDto = {
isSystem: boolean
}
/** Глобальная справочная цена за конфиг (видна только админу) — одна на весь сервис, не per-роль. */
/** Глобальная справочная цена за конфиг (видна только админу) — одна на весь сервис, не per-роль.
* Все поля — цена за конфиг В МЕСЯЦ при данном тарифе; итог за период = ставка × месяцы. */
export type PricingSettingsDto = {
pricePerConfigPerQuarter: number | null
pricePerConfigPerHalfYear: number | null
pricePerConfigPerYear: number | null
}
+10 -2
View File
@@ -271,8 +271,12 @@ const resources = {
title: 'Справочная цена конфига',
pricePerConfigPerQuarter: 'Цена за конфиг в месяц / оплата раз в 3 мес',
pricePerConfigPerQuarterHint: 'Минимальный период оплаты. Видно только админу. Итог за период = ставка × число месяцев × квота роли.',
pricePerConfigPerHalfYear: 'Цена за конфиг в месяц / оплата раз в полгода',
pricePerConfigPerHalfYearHint: 'Может быть ниже квартальной (скидка за оплату на полгода вперёд), но итог за полгода не должен быть дешевле итога за квартал.',
halfYearCheaperThanQuarter: 'Цена за полгода (в пересчёте на 6 месяцев) не может быть меньше цены за 3 месяца.',
pricePerConfigPerYear: 'Цена за конфиг в месяц / оплата раз в год',
pricePerConfigPerYearHint: 'Может быть ниже квартальной (скидка за годовую оплату), но итог за год не должен быть дешевле итога за квартал.',
pricePerConfigPerYearHint: 'Может быть ниже полугодовой (скидка за годовую оплату), но итог за год не должен быть дешевле итога за полгода.',
yearCheaperThanHalfYear: 'Цена за год (в пересчёте на 12 месяцев) не может быть меньше цены за полгода.',
yearCheaperThanQuarter: 'Цена за год (в пересчёте на 12 месяцев) не может быть меньше цены за 3 месяца.',
updated: 'Цена обновлена.',
},
@@ -703,8 +707,12 @@ const resources = {
title: 'Reference config price',
pricePerConfigPerQuarter: 'Price per config per month / billed every 3 months',
pricePerConfigPerQuarterHint: 'Minimum billing period. Visible to admin only. Total for a period = rate × months × role quota.',
pricePerConfigPerHalfYear: 'Price per config per month / billed every 6 months',
pricePerConfigPerHalfYearHint: 'Can be lower than the quarterly rate (discount for a 6-month prepayment), but the 6-month total must not be cheaper than the 3-month total.',
halfYearCheaperThanQuarter: 'The 6-month price (over 6 months) cannot be lower than the 3-month price.',
pricePerConfigPerYear: 'Price per config per month / billed yearly',
pricePerConfigPerYearHint: 'Can be lower than the quarterly rate (annual discount), but the yearly total must not be cheaper than the quarterly total.',
pricePerConfigPerYearHint: 'Can be lower than the 6-month rate (annual discount), but the yearly total must not be cheaper than the 6-month total.',
yearCheaperThanHalfYear: 'The annual price (over 12 months) cannot be lower than the 6-month price.',
yearCheaperThanQuarter: 'The annual price (over 12 months) cannot be lower than the 3-month price.',
updated: 'Pricing updated.',
},