From 5ff5224935ee99756a78213c5873b3e7164a7056 Mon Sep 17 00:00:00 2001 From: Leonid Pershin Date: Sun, 19 Jul 2026 16:45:17 +0300 Subject: [PATCH] Refactor payment request handling to support role change top-ups - Updated the `PaymentRequest` model to include a new `Kind` property, distinguishing between `Subscription` and `RoleChangeTopUp` requests. - Modified the `TelegramNotifier` to accommodate the new request type, ensuring accurate notifications for role change top-ups. - Enhanced the `ConfirmPaymentRequestCommandHandler` to handle role change top-ups without extending the billing period, reflecting the new payment logic. - Updated various application components and tests to support the new payment request structure and ensure proper functionality. - Revised API documentation to clarify the behavior of role change top-ups and their impact on billing. --- .../PnvPanel.Api/Telegram/TelegramNotifier.cs | 7 +- .../Admin/Billing/AdminPaymentRequestDto.cs | 3 +- .../ConfirmPaymentRequestCommandHandler.cs | 30 +- .../ListPaymentRequestsQueryHandler.cs | 1 + .../ApproveRoleRequestCommandHandler.cs | 64 +- .../CreatePaymentRequestCommandHandler.cs | 3 + .../MarkPaymentSentCommandHandler.cs | 1 + .../Billing/PaymentRequestDto.cs | 5 +- .../SendRequisitesToTelegramCommandHandler.cs | 5 +- .../Common/Interfaces/ITelegramNotifier.cs | 6 +- .../PnvPanel.Domain/Billing/PaymentRequest.cs | 31 +- .../Billing/PaymentRequestKind.cs | 10 + .../Billing/RoleChangeTopUp.cs | 53 + .../PaymentRequestConfiguration.cs | 1 + ...19130201_AddPaymentRequestKind.Designer.cs | 1073 +++++++++++++++++ .../20260719130201_AddPaymentRequestKind.cs | 52 + .../Migrations/AppDbContextModelSnapshot.cs | 6 +- ...onfirmPaymentRequestCommandHandlerTests.cs | 42 + .../ApproveRoleRequestCommandHandlerTests.cs | 134 +- .../MarkPaymentSentCommandHandlerTests.cs | 1 + .../Billing/RoleChangeTopUpTests.cs | 107 ++ docs/api-design.md | 10 +- docs/domain-model.md | 64 +- .../features/billing/PaymentRequestPanel.tsx | 6 +- frontend/src/routes/admin/billing.tsx | 4 +- frontend/src/shared/api/types.ts | 9 +- frontend/src/shared/lib/i18n.ts | 4 + 27 files changed, 1656 insertions(+), 76 deletions(-) create mode 100644 backend/src/PnvPanel.Domain/Billing/PaymentRequestKind.cs create mode 100644 backend/src/PnvPanel.Domain/Billing/RoleChangeTopUp.cs create mode 100644 backend/src/PnvPanel.Infrastructure/Persistence/Migrations/20260719130201_AddPaymentRequestKind.Designer.cs create mode 100644 backend/src/PnvPanel.Infrastructure/Persistence/Migrations/20260719130201_AddPaymentRequestKind.cs create mode 100644 backend/tests/PnvPanel.Domain.Tests/Billing/RoleChangeTopUpTests.cs 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 }) {

- {t(`billing.periods.${request.period}`)} — {request.amountSnapshot} ₽ + {request.kind === 'RoleChangeTopUp' ? t('billing.roleChangeTopUp') : t(`billing.periods.${request.period}`)} —{' '} + {request.amountSnapshot} ₽

+ {request.kind === 'RoleChangeTopUp' && ( +

{t('billing.roleChangeTopUpHint')}

+ )} {!isAwaitingConfirmation && (
diff --git a/frontend/src/routes/admin/billing.tsx b/frontend/src/routes/admin/billing.tsx index 3c928ea..4a49f40 100644 --- a/frontend/src/routes/admin/billing.tsx +++ b/frontend/src/routes/admin/billing.tsx @@ -162,7 +162,9 @@ function RequestsSection() { {data.items.map((request) => ( {request.userName} - {t(`billing.periods.${request.period}`)} + + {request.kind === 'RoleChangeTopUp' ? t('billing.roleChangeTopUp') : t(`billing.periods.${request.period}`)} + {request.amountSnapshot} ₽ diff --git a/frontend/src/shared/api/types.ts b/frontend/src/shared/api/types.ts index 397a997..ce16a6d 100644 --- a/frontend/src/shared/api/types.ts +++ b/frontend/src/shared/api/types.ts @@ -202,10 +202,14 @@ export type PaymentRequestStatus = | 'Confirmed' | 'Rejected' | 'Cancelled' +/** Subscription — оплата за период (period задан). RoleChangeTopUp — доплата разницы в цене при + * апгрейде роли с активным оплаченным периодом (period null, не продлевает paidUntil). */ +export type PaymentRequestKind = 'Subscription' | 'RoleChangeTopUp' export type PaymentRequestDto = { id: string - period: PaymentPeriod + kind: PaymentRequestKind + period: PaymentPeriod | null amountSnapshot: number status: PaymentRequestStatus createdAt: string @@ -231,7 +235,8 @@ export type AdminPaymentRequestDto = { id: string userId: string userName: string - period: PaymentPeriod + kind: PaymentRequestKind + period: PaymentPeriod | null amountSnapshot: number status: PaymentRequestStatus createdAt: string diff --git a/frontend/src/shared/lib/i18n.ts b/frontend/src/shared/lib/i18n.ts index 9105584..92255f3 100644 --- a/frontend/src/shared/lib/i18n.ts +++ b/frontend/src/shared/lib/i18n.ts @@ -142,6 +142,8 @@ const resources = { confirmCancel: 'Отменить заявку на оплату?', requestCancelled: 'Заявка отменена.', awaitingAdminHint: 'Администратор уведомлён и проверит оплату. Конфиги не отключатся, пока заявка не решена.', + roleChangeTopUp: 'Доплата за смену роли', + roleChangeTopUpHint: 'Новая роль дороже прежней — эта сумма покрывает разницу в цене за оставшуюся часть уже оплаченного периода, срок подписки при этом не меняется.', }, instructions: { @@ -680,6 +682,8 @@ const resources = { confirmCancel: 'Cancel this payment request?', requestCancelled: 'Request cancelled.', awaitingAdminHint: 'The administrator has been notified and will verify the payment. Configs stay active until the request is decided.', + roleChangeTopUp: 'Role change top-up', + roleChangeTopUpHint: "Your new role costs more than the old one — this amount covers the price difference for the remaining part of your already-paid period; your subscription end date doesn't change.", }, instructions: {