diff --git a/backend/src/PnvPanel.Api/Telegram/TelegramNotifier.cs b/backend/src/PnvPanel.Api/Telegram/TelegramNotifier.cs
index 77233a7..414bba2 100644
--- a/backend/src/PnvPanel.Api/Telegram/TelegramNotifier.cs
+++ b/backend/src/PnvPanel.Api/Telegram/TelegramNotifier.cs
@@ -270,7 +270,8 @@ internal sealed class TelegramNotifier(
public async Task NotifyAdminsPaymentRequestedAsync(
Guid requestId,
string userName,
- PaymentPeriod period,
+ PaymentRequestKind kind,
+ PaymentPeriod? period,
int amount,
CancellationToken cancellationToken
)
@@ -278,8 +279,8 @@ internal sealed class TelegramNotifier(
if (string.IsNullOrWhiteSpace(options.Value.BotToken))
return;
- var text =
- $"💰 {Escape(userName)} заявляет об оплате за {PeriodLabel(period)} — {amount} ₽\nПроверьте поступление и подтвердите.";
+ var reason = kind == PaymentRequestKind.RoleChangeTopUp ? "доплате за смену роли" : $"оплате за {PeriodLabel(period!.Value)}";
+ var text = $"💰 {Escape(userName)} заявляет о {reason} — {amount} ₽\nПроверьте поступление и подтвердите.";
var keyboard = new InlineKeyboardMarkup(
new[]
diff --git a/backend/src/PnvPanel.Application/Admin/Billing/AdminPaymentRequestDto.cs b/backend/src/PnvPanel.Application/Admin/Billing/AdminPaymentRequestDto.cs
index ebb9fa9..23f9f8f 100644
--- a/backend/src/PnvPanel.Application/Admin/Billing/AdminPaymentRequestDto.cs
+++ b/backend/src/PnvPanel.Application/Admin/Billing/AdminPaymentRequestDto.cs
@@ -6,7 +6,8 @@ public sealed record AdminPaymentRequestDto(
Guid Id,
Guid UserId,
string UserName,
- PaymentPeriod Period,
+ PaymentRequestKind Kind,
+ PaymentPeriod? Period,
int AmountSnapshot,
PaymentRequestStatus Status,
DateTimeOffset CreatedAt
diff --git a/backend/src/PnvPanel.Application/Admin/Billing/ConfirmPaymentRequestCommandHandler.cs b/backend/src/PnvPanel.Application/Admin/Billing/ConfirmPaymentRequestCommandHandler.cs
index c840455..cced610 100644
--- a/backend/src/PnvPanel.Application/Admin/Billing/ConfirmPaymentRequestCommandHandler.cs
+++ b/backend/src/PnvPanel.Application/Admin/Billing/ConfirmPaymentRequestCommandHandler.cs
@@ -50,9 +50,37 @@ public sealed class ConfirmPaymentRequestCommandHandler(
if (profile is null)
return Result.Failure(AuthErrors.Unauthorized);
+ // RoleChangeTopUp — доплата разницы в цене при апгрейде роли, не покупка времени: подтверждение
+ // не трогает BillingPaidUntil и не возвращает Expired-конфиги (это делает обычная Subscription-
+ // оплата/продление). Period не задан для этого Kind — ToMonths() здесь неприменим.
+ if (request.Kind == PaymentRequestKind.RoleChangeTopUp)
+ {
+ request.Confirm(adminId);
+
+ dbContext.AuditLogs.Add(
+ AuditLog.Create(
+ adminId,
+ "PaymentConfirmed",
+ "PaymentRequest",
+ request.Id.ToString(),
+ metadata: null,
+ AuditSource.Web
+ )
+ );
+
+ await telegramNotifier.NotifyUserAsync(
+ request.UserId,
+ "✅ Доплата за смену роли подтверждена.",
+ null,
+ cancellationToken
+ );
+
+ return Result.Success();
+ }
+
var now = DateTimeOffset.UtcNow;
var baseline = profile.BillingPaidUntil is { } paidUntil && paidUntil > now ? paidUntil : now;
- var newPaidUntil = baseline.AddMonths(request.Period.ToMonths());
+ var newPaidUntil = baseline.AddMonths(request.Period!.Value.ToMonths());
var extendResult = await identityService.ExtendBillingPaidUntilAsync(
request.UserId,
diff --git a/backend/src/PnvPanel.Application/Admin/Billing/ListPaymentRequestsQueryHandler.cs b/backend/src/PnvPanel.Application/Admin/Billing/ListPaymentRequestsQueryHandler.cs
index 4106c3c..f99fdc3 100644
--- a/backend/src/PnvPanel.Application/Admin/Billing/ListPaymentRequestsQueryHandler.cs
+++ b/backend/src/PnvPanel.Application/Admin/Billing/ListPaymentRequestsQueryHandler.cs
@@ -36,6 +36,7 @@ public sealed class ListPaymentRequestsQueryHandler(
r.Id,
r.UserId,
userNames.GetValueOrDefault(r.UserId, "?"),
+ r.Kind,
r.Period,
r.AmountSnapshot,
r.Status,
diff --git a/backend/src/PnvPanel.Application/Admin/Support/ApproveRoleRequestCommandHandler.cs b/backend/src/PnvPanel.Application/Admin/Support/ApproveRoleRequestCommandHandler.cs
index 6c610da..89cf324 100644
--- a/backend/src/PnvPanel.Application/Admin/Support/ApproveRoleRequestCommandHandler.cs
+++ b/backend/src/PnvPanel.Application/Admin/Support/ApproveRoleRequestCommandHandler.cs
@@ -5,6 +5,7 @@ using PnvPanel.Application.Common.Messaging;
using PnvPanel.Application.Common.Models;
using PnvPanel.Application.Support;
using PnvPanel.Domain.Audit;
+using PnvPanel.Domain.Billing;
using PnvPanel.Domain.Support;
namespace PnvPanel.Application.Admin.Support;
@@ -12,6 +13,7 @@ namespace PnvPanel.Application.Admin.Support;
public sealed class ApproveRoleRequestCommandHandler(
IAppDbContext dbContext,
IRoleService roleService,
+ IIdentityService identityService,
IRealtimeNotifier notifier,
ITelegramNotifier telegramNotifier,
ICurrentUser currentUser
@@ -38,10 +40,19 @@ public sealed class ApproveRoleRequestCommandHandler(
if (ticket.Status != TicketStatus.Open)
return Result.Failure(SupportErrors.NotOpen);
- Guid roleId;
+ // Снимаем профиль ДО смены роли — нужен старый MaxConfigs/BillingPaidUntil для проратированной
+ // доплаты за апгрейд (см. ниже), после ChangeUserRoleAsync эти данные уже недоступны.
+ var oldProfile = await identityService.GetProfileAsync(ticket.UserId, cancellationToken);
+
+ RoleDto newRole;
if (ticket.RequestedRoleId is { } existingRoleId)
{
- roleId = existingRoleId;
+ var roles = await roleService.ListRolesAsync(cancellationToken);
+ var found = roles.FirstOrDefault(r => r.Id == existingRoleId);
+ if (found is null)
+ return Result.Failure(SupportErrors.NotFound);
+
+ newRole = found;
}
else
{
@@ -55,12 +66,12 @@ public sealed class ApproveRoleRequestCommandHandler(
if (!createResult.IsSuccess)
return Result.Failure(createResult.Error);
- roleId = createResult.Value.Id;
+ newRole = createResult.Value;
}
var assignResult = await roleService.ChangeUserRoleAsync(
ticket.UserId,
- roleId,
+ newRole.Id,
cancellationToken
);
if (!assignResult.IsSuccess)
@@ -87,6 +98,51 @@ public sealed class ApproveRoleRequestCommandHandler(
cancellationToken
);
+ if (newRole.BillingEnabled && oldProfile?.BillingPaidUntil is { } paidUntil)
+ await CreateTopUpIfNeededAsync(ticket.UserId, oldProfile.MaxConfigs, newRole.MaxConfigs, paidUntil, cancellationToken);
+
return Result.Success();
}
+
+ /// Роль подорожала, а оплаченный период ещё активен — по-хорошему пользователь должен
+ /// доплатить разницу, а не доиграть апгрейд бесплатно до конца уже оплаченного срока. Роль меняется
+ /// сразу (см. выше); доплата решается отдельно через обычный флоу PaymentRequest — см.
+ /// domain-model.md#rolechangetopup.
+ private async Task CreateTopUpIfNeededAsync(
+ Guid userId,
+ int oldMaxConfigs,
+ int newMaxConfigs,
+ DateTimeOffset paidUntil,
+ CancellationToken cancellationToken
+ )
+ {
+ var pricing = await dbContext.PricingSettings.AsNoTracking().FirstOrDefaultAsync(cancellationToken);
+ if (pricing?.PricePerConfigPerQuarter is not { } rate)
+ return;
+
+ var tiers = await dbContext
+ .PricingDiscountTiers.AsNoTracking()
+ .Where(t => t.PricingSettingsId == pricing.Id)
+ .ToListAsync(cancellationToken);
+
+ var amount = RoleChangeTopUp.Compute(
+ rate,
+ oldMaxConfigs,
+ newMaxConfigs,
+ tiers,
+ paidUntil,
+ DateTimeOffset.UtcNow
+ );
+ if (amount is not { } topUpAmount)
+ return;
+
+ dbContext.PaymentRequests.Add(PaymentRequest.CreateRoleChangeTopUp(userId, topUpAmount));
+
+ await telegramNotifier.NotifyUserAsync(
+ userId,
+ $"💳 Новая роль дороже прежней — требуется доплата {topUpAmount} ₽ за оставшуюся часть оплаченного периода.",
+ "/billing",
+ cancellationToken
+ );
+ }
}
diff --git a/backend/src/PnvPanel.Application/Billing/CreatePaymentRequest/CreatePaymentRequestCommandHandler.cs b/backend/src/PnvPanel.Application/Billing/CreatePaymentRequest/CreatePaymentRequestCommandHandler.cs
index f4da25a..b91a7da 100644
--- a/backend/src/PnvPanel.Application/Billing/CreatePaymentRequest/CreatePaymentRequestCommandHandler.cs
+++ b/backend/src/PnvPanel.Application/Billing/CreatePaymentRequest/CreatePaymentRequestCommandHandler.cs
@@ -32,9 +32,12 @@ public sealed class CreatePaymentRequestCommandHandler(
if (profile.MaxConfigs == RoleQuota.Unlimited)
return Result.Failure(BillingErrors.UnlimitedRoleNotSupported);
+ // RoleChangeTopUp не считается активной подписной заявкой — доплата за смену роли не должна
+ // мешать пользователю продлить/оформить обычную подписку.
var hasActiveRequest = await dbContext.PaymentRequests.AnyAsync(
r =>
r.UserId == userId
+ && r.Kind == PaymentRequestKind.Subscription
&& (
r.Status == PaymentRequestStatus.AwaitingPayment
|| r.Status == PaymentRequestStatus.AwaitingConfirmation
diff --git a/backend/src/PnvPanel.Application/Billing/MarkPaymentSent/MarkPaymentSentCommandHandler.cs b/backend/src/PnvPanel.Application/Billing/MarkPaymentSent/MarkPaymentSentCommandHandler.cs
index 9a8b9b7..9acb6ab 100644
--- a/backend/src/PnvPanel.Application/Billing/MarkPaymentSent/MarkPaymentSentCommandHandler.cs
+++ b/backend/src/PnvPanel.Application/Billing/MarkPaymentSent/MarkPaymentSentCommandHandler.cs
@@ -35,6 +35,7 @@ public sealed class MarkPaymentSentCommandHandler(
await telegramNotifier.NotifyAdminsPaymentRequestedAsync(
request.Id,
profile?.UserName ?? userId.ToString(),
+ request.Kind,
request.Period,
request.AmountSnapshot,
cancellationToken
diff --git a/backend/src/PnvPanel.Application/Billing/PaymentRequestDto.cs b/backend/src/PnvPanel.Application/Billing/PaymentRequestDto.cs
index c3b603c..65a63fa 100644
--- a/backend/src/PnvPanel.Application/Billing/PaymentRequestDto.cs
+++ b/backend/src/PnvPanel.Application/Billing/PaymentRequestDto.cs
@@ -4,12 +4,13 @@ namespace PnvPanel.Application.Billing;
public sealed record PaymentRequestDto(
Guid Id,
- PaymentPeriod Period,
+ PaymentRequestKind Kind,
+ PaymentPeriod? Period,
int AmountSnapshot,
PaymentRequestStatus Status,
DateTimeOffset CreatedAt
)
{
public static PaymentRequestDto FromDomain(PaymentRequest request) =>
- new(request.Id, request.Period, request.AmountSnapshot, request.Status, request.CreatedAt);
+ new(request.Id, request.Kind, request.Period, request.AmountSnapshot, request.Status, request.CreatedAt);
}
diff --git a/backend/src/PnvPanel.Application/Billing/SendRequisitesToTelegram/SendRequisitesToTelegramCommandHandler.cs b/backend/src/PnvPanel.Application/Billing/SendRequisitesToTelegram/SendRequisitesToTelegramCommandHandler.cs
index 6d2bcfa..fa98c3b 100644
--- a/backend/src/PnvPanel.Application/Billing/SendRequisitesToTelegram/SendRequisitesToTelegramCommandHandler.cs
+++ b/backend/src/PnvPanel.Application/Billing/SendRequisitesToTelegram/SendRequisitesToTelegramCommandHandler.cs
@@ -38,13 +38,16 @@ public sealed class SendRequisitesToTelegramCommandHandler(
.FirstOrDefaultAsync(cancellationToken);
var text =
- $"💳 Реквизиты для оплаты ({PeriodLabel(request.Period)}, {request.AmountSnapshot} ₽):\n{settings?.RequisitesText}";
+ $"💳 Реквизиты для оплаты ({DescribeRequest(request)}, {request.AmountSnapshot} ₽):\n{settings?.RequisitesText}";
await telegramNotifier.NotifyUserAsync(userId, text, null, cancellationToken);
return Result.Success();
}
+ private static string DescribeRequest(PaymentRequest request) =>
+ request.Kind == PaymentRequestKind.RoleChangeTopUp ? "доплата за смену роли" : PeriodLabel(request.Period!.Value);
+
private static string PeriodLabel(PaymentPeriod period) =>
period switch
{
diff --git a/backend/src/PnvPanel.Application/Common/Interfaces/ITelegramNotifier.cs b/backend/src/PnvPanel.Application/Common/Interfaces/ITelegramNotifier.cs
index fab32da..7ff5e7b 100644
--- a/backend/src/PnvPanel.Application/Common/Interfaces/ITelegramNotifier.cs
+++ b/backend/src/PnvPanel.Application/Common/Interfaces/ITelegramNotifier.cs
@@ -54,11 +54,13 @@ public interface ITelegramNotifier
);
/// Пользователь нажал «Я оплатил» — инлайн-кнопки «Подтвердить/Отклонить», решается
- /// полностью в Telegram (аналогично заявке на роль).
+ /// полностью в Telegram (аналогично заявке на роль). Period задан только для Kind.Subscription —
+ /// для Kind.RoleChangeTopUp он null (доплата не привязана к тарифному периоду).
Task NotifyAdminsPaymentRequestedAsync(
Guid requestId,
string userName,
- PaymentPeriod period,
+ PaymentRequestKind kind,
+ PaymentPeriod? period,
int amount,
CancellationToken cancellationToken
);
diff --git a/backend/src/PnvPanel.Domain/Billing/PaymentRequest.cs b/backend/src/PnvPanel.Domain/Billing/PaymentRequest.cs
index 7181225..87c04ed 100644
--- a/backend/src/PnvPanel.Domain/Billing/PaymentRequest.cs
+++ b/backend/src/PnvPanel.Domain/Billing/PaymentRequest.cs
@@ -4,15 +4,21 @@ using PnvPanel.Domain.Exceptions;
namespace PnvPanel.Domain.Billing;
///
-/// Заявка пользователя на оплату подписки за период (квартал/полгода/год). Сумма замораживается на
-/// момент создания (по действовавшей на тот момент ставке PricingSettings) — последующее изменение
-/// прайса админом не меняет уже созданные заявки. Не более одной активной (AwaitingPayment/
-/// AwaitingConfirmation) заявки на пользователя — инвариант проверяется на уровне Application.
+/// Заявка пользователя на оплату. Kind.Subscription — оплата подписки за период (квартал/полгода/год,
+/// Period задан), продлевает BillingPaidUntil при подтверждении. Kind.RoleChangeTopUp — доплата разницы
+/// в цене при апгрейде роли с активным оплаченным периодом (Period не задан), подтверждение НЕ меняет
+/// BillingPaidUntil — см. RoleChangeTopUp.Compute и ConfirmPaymentRequestCommandHandler. Сумма
+/// замораживается на момент создания (по действовавшей на тот момент ставке PricingSettings) —
+/// последующее изменение прайса админом не меняет уже созданные заявки. Не более одной активной
+/// (AwaitingPayment/AwaitingConfirmation) заявки Kind.Subscription на пользователя — инвариант
+/// проверяется на уровне Application; RoleChangeTopUp создаётся системой при одобрении заявки на роль
+/// и этим инвариантом не ограничен.
///
public sealed class PaymentRequest : Entity
{
public Guid UserId { get; private set; }
- public PaymentPeriod Period { get; private set; }
+ public PaymentRequestKind Kind { get; private set; }
+ public PaymentPeriod? Period { get; private set; }
public int AmountSnapshot { get; private set; }
public PaymentRequestStatus Status { get; private set; }
public Guid? DecidedBy { get; private set; }
@@ -28,6 +34,7 @@ public sealed class PaymentRequest : Entity
{
Id = Guid.NewGuid(),
UserId = userId,
+ Kind = PaymentRequestKind.Subscription,
Period = period,
AmountSnapshot = amountSnapshot,
Status = PaymentRequestStatus.AwaitingPayment,
@@ -35,6 +42,20 @@ public sealed class PaymentRequest : Entity
};
}
+ public static PaymentRequest CreateRoleChangeTopUp(Guid userId, int amountSnapshot)
+ {
+ return new PaymentRequest
+ {
+ Id = Guid.NewGuid(),
+ UserId = userId,
+ Kind = PaymentRequestKind.RoleChangeTopUp,
+ Period = null,
+ AmountSnapshot = amountSnapshot,
+ Status = PaymentRequestStatus.AwaitingPayment,
+ CreatedAt = DateTimeOffset.UtcNow,
+ };
+ }
+
/// Пользователь нажал «Я оплатил» — уходит на подтверждение админом.
public void MarkPaymentSent()
{
diff --git a/backend/src/PnvPanel.Domain/Billing/PaymentRequestKind.cs b/backend/src/PnvPanel.Domain/Billing/PaymentRequestKind.cs
new file mode 100644
index 0000000..c4b37fa
--- /dev/null
+++ b/backend/src/PnvPanel.Domain/Billing/PaymentRequestKind.cs
@@ -0,0 +1,10 @@
+namespace PnvPanel.Domain.Billing;
+
+/// Subscription — обычная оплата за период (Period задан). RoleChangeTopUp — доплата разницы
+/// в цене при апгрейде роли с активным оплаченным периодом (Period не задан, не продлевает
+/// BillingPaidUntil) — см. RoleChangeTopUp.Compute.
+public enum PaymentRequestKind
+{
+ Subscription,
+ RoleChangeTopUp,
+}
diff --git a/backend/src/PnvPanel.Domain/Billing/RoleChangeTopUp.cs b/backend/src/PnvPanel.Domain/Billing/RoleChangeTopUp.cs
new file mode 100644
index 0000000..f17dfb2
--- /dev/null
+++ b/backend/src/PnvPanel.Domain/Billing/RoleChangeTopUp.cs
@@ -0,0 +1,53 @@
+using PnvPanel.Domain.Pricing;
+
+namespace PnvPanel.Domain.Billing;
+
+///
+/// Проратированная доплата при апгрейде роли с активным оплаченным периодом: пользователь платит
+/// разницу в месячной стоимости между старой и новой ролью за оставшиеся дни BillingPaidUntil, а не
+/// выкупает период заново — так более выгодная роль не достаётся бесплатно до конца уже оплаченного
+/// периода. Базовая ставка — PricePerConfigPerQuarter (минимальный/справочный тариф), с той же
+/// скидочной лесенкой (PricingDiscountTier), что и обычная оплата — см. domain-model.md#rolechangetopup.
+///
+public static class RoleChangeTopUp
+{
+ private const int DaysPerMonth = 30;
+
+ /// Null, если доплата не нужна: роль не подорожала, оплаченный период уже истёк, или
+ /// у старой/новой роли нет квоты (unlimited — цена для неё не считается).
+ public static int? Compute(
+ int pricePerConfigPerMonth,
+ int oldMaxConfigs,
+ int newMaxConfigs,
+ IReadOnlyCollection discountTiers,
+ DateTimeOffset paidUntil,
+ DateTimeOffset now
+ )
+ {
+ if (oldMaxConfigs < 0 || newMaxConfigs < 0)
+ return null;
+
+ var remainingDays = (paidUntil - now).TotalDays;
+ if (remainingDays <= 0)
+ return null;
+
+ var oldMonthly = MonthlyTotal(pricePerConfigPerMonth, oldMaxConfigs, discountTiers);
+ var newMonthly = MonthlyTotal(pricePerConfigPerMonth, newMaxConfigs, discountTiers);
+ if (newMonthly <= oldMonthly)
+ return null;
+
+ var amount = (newMonthly - oldMonthly) / (decimal)DaysPerMonth * (decimal)remainingDays;
+ return (int)Math.Round(amount, MidpointRounding.AwayFromZero);
+ }
+
+ private static int MonthlyTotal(
+ int pricePerConfigPerMonth,
+ int maxConfigs,
+ IReadOnlyCollection tiers
+ )
+ {
+ var raw = pricePerConfigPerMonth * maxConfigs;
+ var discountPercent = PricingDiscount.ResolvePercent(tiers, maxConfigs);
+ return PricingDiscount.Apply(raw, discountPercent);
+ }
+}
diff --git a/backend/src/PnvPanel.Infrastructure/Persistence/Configurations/PaymentRequestConfiguration.cs b/backend/src/PnvPanel.Infrastructure/Persistence/Configurations/PaymentRequestConfiguration.cs
index fedb4fe..ece8e74 100644
--- a/backend/src/PnvPanel.Infrastructure/Persistence/Configurations/PaymentRequestConfiguration.cs
+++ b/backend/src/PnvPanel.Infrastructure/Persistence/Configurations/PaymentRequestConfiguration.cs
@@ -11,6 +11,7 @@ public class PaymentRequestConfiguration : IEntityTypeConfiguration x.Id);
+ builder.Property(x => x.Kind).HasConversion().HasMaxLength(32);
builder.Property(x => x.Period).HasConversion().HasMaxLength(32);
builder.Property(x => x.Status).HasConversion().HasMaxLength(32);
builder.Property(x => x.RejectionReason).HasMaxLength(500);
diff --git a/backend/src/PnvPanel.Infrastructure/Persistence/Migrations/20260719130201_AddPaymentRequestKind.Designer.cs b/backend/src/PnvPanel.Infrastructure/Persistence/Migrations/20260719130201_AddPaymentRequestKind.Designer.cs
new file mode 100644
index 0000000..ccebf2d
--- /dev/null
+++ b/backend/src/PnvPanel.Infrastructure/Persistence/Migrations/20260719130201_AddPaymentRequestKind.Designer.cs
@@ -0,0 +1,1073 @@
+//
+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("20260719130201_AddPaymentRequestKind")]
+ partial class AddPaymentRequestKind
+ {
+ ///
+ 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("Kind")
+ .IsRequired()
+ .HasMaxLength(32)
+ .HasColumnType("character varying(32)");
+
+ b.Property("Period")
+ .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/20260719130201_AddPaymentRequestKind.cs b/backend/src/PnvPanel.Infrastructure/Persistence/Migrations/20260719130201_AddPaymentRequestKind.cs
new file mode 100644
index 0000000..5cf1995
--- /dev/null
+++ b/backend/src/PnvPanel.Infrastructure/Persistence/Migrations/20260719130201_AddPaymentRequestKind.cs
@@ -0,0 +1,52 @@
+using Microsoft.EntityFrameworkCore.Migrations;
+
+#nullable disable
+
+namespace PnvPanel.Infrastructure.Persistence.Migrations
+{
+ ///
+ public partial class AddPaymentRequestKind : Migration
+ {
+ ///
+ protected override void Up(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.AlterColumn(
+ name: "Period",
+ table: "PaymentRequests",
+ type: "character varying(32)",
+ maxLength: 32,
+ nullable: true,
+ oldClrType: typeof(string),
+ oldType: "character varying(32)",
+ oldMaxLength: 32);
+
+ migrationBuilder.AddColumn(
+ name: "Kind",
+ table: "PaymentRequests",
+ type: "character varying(32)",
+ maxLength: 32,
+ nullable: false,
+ defaultValue: "Subscription");
+ }
+
+ ///
+ protected override void Down(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.DropColumn(
+ name: "Kind",
+ table: "PaymentRequests");
+
+ migrationBuilder.AlterColumn(
+ name: "Period",
+ table: "PaymentRequests",
+ type: "character varying(32)",
+ maxLength: 32,
+ nullable: false,
+ defaultValue: "",
+ oldClrType: typeof(string),
+ oldType: "character varying(32)",
+ oldMaxLength: 32,
+ oldNullable: true);
+ }
+ }
+}
diff --git a/backend/src/PnvPanel.Infrastructure/Persistence/Migrations/AppDbContextModelSnapshot.cs b/backend/src/PnvPanel.Infrastructure/Persistence/Migrations/AppDbContextModelSnapshot.cs
index 5b5ebbe..f10cc44 100644
--- a/backend/src/PnvPanel.Infrastructure/Persistence/Migrations/AppDbContextModelSnapshot.cs
+++ b/backend/src/PnvPanel.Infrastructure/Persistence/Migrations/AppDbContextModelSnapshot.cs
@@ -293,11 +293,15 @@ namespace PnvPanel.Infrastructure.Persistence.Migrations
b.Property("DecidedBy")
.HasColumnType("uuid");
- b.Property("Period")
+ b.Property("Kind")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)");
+ b.Property("Period")
+ .HasMaxLength(32)
+ .HasColumnType("character varying(32)");
+
b.Property("RejectionReason")
.HasMaxLength(500)
.HasColumnType("character varying(500)");
diff --git a/backend/tests/PnvPanel.Application.Tests/Admin/Billing/ConfirmPaymentRequestCommandHandlerTests.cs b/backend/tests/PnvPanel.Application.Tests/Admin/Billing/ConfirmPaymentRequestCommandHandlerTests.cs
index 3c910b1..df83323 100644
--- a/backend/tests/PnvPanel.Application.Tests/Admin/Billing/ConfirmPaymentRequestCommandHandlerTests.cs
+++ b/backend/tests/PnvPanel.Application.Tests/Admin/Billing/ConfirmPaymentRequestCommandHandlerTests.cs
@@ -205,6 +205,48 @@ public class ConfirmPaymentRequestCommandHandlerTests
);
}
+ [Fact]
+ public async Task Handle_WhenRoleChangeTopUp_ConfirmsWithoutExtendingPaidUntil()
+ {
+ using var dbContext = InMemoryDbContextFactory.Create();
+ var adminId = Guid.NewGuid();
+ var userId = Guid.NewGuid();
+ var existingPaidUntil = DateTimeOffset.UtcNow.AddDays(20);
+ var request = PaymentRequest.CreateRoleChangeTopUp(userId, 1200);
+ request.MarkPaymentSent();
+ dbContext.PaymentRequests.Add(request);
+ await dbContext.SaveChangesAsync(CancellationToken.None);
+
+ _identityService
+ .GetProfileAsync(userId, Arg.Any())
+ .Returns(Profile(userId, paidUntil: existingPaidUntil));
+
+ var handler = new ConfirmPaymentRequestCommandHandler(
+ dbContext,
+ _identityService,
+ _gateway,
+ _notifier,
+ _telegramNotifier,
+ FakeCurrentUser.Authenticated(adminId, "admin"),
+ _logger
+ );
+
+ var result = await handler.Handle(
+ new ConfirmPaymentRequestCommand(request.Id),
+ CancellationToken.None
+ );
+
+ Assert.True(result.IsSuccess);
+ Assert.Equal(PaymentRequestStatus.Confirmed, request.Status);
+ await _identityService
+ .DidNotReceive()
+ .ExtendBillingPaidUntilAsync(
+ Arg.Any(),
+ Arg.Any(),
+ Arg.Any()
+ );
+ }
+
[Fact]
public async Task Handle_WhenRequestAlreadyDecided_ReturnsNotDecidable()
{
diff --git a/backend/tests/PnvPanel.Application.Tests/Admin/Support/ApproveRoleRequestCommandHandlerTests.cs b/backend/tests/PnvPanel.Application.Tests/Admin/Support/ApproveRoleRequestCommandHandlerTests.cs
index 4b94062..54f26de 100644
--- a/backend/tests/PnvPanel.Application.Tests/Admin/Support/ApproveRoleRequestCommandHandlerTests.cs
+++ b/backend/tests/PnvPanel.Application.Tests/Admin/Support/ApproveRoleRequestCommandHandlerTests.cs
@@ -2,9 +2,11 @@ using NSubstitute;
using PnvPanel.Application.Admin.Roles;
using PnvPanel.Application.Admin.Support;
using PnvPanel.Application.Common.Interfaces;
+using PnvPanel.Application.Common.Messaging;
using PnvPanel.Application.Common.Models;
using PnvPanel.Application.Support;
using PnvPanel.Application.Tests.TestSupport;
+using PnvPanel.Domain.Billing;
using PnvPanel.Domain.Support;
using Xunit;
@@ -13,9 +15,33 @@ namespace PnvPanel.Application.Tests.Admin.Support;
public class ApproveRoleRequestCommandHandlerTests
{
private readonly IRoleService _roleService = Substitute.For();
+ private readonly IIdentityService _identityService = Substitute.For();
private readonly IRealtimeNotifier _notifier = Substitute.For();
private readonly ITelegramNotifier _telegramNotifier = Substitute.For();
+ private ApproveRoleRequestCommandHandler CreateHandler(IAppDbContext dbContext, ICurrentUser currentUser) =>
+ new(dbContext, _roleService, _identityService, _notifier, _telegramNotifier, currentUser);
+
+ private static CurrentUserProfile Profile(
+ Guid userId,
+ int maxConfigs,
+ DateTimeOffset? billingPaidUntil = null
+ ) =>
+ new(
+ userId,
+ "alice",
+ Guid.NewGuid(),
+ "old-role",
+ IsActivated: true,
+ IsBlocked: false,
+ MaxConfigs: maxConfigs,
+ MaxIpLimit: 3,
+ SubscriptionToken: "sub-token",
+ BillingEnabled: billingPaidUntil != null,
+ BillingPaidUntil: billingPaidUntil,
+ BillingSuspended: false
+ );
+
[Fact]
public async Task Handle_ForNewRoleRequest_CreatesRoleAssignsAndResolves()
{
@@ -34,13 +60,7 @@ public class ApproveRoleRequestCommandHandlerTests
.Returns(Result.Success());
var currentUser = FakeCurrentUser.Authenticated(Guid.NewGuid(), "admin");
- var handler = new ApproveRoleRequestCommandHandler(
- dbContext,
- _roleService,
- _notifier,
- _telegramNotifier,
- currentUser
- );
+ var handler = CreateHandler(dbContext, currentUser);
var result = await handler.Handle(
new ApproveRoleRequestCommand(ticket.Id),
@@ -75,18 +95,15 @@ public class ApproveRoleRequestCommandHandlerTests
dbContext.SupportTickets.Add(ticket);
await dbContext.SaveChangesAsync(CancellationToken.None);
+ _roleService
+ .ListRolesAsync(Arg.Any())
+ .Returns(new List { new(roleId, "premium", 10, 5, false, false) });
_roleService
.ChangeUserRoleAsync(userId, roleId, Arg.Any())
.Returns(Result.Success());
var currentUser = FakeCurrentUser.Authenticated(Guid.NewGuid());
- var handler = new ApproveRoleRequestCommandHandler(
- dbContext,
- _roleService,
- _notifier,
- _telegramNotifier,
- currentUser
- );
+ var handler = CreateHandler(dbContext, currentUser);
var result = await handler.Handle(
new ApproveRoleRequestCommand(ticket.Id),
@@ -114,13 +131,7 @@ public class ApproveRoleRequestCommandHandlerTests
await dbContext.SaveChangesAsync(CancellationToken.None);
var currentUser = FakeCurrentUser.Authenticated(Guid.NewGuid());
- var handler = new ApproveRoleRequestCommandHandler(
- dbContext,
- _roleService,
- _notifier,
- _telegramNotifier,
- currentUser
- );
+ var handler = CreateHandler(dbContext, currentUser);
var result = await handler.Handle(
new ApproveRoleRequestCommand(ticket.Id),
@@ -143,18 +154,15 @@ public class ApproveRoleRequestCommandHandlerTests
dbContext.SupportTickets.Add(ticket);
await dbContext.SaveChangesAsync(CancellationToken.None);
+ _roleService
+ .ListRolesAsync(Arg.Any())
+ .Returns(new List { new(roleId, "premium", 10, 5, false, false) });
_roleService
.ChangeUserRoleAsync(adminId, roleId, Arg.Any())
.Returns(Result.Success());
var currentUser = FakeCurrentUser.Authenticated(adminId, "admin");
- var handler = new ApproveRoleRequestCommandHandler(
- dbContext,
- _roleService,
- _notifier,
- _telegramNotifier,
- currentUser
- );
+ var handler = CreateHandler(dbContext, currentUser);
var result = await handler.Handle(
new ApproveRoleRequestCommand(ticket.Id),
@@ -175,18 +183,15 @@ public class ApproveRoleRequestCommandHandlerTests
dbContext.SupportTickets.Add(ticket);
await dbContext.SaveChangesAsync(CancellationToken.None);
+ _roleService
+ .ListRolesAsync(Arg.Any())
+ .Returns(new List { new(roleId, "premium", 10, 5, false, false) });
_roleService
.ChangeUserRoleAsync(adminId, roleId, Arg.Any())
.Returns(Result.Failure(RoleErrors.CannotRemoveLastAdmin));
var currentUser = FakeCurrentUser.Authenticated(adminId, "admin");
- var handler = new ApproveRoleRequestCommandHandler(
- dbContext,
- _roleService,
- _notifier,
- _telegramNotifier,
- currentUser
- );
+ var handler = CreateHandler(dbContext, currentUser);
var result = await handler.Handle(
new ApproveRoleRequestCommand(ticket.Id),
@@ -198,4 +203,63 @@ public class ApproveRoleRequestCommandHandlerTests
// Тикет остаётся Open — можно повторить попытку после назначения второго админа.
Assert.Equal(TicketStatus.Open, ticket.Status);
}
+
+ [Fact]
+ public async Task Handle_WhenNewRoleMoreExpensiveWithActivePaidPeriod_CreatesRoleChangeTopUp()
+ {
+ using var dbContext = InMemoryDbContextFactory.Create();
+ var userId = Guid.NewGuid();
+ var roleId = Guid.NewGuid();
+ var ticket = SupportTicket.CreateRoleRequestForExistingRole(userId, roleId);
+ dbContext.SupportTickets.Add(ticket);
+
+ var pricing = PnvPanel.Domain.Pricing.PricingSettings.CreateDefault();
+ pricing.Update(500, 450, 400);
+ dbContext.PricingSettings.Add(pricing);
+ await dbContext.SaveChangesAsync(CancellationToken.None);
+
+ _roleService
+ .ListRolesAsync(Arg.Any())
+ .Returns(new List { new(roleId, "premium", MaxConfigs: 10, 5, false, BillingEnabled: true) });
+ _roleService.ChangeUserRoleAsync(userId, roleId, Arg.Any()).Returns(Result.Success());
+ _identityService
+ .GetProfileAsync(userId, Arg.Any())
+ .Returns(Profile(userId, maxConfigs: 3, billingPaidUntil: DateTimeOffset.UtcNow.AddDays(30)));
+
+ var handler = CreateHandler(dbContext, FakeCurrentUser.Authenticated(Guid.NewGuid(), "admin"));
+
+ var result = await handler.Handle(new ApproveRoleRequestCommand(ticket.Id), CancellationToken.None);
+
+ Assert.True(result.IsSuccess);
+ var topUp = Assert.Single(dbContext.PaymentRequests.Local);
+ Assert.Equal(PaymentRequestKind.RoleChangeTopUp, topUp.Kind);
+ Assert.Null(topUp.Period);
+ Assert.True(topUp.AmountSnapshot > 0);
+ }
+
+ [Fact]
+ public async Task Handle_WhenNoActivePaidPeriod_DoesNotCreateTopUp()
+ {
+ using var dbContext = InMemoryDbContextFactory.Create();
+ var userId = Guid.NewGuid();
+ var roleId = Guid.NewGuid();
+ var ticket = SupportTicket.CreateRoleRequestForExistingRole(userId, roleId);
+ dbContext.SupportTickets.Add(ticket);
+ await dbContext.SaveChangesAsync(CancellationToken.None);
+
+ _roleService
+ .ListRolesAsync(Arg.Any())
+ .Returns(new List { new(roleId, "premium", MaxConfigs: 10, 5, false, BillingEnabled: true) });
+ _roleService.ChangeUserRoleAsync(userId, roleId, Arg.Any()).Returns(Result.Success());
+ _identityService
+ .GetProfileAsync(userId, Arg.Any())
+ .Returns(Profile(userId, maxConfigs: 3, billingPaidUntil: null));
+
+ var handler = CreateHandler(dbContext, FakeCurrentUser.Authenticated(Guid.NewGuid(), "admin"));
+
+ var result = await handler.Handle(new ApproveRoleRequestCommand(ticket.Id), CancellationToken.None);
+
+ Assert.True(result.IsSuccess);
+ Assert.Empty(dbContext.PaymentRequests.Local);
+ }
}
diff --git a/backend/tests/PnvPanel.Application.Tests/Billing/MarkPaymentSent/MarkPaymentSentCommandHandlerTests.cs b/backend/tests/PnvPanel.Application.Tests/Billing/MarkPaymentSent/MarkPaymentSentCommandHandlerTests.cs
index f936059..9760a91 100644
--- a/backend/tests/PnvPanel.Application.Tests/Billing/MarkPaymentSent/MarkPaymentSentCommandHandlerTests.cs
+++ b/backend/tests/PnvPanel.Application.Tests/Billing/MarkPaymentSent/MarkPaymentSentCommandHandlerTests.cs
@@ -41,6 +41,7 @@ public class MarkPaymentSentCommandHandlerTests
.NotifyAdminsPaymentRequestedAsync(
request.Id,
Arg.Any(),
+ PaymentRequestKind.Subscription,
PaymentPeriod.Year,
6000,
Arg.Any()
diff --git a/backend/tests/PnvPanel.Domain.Tests/Billing/RoleChangeTopUpTests.cs b/backend/tests/PnvPanel.Domain.Tests/Billing/RoleChangeTopUpTests.cs
new file mode 100644
index 0000000..490bb7c
--- /dev/null
+++ b/backend/tests/PnvPanel.Domain.Tests/Billing/RoleChangeTopUpTests.cs
@@ -0,0 +1,107 @@
+using PnvPanel.Domain.Billing;
+using PnvPanel.Domain.Pricing;
+using Xunit;
+
+namespace PnvPanel.Domain.Tests.Billing;
+
+public class RoleChangeTopUpTests
+{
+ private static readonly DateTimeOffset Now = new(2026, 1, 1, 0, 0, 0, TimeSpan.Zero);
+
+ [Fact]
+ public void Compute_WhenNewRoleCheaper_ReturnsNull()
+ {
+ var amount = RoleChangeTopUp.Compute(
+ pricePerConfigPerMonth: 200,
+ oldMaxConfigs: 10,
+ newMaxConfigs: 3,
+ discountTiers: [],
+ paidUntil: Now.AddDays(30),
+ now: Now
+ );
+
+ Assert.Null(amount);
+ }
+
+ [Fact]
+ public void Compute_WhenPaidUntilAlreadyExpired_ReturnsNull()
+ {
+ var amount = RoleChangeTopUp.Compute(
+ pricePerConfigPerMonth: 200,
+ oldMaxConfigs: 3,
+ newMaxConfigs: 10,
+ discountTiers: [],
+ paidUntil: Now.AddDays(-1),
+ now: Now
+ );
+
+ Assert.Null(amount);
+ }
+
+ [Fact]
+ public void Compute_WhenEitherRoleUnlimited_ReturnsNull()
+ {
+ var amount = RoleChangeTopUp.Compute(
+ pricePerConfigPerMonth: 200,
+ oldMaxConfigs: 3,
+ newMaxConfigs: -1,
+ discountTiers: [],
+ paidUntil: Now.AddDays(30),
+ now: Now
+ );
+
+ Assert.Null(amount);
+ }
+
+ [Fact]
+ public void Compute_WhenUpgrading_ReturnsProratedDifference()
+ {
+ // Старая роль: 200 * 3 = 600 ₽/мес. Новая: 200 * 10 = 2000 ₽/мес. Разница 1400 ₽/мес,
+ // за 30 дней (ровно месяц) — 1400 ₽.
+ var amount = RoleChangeTopUp.Compute(
+ pricePerConfigPerMonth: 200,
+ oldMaxConfigs: 3,
+ newMaxConfigs: 10,
+ discountTiers: [],
+ paidUntil: Now.AddDays(30),
+ now: Now
+ );
+
+ Assert.Equal(1400, amount);
+ }
+
+ [Fact]
+ public void Compute_ProratesToRemainingDaysOnly()
+ {
+ // Та же разница 1400 ₽/мес, но остаётся только 15 из 30 дней — половина.
+ var amount = RoleChangeTopUp.Compute(
+ pricePerConfigPerMonth: 200,
+ oldMaxConfigs: 3,
+ newMaxConfigs: 10,
+ discountTiers: [],
+ paidUntil: Now.AddDays(15),
+ now: Now
+ );
+
+ Assert.Equal(700, amount);
+ }
+
+ [Fact]
+ public void Compute_AppliesDiscountTiersToBothRoles()
+ {
+ var tiers = new[] { PricingDiscountTier.Create(Guid.NewGuid(), minConfigs: 10, discountPercent: 10) };
+
+ // Старая роль (3 конфига, без скидки): 200*3 = 600. Новая (10 конфигов, порог скидки достигнут):
+ // 200*10 = 2000, -10% = 1800. Разница 1200 ₽/мес, за 30 дней — 1200 ₽.
+ var amount = RoleChangeTopUp.Compute(
+ pricePerConfigPerMonth: 200,
+ oldMaxConfigs: 3,
+ newMaxConfigs: 10,
+ discountTiers: tiers,
+ paidUntil: Now.AddDays(30),
+ now: Now
+ );
+
+ Assert.Equal(1200, amount);
+ }
+}
diff --git a/docs/api-design.md b/docs/api-design.md
index d6f864b..4868db6 100644
--- a/docs/api-design.md
+++ b/docs/api-design.md
@@ -154,8 +154,8 @@ status, createdAt }`. `expiresAt` всегда `null` (лимиты по сро
| Метод | Путь | Тело запроса | Тело ответа |
| ----- | -------------------------------------------- | ---------------------- | ------------- |
-| GET | `/api/billing/status` | — | `BillingStatusDto { billingEnabled, paidUntil, suspended, requisitesText, activeRequest: PaymentRequestDto \| null }` |
-| POST | `/api/billing/requests` | `{ period }` (`Quarter`/`HalfYear`/`Year`) | `PaymentRequestDto` (`409 Billing.ActiveRequestExists`, если уже есть активная заявка; `409 Billing.UnlimitedRoleNotSupported` для ролей с `MaxConfigs=-1`; `500 Billing.PricingNotConfigured`, если ставка для периода не задана) |
+| GET | `/api/billing/status` | — | `BillingStatusDto { billingEnabled, paidUntil, suspended, requisitesText, activeRequest: PaymentRequestDto \| null }`. `PaymentRequestDto` теперь несёт `kind` (`Subscription`/`RoleChangeTopUp`) и `period: PaymentPeriod \| null` (`null` для `RoleChangeTopUp` — см. domain-model.md#rolechangetopup). Если у пользователя одновременно активны заявки обоих `Kind`, видна только одна (см. известное ограничение там же) |
+| POST | `/api/billing/requests` | `{ period }` (`Quarter`/`HalfYear`/`Year`) | `PaymentRequestDto` (`Kind.Subscription`; `409 Billing.ActiveRequestExists`, если уже есть активная заявка **того же Kind** — активный `RoleChangeTopUp` не блокирует; `409 Billing.UnlimitedRoleNotSupported` для ролей с `MaxConfigs=-1`; `500 Billing.PricingNotConfigured`, если ставка для периода не задана) |
| POST | `/api/billing/requests/{id}/cancel` | — | `204 No Content` (только из `AwaitingPayment`) |
| POST | `/api/billing/requests/{id}/mark-paid` | — | `204 No Content` (`AwaitingPayment → AwaitingConfirmation`, уведомляет админов в Telegram) |
| POST | `/api/billing/requests/{id}/send-requisites` | — | `204 No Content` (дублирует реквизиты в свой Telegram; `409 Telegram.NotLinked`, если Telegram не привязан) |
@@ -203,7 +203,7 @@ Support.CannotRequestAdminRole`), либо все три поля новой р
| POST | `/api/admin/support/tickets/{id}/comments` | multipart: `body` + `files[]` | `TicketCommentDto` |
| POST | `/api/admin/support/tickets/{id}/resolve` | — | `204 No Content` (любой тип, только из `Open`) |
| POST | `/api/admin/support/tickets/{id}/close` | — | `204 No Content` (любой тип, финал) |
-| POST | `/api/admin/support/tickets/{id}/approve` | — | `204 No Content` (только `RoleRequest`/`Open`; создаёt/назначает роль) |
+| POST | `/api/admin/support/tickets/{id}/approve` | — | `204 No Content` (только `RoleRequest`/`Open`; создаёt/назначает роль сразу; если новая роль дороже старой и у пользователя активен `BillingPaidUntil` — дополнительно создаёт `PaymentRequest(Kind.RoleChangeTopUp)` на разницу в цене, см. domain-model.md#rolechangetopup) |
| POST | `/api/admin/support/tickets/{id}/reject` | `{ reason? }` | `204 No Content` (только `RoleRequest`; `reason` уходит комментарием) |
| POST | `/api/admin/support/tickets/{id}/approve-extension` | — | `204 No Content` (только `ExtensionRequest`/`Open`; продлевает `BillingPaidUntil` на `RequestedDays`) |
| POST | `/api/admin/support/tickets/{id}/reject-extension` | `{ reason? }` | `204 No Content` (только `ExtensionRequest`; `reason` уходит комментарием) |
@@ -278,8 +278,8 @@ approve/reject над `ActivationRequest`.
| ----- | -------------------------------------------- | ----- | ---------------------------- | ------------- |
| GET | `/api/admin/billing/settings` | admin | — | `BillingSettingsDto { requisitesText, graceDays, defaultBillingEnabledForNewRoles }` |
| PUT | `/api/admin/billing/settings` | admin | `{ requisitesText, graceDays, defaultBillingEnabledForNewRoles }` | `BillingSettingsDto` |
-| GET | `/api/admin/billing/requests` | admin | query: `status?, page=1, pageSize=20` | `PagedList` (включает `userName`) |
-| POST | `/api/admin/billing/requests/{id}/confirm` | admin | — | `204 No Content` (продлевает `BillingPaidUntil`, возвращает приостановленные конфиги в `Active`) |
+| GET | `/api/admin/billing/requests` | admin | query: `status?, page=1, pageSize=20` | `PagedList` (включает `userName`, `kind`, `period: PaymentPeriod \| null`) |
+| POST | `/api/admin/billing/requests/{id}/confirm` | admin | — | `204 No Content` (для `Kind.Subscription` продлевает `BillingPaidUntil` и возвращает приостановленные конфиги в `Active`; для `Kind.RoleChangeTopUp` — только помечает `Confirmed`, `BillingPaidUntil` не трогает, см. domain-model.md#rolechangetopup) |
| POST | `/api/admin/billing/requests/{id}/reject` | admin | `{ reason? }` | `204 No Content` |
| POST | `/api/admin/billing/gift` | admin | `{ userId, days }` | `204 No Content` (продлевает `BillingPaidUntil` на `days` от `max(текущий, сейчас)`, возвращает приостановленные конфиги, шлёт Telegram-уведомление пользователю; `403 Billing.NotEnabled`, если роль пользователя не billing) |
diff --git a/docs/domain-model.md b/docs/domain-model.md
index ef610b1..3ef60f0 100644
--- a/docs/domain-model.md
+++ b/docs/domain-model.md
@@ -417,33 +417,73 @@ Singleton (как `PricingSettings`) — реквизиты для оплаты
### PaymentRequest — заявка на оплату
Пользователь оформляет заявку на период (3/6/12 мес); решает админ на сайте или в Telegram. Не более
-одной активной (`AwaitingPayment`/`AwaitingConfirmation`) заявки на пользователя — инвариант
-проверяется в `CreatePaymentRequestCommandHandler`.
+одной активной (`AwaitingPayment`/`AwaitingConfirmation`) заявки **`Kind.Subscription`** на
+пользователя — инвариант проверяется в `CreatePaymentRequestCommandHandler` и не распространяется на
+`Kind.RoleChangeTopUp` (см. ниже) — доплата не должна мешать оформить/продлить обычную подписку.
| Поле | Тип | Заметки |
| ------------------ | ----------------------- | ------------------------------------------------------------ |
| `Id` | `Guid` | PK |
| `UserId` | `Guid` | FK → AppUser (заявитель) |
-| `Period` | `PaymentPeriod` | `Quarter` (3 мес) / `HalfYear` (6 мес) / `Year` (12 мес) |
-| `AmountSnapshot` | `int` | Сумма, замороженная на момент создания: `ставка PricingSettings за период × MaxConfigs роли × число месяцев`, затем скидка по лесенке `PricingDiscountTier` (см. выше), если применима. Последующее изменение прайса/лесенки админом не меняет уже созданные заявки |
+| `Kind` | `PaymentRequestKind` | `Subscription` (оплата за период) / `RoleChangeTopUp` (доплата за апгрейд роли, см. ниже) |
+| `Period` | `PaymentPeriod?` | `Quarter` (3 мес) / `HalfYear` (6 мес) / `Year` (12 мес). `null` для `Kind.RoleChangeTopUp` — доплата не привязана к тарифному периоду |
+| `AmountSnapshot` | `int` | Сумма, замороженная на момент создания. Для `Subscription`: `ставка PricingSettings за период × MaxConfigs роли × число месяцев`, затем скидка по лесенке `PricingDiscountTier` (см. выше), если применима. Для `RoleChangeTopUp`: см. `RoleChangeTopUp.Compute` ниже. Последующее изменение прайса/лесенки админом не меняет уже созданные заявки |
| `Status` | `PaymentRequestStatus` | `AwaitingPayment` → `AwaitingConfirmation` → `Confirmed`/`Rejected`, либо `Cancelled` из `AwaitingPayment` |
| `DecidedBy`/`DecidedAt`/`RejectionReason` | | Кто/когда решил, причина отказа (опционально) |
| `CreatedAt` | `DateTimeOffset` | |
Роль с `MaxConfigs = -1` (unlimited) не поддерживает биллинг по формуле —
-`CreatePaymentRequestCommandHandler` отдаёт `BillingErrors.UnlimitedRoleNotSupported`.
+`CreatePaymentRequestCommandHandler` отдаёт `BillingErrors.UnlimitedRoleNotSupported`; та же логика в
+`RoleChangeTopUp.Compute` (`null`, доплата не считается).
**Переходы** (`backend/src/PnvPanel.Domain/Billing/PaymentRequest.cs`):
-- `Create(userId, period, amount)` → `AwaitingPayment`, показываются реквизиты `BillingSettings`.
- Пользователь может `Cancel()` (только из `AwaitingPayment`) или дождаться проверки.
+- `Create(userId, period, amount)` (`Kind.Subscription`) / `CreateRoleChangeTopUp(userId, amount)`
+ (`Kind.RoleChangeTopUp`) → `AwaitingPayment`, показываются реквизиты `BillingSettings`. Пользователь
+ может `Cancel()` (только из `AwaitingPayment`) или дождаться проверки.
- `MarkPaymentSent()` → пользователь нажал «Я оплатил»; `AwaitingPayment → AwaitingConfirmation`,
- админам уходит Telegram-уведомление с инлайн-кнопками `pay:approve:{id}`/`pay:reject:{id}`.
+ админам уходит Telegram-уведомление с инлайн-кнопками `pay:approve:{id}`/`pay:reject:{id}` (текст
+ уведомления зависит от `Kind` — период или «доплата за смену роли», см. `TelegramNotifier`).
- `Confirm(adminId)`/`Reject(adminId, reason)` → допустимы из **обоих** `AwaitingPayment` и
`AwaitingConfirmation` (админ мог заметить оплату раньше, чем пользователь нажал кнопку).
- `Confirm` продлевает `AppUser.BillingPaidUntil = max(текущий, сейчас) + период` (не теряет уже
- оплаченный остаток при досрочной оплате), возвращает в `Active` конфиги, приостановленные за
- неуплату (`Suspend()`/`Resume()` на `VpnConfig`, статус `Expired`), обновляет `ExpiresAt` на всех
- конфигах пользователя.
+ Для `Kind.Subscription` `Confirm` продлевает `AppUser.BillingPaidUntil = max(текущий, сейчас) +
+ период` (не теряет уже оплаченный остаток при досрочной оплате), возвращает в `Active` конфиги,
+ приостановленные за неуплату (`Suspend()`/`Resume()` на `VpnConfig`, статус `Expired`), обновляет
+ `ExpiresAt` на всех конфигах пользователя. Для `Kind.RoleChangeTopUp` `Confirm` **только** переводит
+ заявку в `Confirmed` — `BillingPaidUntil` не трогает (это не покупка времени, а закрытие долга за уже
+ выданный апгрейд) — см. `ConfirmPaymentRequestCommandHandler`.
+
+#### RoleChangeTopUp — доплата при апгрейде роли с активным периодом
+
+Пользователь с активным `BillingPaidUntil` меняет роль (тикетом `SupportTicket.RoleRequest`,
+`ApproveRoleRequestCommandHandler`) на более дорогую — по-хорошему должен доплатить разницу, а не
+доиграть апгрейд бесплатно до конца уже оплаченного срока. **Роль меняется сразу** (не блокируется
+ожиданием оплаты); доплата решается отдельно через обычный флоу `PaymentRequest`
+(`Kind.RoleChangeTopUp`) — тем же путём, что и обычная оплата: сайт (`billing.tsx`,
+`PaymentRequestPanel`) или Telegram (`pay:approve`/`pay:reject`).
+
+Сумма — `RoleChangeTopUp.Compute` (`backend/src/PnvPanel.Domain/Billing/RoleChangeTopUp.cs`), чистая
+функция без I/O:
+1. Месячная стоимость роли = `PricingSettings.PricePerConfigPerQuarter × MaxConfigs`, затем скидка по
+ лесенке `PricingDiscountTier` (`PricingDiscount.ResolvePercent`/`Apply`) — та же формула и тот же
+ базовый (квартальный/минимальный) тариф, что у обычной оплаты, независимо от того, за какой период
+ пользователь платил на самом деле — упрощение, чтобы не вводить отдельное понятие «дневная ставка
+ по фактическому тарифу».
+2. Разница месячных стоимостей новой и старой роли, поделённая на 30 (условный «месяц» для
+ проратирования) и умноженная на число оставшихся до `BillingPaidUntil` дней — округление до целого
+ рубля (`MidpointRounding.AwayFromZero`).
+3. `null` (доплата не создаётся), если: новая роль не дороже старой (в т.ч. понижение — остаётся
+ грандфазеринг, без доплаты и без возврата), оплаченный период уже истёк, либо старая/новая роль без
+ лимита конфигов (`MaxConfigs = -1`, цена не считается).
+
+Применяется только к самостоятельной заявке на роль (`ApproveRoleRequestCommandHandler`) — админская
+прямая смена роли (`PATCH /api/admin/users/{id}/role`, `UserManageDialog`) доплату не создаёт: это
+осознанный инструмент админа, который может быть применён как поощрение.
+
+**Известное ограничение**: `GetMyBillingStatusQueryHandler` отдаёт только одну `activeRequest` —
+если у пользователя одновременно есть активная `Subscription`-заявка и `RoleChangeTopUp` (редкий
+случай: роль сменили, пока уже шла обычная оплата), на странице `/billing` будет видна только одна из
+них (обе видны в админке и обе решаемы через Telegram). Не устранено — узкий edge case, не блокирует
+основной сценарий.
### BillingService — приостановка за неуплату (фоновая джоба)
`Infrastructure/BackgroundJobs/BillingService.cs`, раз в час (по образцу `TrafficSyncService`). Для
diff --git a/frontend/src/features/billing/PaymentRequestPanel.tsx b/frontend/src/features/billing/PaymentRequestPanel.tsx
index e2c3924..c2dd19a 100644
--- a/frontend/src/features/billing/PaymentRequestPanel.tsx
+++ b/frontend/src/features/billing/PaymentRequestPanel.tsx
@@ -103,8 +103,12 @@ export function PaymentRequestPanel({ status }: { status: BillingStatusDto }) {