Refactor payment request handling to support role change top-ups
CI / Backend (build + test) (push) Successful in 1m19s
CI / Frontend (lint + typecheck + build) (push) Successful in 33s

- 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.
This commit is contained in:
Leonid Pershin
2026-07-19 16:45:17 +03:00
parent 0dcaf1203f
commit 5ff5224935
27 changed files with 1656 additions and 76 deletions
@@ -270,7 +270,8 @@ internal sealed class TelegramNotifier(
public async Task NotifyAdminsPaymentRequestedAsync( public async Task NotifyAdminsPaymentRequestedAsync(
Guid requestId, Guid requestId,
string userName, string userName,
PaymentPeriod period, PaymentRequestKind kind,
PaymentPeriod? period,
int amount, int amount,
CancellationToken cancellationToken CancellationToken cancellationToken
) )
@@ -278,8 +279,8 @@ internal sealed class TelegramNotifier(
if (string.IsNullOrWhiteSpace(options.Value.BotToken)) if (string.IsNullOrWhiteSpace(options.Value.BotToken))
return; return;
var text = var reason = kind == PaymentRequestKind.RoleChangeTopUp ? "доплате за смену роли" : $"оплате за {PeriodLabel(period!.Value)}";
$"💰 <b>{Escape(userName)}</b> заявляет об оплате за {PeriodLabel(period)} — {amount} ₽\nПроверьте поступление и подтвердите."; var text = $"💰 <b>{Escape(userName)}</b> заявляет о {reason} — {amount} ₽\nПроверьте поступление и подтвердите.";
var keyboard = new InlineKeyboardMarkup( var keyboard = new InlineKeyboardMarkup(
new[] new[]
@@ -6,7 +6,8 @@ public sealed record AdminPaymentRequestDto(
Guid Id, Guid Id,
Guid UserId, Guid UserId,
string UserName, string UserName,
PaymentPeriod Period, PaymentRequestKind Kind,
PaymentPeriod? Period,
int AmountSnapshot, int AmountSnapshot,
PaymentRequestStatus Status, PaymentRequestStatus Status,
DateTimeOffset CreatedAt DateTimeOffset CreatedAt
@@ -50,9 +50,37 @@ public sealed class ConfirmPaymentRequestCommandHandler(
if (profile is null) if (profile is null)
return Result.Failure(AuthErrors.Unauthorized); 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 now = DateTimeOffset.UtcNow;
var baseline = profile.BillingPaidUntil is { } paidUntil && paidUntil > now ? paidUntil : now; 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( var extendResult = await identityService.ExtendBillingPaidUntilAsync(
request.UserId, request.UserId,
@@ -36,6 +36,7 @@ public sealed class ListPaymentRequestsQueryHandler(
r.Id, r.Id,
r.UserId, r.UserId,
userNames.GetValueOrDefault(r.UserId, "?"), userNames.GetValueOrDefault(r.UserId, "?"),
r.Kind,
r.Period, r.Period,
r.AmountSnapshot, r.AmountSnapshot,
r.Status, r.Status,
@@ -5,6 +5,7 @@ using PnvPanel.Application.Common.Messaging;
using PnvPanel.Application.Common.Models; using PnvPanel.Application.Common.Models;
using PnvPanel.Application.Support; using PnvPanel.Application.Support;
using PnvPanel.Domain.Audit; using PnvPanel.Domain.Audit;
using PnvPanel.Domain.Billing;
using PnvPanel.Domain.Support; using PnvPanel.Domain.Support;
namespace PnvPanel.Application.Admin.Support; namespace PnvPanel.Application.Admin.Support;
@@ -12,6 +13,7 @@ namespace PnvPanel.Application.Admin.Support;
public sealed class ApproveRoleRequestCommandHandler( public sealed class ApproveRoleRequestCommandHandler(
IAppDbContext dbContext, IAppDbContext dbContext,
IRoleService roleService, IRoleService roleService,
IIdentityService identityService,
IRealtimeNotifier notifier, IRealtimeNotifier notifier,
ITelegramNotifier telegramNotifier, ITelegramNotifier telegramNotifier,
ICurrentUser currentUser ICurrentUser currentUser
@@ -38,10 +40,19 @@ public sealed class ApproveRoleRequestCommandHandler(
if (ticket.Status != TicketStatus.Open) if (ticket.Status != TicketStatus.Open)
return Result.Failure(SupportErrors.NotOpen); 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) 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 else
{ {
@@ -55,12 +66,12 @@ public sealed class ApproveRoleRequestCommandHandler(
if (!createResult.IsSuccess) if (!createResult.IsSuccess)
return Result.Failure(createResult.Error); return Result.Failure(createResult.Error);
roleId = createResult.Value.Id; newRole = createResult.Value;
} }
var assignResult = await roleService.ChangeUserRoleAsync( var assignResult = await roleService.ChangeUserRoleAsync(
ticket.UserId, ticket.UserId,
roleId, newRole.Id,
cancellationToken cancellationToken
); );
if (!assignResult.IsSuccess) if (!assignResult.IsSuccess)
@@ -87,6 +98,51 @@ public sealed class ApproveRoleRequestCommandHandler(
cancellationToken cancellationToken
); );
if (newRole.BillingEnabled && oldProfile?.BillingPaidUntil is { } paidUntil)
await CreateTopUpIfNeededAsync(ticket.UserId, oldProfile.MaxConfigs, newRole.MaxConfigs, paidUntil, cancellationToken);
return Result.Success(); return Result.Success();
} }
/// <summary>Роль подорожала, а оплаченный период ещё активен — по-хорошему пользователь должен
/// доплатить разницу, а не доиграть апгрейд бесплатно до конца уже оплаченного срока. Роль меняется
/// сразу (см. выше); доплата решается отдельно через обычный флоу PaymentRequest — см.
/// domain-model.md#rolechangetopup.</summary>
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
);
}
} }
@@ -32,9 +32,12 @@ public sealed class CreatePaymentRequestCommandHandler(
if (profile.MaxConfigs == RoleQuota.Unlimited) if (profile.MaxConfigs == RoleQuota.Unlimited)
return Result.Failure<PaymentRequestDto>(BillingErrors.UnlimitedRoleNotSupported); return Result.Failure<PaymentRequestDto>(BillingErrors.UnlimitedRoleNotSupported);
// RoleChangeTopUp не считается активной подписной заявкой — доплата за смену роли не должна
// мешать пользователю продлить/оформить обычную подписку.
var hasActiveRequest = await dbContext.PaymentRequests.AnyAsync( var hasActiveRequest = await dbContext.PaymentRequests.AnyAsync(
r => r =>
r.UserId == userId r.UserId == userId
&& r.Kind == PaymentRequestKind.Subscription
&& ( && (
r.Status == PaymentRequestStatus.AwaitingPayment r.Status == PaymentRequestStatus.AwaitingPayment
|| r.Status == PaymentRequestStatus.AwaitingConfirmation || r.Status == PaymentRequestStatus.AwaitingConfirmation
@@ -35,6 +35,7 @@ public sealed class MarkPaymentSentCommandHandler(
await telegramNotifier.NotifyAdminsPaymentRequestedAsync( await telegramNotifier.NotifyAdminsPaymentRequestedAsync(
request.Id, request.Id,
profile?.UserName ?? userId.ToString(), profile?.UserName ?? userId.ToString(),
request.Kind,
request.Period, request.Period,
request.AmountSnapshot, request.AmountSnapshot,
cancellationToken cancellationToken
@@ -4,12 +4,13 @@ namespace PnvPanel.Application.Billing;
public sealed record PaymentRequestDto( public sealed record PaymentRequestDto(
Guid Id, Guid Id,
PaymentPeriod Period, PaymentRequestKind Kind,
PaymentPeriod? Period,
int AmountSnapshot, int AmountSnapshot,
PaymentRequestStatus Status, PaymentRequestStatus Status,
DateTimeOffset CreatedAt DateTimeOffset CreatedAt
) )
{ {
public static PaymentRequestDto FromDomain(PaymentRequest request) => 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);
} }
@@ -38,13 +38,16 @@ public sealed class SendRequisitesToTelegramCommandHandler(
.FirstOrDefaultAsync(cancellationToken); .FirstOrDefaultAsync(cancellationToken);
var text = var text =
$"💳 Реквизиты для оплаты ({PeriodLabel(request.Period)}, {request.AmountSnapshot} ₽):\n{settings?.RequisitesText}"; $"💳 Реквизиты для оплаты ({DescribeRequest(request)}, {request.AmountSnapshot} ₽):\n{settings?.RequisitesText}";
await telegramNotifier.NotifyUserAsync(userId, text, null, cancellationToken); await telegramNotifier.NotifyUserAsync(userId, text, null, cancellationToken);
return Result.Success(); return Result.Success();
} }
private static string DescribeRequest(PaymentRequest request) =>
request.Kind == PaymentRequestKind.RoleChangeTopUp ? "доплата за смену роли" : PeriodLabel(request.Period!.Value);
private static string PeriodLabel(PaymentPeriod period) => private static string PeriodLabel(PaymentPeriod period) =>
period switch period switch
{ {
@@ -54,11 +54,13 @@ public interface ITelegramNotifier
); );
/// <summary>Пользователь нажал «Я оплатил» — инлайн-кнопки «Подтвердить/Отклонить», решается /// <summary>Пользователь нажал «Я оплатил» — инлайн-кнопки «Подтвердить/Отклонить», решается
/// полностью в Telegram (аналогично заявке на роль).</summary> /// полностью в Telegram (аналогично заявке на роль). Period задан только для Kind.Subscription —
/// для Kind.RoleChangeTopUp он null (доплата не привязана к тарифному периоду).</summary>
Task NotifyAdminsPaymentRequestedAsync( Task NotifyAdminsPaymentRequestedAsync(
Guid requestId, Guid requestId,
string userName, string userName,
PaymentPeriod period, PaymentRequestKind kind,
PaymentPeriod? period,
int amount, int amount,
CancellationToken cancellationToken CancellationToken cancellationToken
); );
@@ -4,15 +4,21 @@ using PnvPanel.Domain.Exceptions;
namespace PnvPanel.Domain.Billing; namespace PnvPanel.Domain.Billing;
/// <summary> /// <summary>
/// Заявка пользователя на оплату подписки за период (квартал/полгода/год). Сумма замораживается на /// Заявка пользователя на оплату. Kind.Subscription — оплата подписки за период (квартал/полгода/год,
/// момент создания (по действовавшей на тот момент ставке PricingSettings) — последующее изменение /// Period задан), продлевает BillingPaidUntil при подтверждении. Kind.RoleChangeTopUp — доплата разницы
/// прайса админом не меняет уже созданные заявки. Не более одной активной (AwaitingPayment/ /// в цене при апгрейде роли с активным оплаченным периодом (Period не задан), подтверждение НЕ меняет
/// AwaitingConfirmation) заявки на пользователя — инвариант проверяется на уровне Application. /// BillingPaidUntil — см. RoleChangeTopUp.Compute и ConfirmPaymentRequestCommandHandler. Сумма
/// замораживается на момент создания (по действовавшей на тот момент ставке PricingSettings) —
/// последующее изменение прайса админом не меняет уже созданные заявки. Не более одной активной
/// (AwaitingPayment/AwaitingConfirmation) заявки Kind.Subscription на пользователя — инвариант
/// проверяется на уровне Application; RoleChangeTopUp создаётся системой при одобрении заявки на роль
/// и этим инвариантом не ограничен.
/// </summary> /// </summary>
public sealed class PaymentRequest : Entity public sealed class PaymentRequest : Entity
{ {
public Guid UserId { get; private set; } 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 int AmountSnapshot { get; private set; }
public PaymentRequestStatus Status { get; private set; } public PaymentRequestStatus Status { get; private set; }
public Guid? DecidedBy { get; private set; } public Guid? DecidedBy { get; private set; }
@@ -28,6 +34,7 @@ public sealed class PaymentRequest : Entity
{ {
Id = Guid.NewGuid(), Id = Guid.NewGuid(),
UserId = userId, UserId = userId,
Kind = PaymentRequestKind.Subscription,
Period = period, Period = period,
AmountSnapshot = amountSnapshot, AmountSnapshot = amountSnapshot,
Status = PaymentRequestStatus.AwaitingPayment, 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,
};
}
/// <summary>Пользователь нажал «Я оплатил» — уходит на подтверждение админом.</summary> /// <summary>Пользователь нажал «Я оплатил» — уходит на подтверждение админом.</summary>
public void MarkPaymentSent() public void MarkPaymentSent()
{ {
@@ -0,0 +1,10 @@
namespace PnvPanel.Domain.Billing;
/// <summary>Subscription — обычная оплата за период (Period задан). RoleChangeTopUp — доплата разницы
/// в цене при апгрейде роли с активным оплаченным периодом (Period не задан, не продлевает
/// BillingPaidUntil) — см. RoleChangeTopUp.Compute.</summary>
public enum PaymentRequestKind
{
Subscription,
RoleChangeTopUp,
}
@@ -0,0 +1,53 @@
using PnvPanel.Domain.Pricing;
namespace PnvPanel.Domain.Billing;
/// <summary>
/// Проратированная доплата при апгрейде роли с активным оплаченным периодом: пользователь платит
/// разницу в месячной стоимости между старой и новой ролью за оставшиеся дни BillingPaidUntil, а не
/// выкупает период заново — так более выгодная роль не достаётся бесплатно до конца уже оплаченного
/// периода. Базовая ставка — PricePerConfigPerQuarter (минимальный/справочный тариф), с той же
/// скидочной лесенкой (PricingDiscountTier), что и обычная оплата — см. domain-model.md#rolechangetopup.
/// </summary>
public static class RoleChangeTopUp
{
private const int DaysPerMonth = 30;
/// <summary>Null, если доплата не нужна: роль не подорожала, оплаченный период уже истёк, или
/// у старой/новой роли нет квоты (unlimited — цена для неё не считается).</summary>
public static int? Compute(
int pricePerConfigPerMonth,
int oldMaxConfigs,
int newMaxConfigs,
IReadOnlyCollection<PricingDiscountTier> 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<PricingDiscountTier> tiers
)
{
var raw = pricePerConfigPerMonth * maxConfigs;
var discountPercent = PricingDiscount.ResolvePercent(tiers, maxConfigs);
return PricingDiscount.Apply(raw, discountPercent);
}
}
@@ -11,6 +11,7 @@ public class PaymentRequestConfiguration : IEntityTypeConfiguration<PaymentReque
builder.ToTable("PaymentRequests"); builder.ToTable("PaymentRequests");
builder.HasKey(x => x.Id); builder.HasKey(x => x.Id);
builder.Property(x => x.Kind).HasConversion<string>().HasMaxLength(32);
builder.Property(x => x.Period).HasConversion<string>().HasMaxLength(32); builder.Property(x => x.Period).HasConversion<string>().HasMaxLength(32);
builder.Property(x => x.Status).HasConversion<string>().HasMaxLength(32); builder.Property(x => x.Status).HasConversion<string>().HasMaxLength(32);
builder.Property(x => x.RejectionReason).HasMaxLength(500); builder.Property(x => x.RejectionReason).HasMaxLength(500);
@@ -0,0 +1,52 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace PnvPanel.Infrastructure.Persistence.Migrations
{
/// <inheritdoc />
public partial class AddPaymentRequestKind : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AlterColumn<string>(
name: "Period",
table: "PaymentRequests",
type: "character varying(32)",
maxLength: 32,
nullable: true,
oldClrType: typeof(string),
oldType: "character varying(32)",
oldMaxLength: 32);
migrationBuilder.AddColumn<string>(
name: "Kind",
table: "PaymentRequests",
type: "character varying(32)",
maxLength: 32,
nullable: false,
defaultValue: "Subscription");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "Kind",
table: "PaymentRequests");
migrationBuilder.AlterColumn<string>(
name: "Period",
table: "PaymentRequests",
type: "character varying(32)",
maxLength: 32,
nullable: false,
defaultValue: "",
oldClrType: typeof(string),
oldType: "character varying(32)",
oldMaxLength: 32,
oldNullable: true);
}
}
}
@@ -293,11 +293,15 @@ namespace PnvPanel.Infrastructure.Persistence.Migrations
b.Property<Guid?>("DecidedBy") b.Property<Guid?>("DecidedBy")
.HasColumnType("uuid"); .HasColumnType("uuid");
b.Property<string>("Period") b.Property<string>("Kind")
.IsRequired() .IsRequired()
.HasMaxLength(32) .HasMaxLength(32)
.HasColumnType("character varying(32)"); .HasColumnType("character varying(32)");
b.Property<string>("Period")
.HasMaxLength(32)
.HasColumnType("character varying(32)");
b.Property<string>("RejectionReason") b.Property<string>("RejectionReason")
.HasMaxLength(500) .HasMaxLength(500)
.HasColumnType("character varying(500)"); .HasColumnType("character varying(500)");
@@ -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<CancellationToken>())
.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<Guid>(),
Arg.Any<DateTimeOffset>(),
Arg.Any<CancellationToken>()
);
}
[Fact] [Fact]
public async Task Handle_WhenRequestAlreadyDecided_ReturnsNotDecidable() public async Task Handle_WhenRequestAlreadyDecided_ReturnsNotDecidable()
{ {
@@ -2,9 +2,11 @@ using NSubstitute;
using PnvPanel.Application.Admin.Roles; using PnvPanel.Application.Admin.Roles;
using PnvPanel.Application.Admin.Support; using PnvPanel.Application.Admin.Support;
using PnvPanel.Application.Common.Interfaces; using PnvPanel.Application.Common.Interfaces;
using PnvPanel.Application.Common.Messaging;
using PnvPanel.Application.Common.Models; using PnvPanel.Application.Common.Models;
using PnvPanel.Application.Support; using PnvPanel.Application.Support;
using PnvPanel.Application.Tests.TestSupport; using PnvPanel.Application.Tests.TestSupport;
using PnvPanel.Domain.Billing;
using PnvPanel.Domain.Support; using PnvPanel.Domain.Support;
using Xunit; using Xunit;
@@ -13,9 +15,33 @@ namespace PnvPanel.Application.Tests.Admin.Support;
public class ApproveRoleRequestCommandHandlerTests public class ApproveRoleRequestCommandHandlerTests
{ {
private readonly IRoleService _roleService = Substitute.For<IRoleService>(); private readonly IRoleService _roleService = Substitute.For<IRoleService>();
private readonly IIdentityService _identityService = Substitute.For<IIdentityService>();
private readonly IRealtimeNotifier _notifier = Substitute.For<IRealtimeNotifier>(); private readonly IRealtimeNotifier _notifier = Substitute.For<IRealtimeNotifier>();
private readonly ITelegramNotifier _telegramNotifier = Substitute.For<ITelegramNotifier>(); private readonly ITelegramNotifier _telegramNotifier = Substitute.For<ITelegramNotifier>();
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] [Fact]
public async Task Handle_ForNewRoleRequest_CreatesRoleAssignsAndResolves() public async Task Handle_ForNewRoleRequest_CreatesRoleAssignsAndResolves()
{ {
@@ -34,13 +60,7 @@ public class ApproveRoleRequestCommandHandlerTests
.Returns(Result.Success()); .Returns(Result.Success());
var currentUser = FakeCurrentUser.Authenticated(Guid.NewGuid(), "admin"); var currentUser = FakeCurrentUser.Authenticated(Guid.NewGuid(), "admin");
var handler = new ApproveRoleRequestCommandHandler( var handler = CreateHandler(dbContext, currentUser);
dbContext,
_roleService,
_notifier,
_telegramNotifier,
currentUser
);
var result = await handler.Handle( var result = await handler.Handle(
new ApproveRoleRequestCommand(ticket.Id), new ApproveRoleRequestCommand(ticket.Id),
@@ -75,18 +95,15 @@ public class ApproveRoleRequestCommandHandlerTests
dbContext.SupportTickets.Add(ticket); dbContext.SupportTickets.Add(ticket);
await dbContext.SaveChangesAsync(CancellationToken.None); await dbContext.SaveChangesAsync(CancellationToken.None);
_roleService
.ListRolesAsync(Arg.Any<CancellationToken>())
.Returns(new List<RoleDto> { new(roleId, "premium", 10, 5, false, false) });
_roleService _roleService
.ChangeUserRoleAsync(userId, roleId, Arg.Any<CancellationToken>()) .ChangeUserRoleAsync(userId, roleId, Arg.Any<CancellationToken>())
.Returns(Result.Success()); .Returns(Result.Success());
var currentUser = FakeCurrentUser.Authenticated(Guid.NewGuid()); var currentUser = FakeCurrentUser.Authenticated(Guid.NewGuid());
var handler = new ApproveRoleRequestCommandHandler( var handler = CreateHandler(dbContext, currentUser);
dbContext,
_roleService,
_notifier,
_telegramNotifier,
currentUser
);
var result = await handler.Handle( var result = await handler.Handle(
new ApproveRoleRequestCommand(ticket.Id), new ApproveRoleRequestCommand(ticket.Id),
@@ -114,13 +131,7 @@ public class ApproveRoleRequestCommandHandlerTests
await dbContext.SaveChangesAsync(CancellationToken.None); await dbContext.SaveChangesAsync(CancellationToken.None);
var currentUser = FakeCurrentUser.Authenticated(Guid.NewGuid()); var currentUser = FakeCurrentUser.Authenticated(Guid.NewGuid());
var handler = new ApproveRoleRequestCommandHandler( var handler = CreateHandler(dbContext, currentUser);
dbContext,
_roleService,
_notifier,
_telegramNotifier,
currentUser
);
var result = await handler.Handle( var result = await handler.Handle(
new ApproveRoleRequestCommand(ticket.Id), new ApproveRoleRequestCommand(ticket.Id),
@@ -143,18 +154,15 @@ public class ApproveRoleRequestCommandHandlerTests
dbContext.SupportTickets.Add(ticket); dbContext.SupportTickets.Add(ticket);
await dbContext.SaveChangesAsync(CancellationToken.None); await dbContext.SaveChangesAsync(CancellationToken.None);
_roleService
.ListRolesAsync(Arg.Any<CancellationToken>())
.Returns(new List<RoleDto> { new(roleId, "premium", 10, 5, false, false) });
_roleService _roleService
.ChangeUserRoleAsync(adminId, roleId, Arg.Any<CancellationToken>()) .ChangeUserRoleAsync(adminId, roleId, Arg.Any<CancellationToken>())
.Returns(Result.Success()); .Returns(Result.Success());
var currentUser = FakeCurrentUser.Authenticated(adminId, "admin"); var currentUser = FakeCurrentUser.Authenticated(adminId, "admin");
var handler = new ApproveRoleRequestCommandHandler( var handler = CreateHandler(dbContext, currentUser);
dbContext,
_roleService,
_notifier,
_telegramNotifier,
currentUser
);
var result = await handler.Handle( var result = await handler.Handle(
new ApproveRoleRequestCommand(ticket.Id), new ApproveRoleRequestCommand(ticket.Id),
@@ -175,18 +183,15 @@ public class ApproveRoleRequestCommandHandlerTests
dbContext.SupportTickets.Add(ticket); dbContext.SupportTickets.Add(ticket);
await dbContext.SaveChangesAsync(CancellationToken.None); await dbContext.SaveChangesAsync(CancellationToken.None);
_roleService
.ListRolesAsync(Arg.Any<CancellationToken>())
.Returns(new List<RoleDto> { new(roleId, "premium", 10, 5, false, false) });
_roleService _roleService
.ChangeUserRoleAsync(adminId, roleId, Arg.Any<CancellationToken>()) .ChangeUserRoleAsync(adminId, roleId, Arg.Any<CancellationToken>())
.Returns(Result.Failure(RoleErrors.CannotRemoveLastAdmin)); .Returns(Result.Failure(RoleErrors.CannotRemoveLastAdmin));
var currentUser = FakeCurrentUser.Authenticated(adminId, "admin"); var currentUser = FakeCurrentUser.Authenticated(adminId, "admin");
var handler = new ApproveRoleRequestCommandHandler( var handler = CreateHandler(dbContext, currentUser);
dbContext,
_roleService,
_notifier,
_telegramNotifier,
currentUser
);
var result = await handler.Handle( var result = await handler.Handle(
new ApproveRoleRequestCommand(ticket.Id), new ApproveRoleRequestCommand(ticket.Id),
@@ -198,4 +203,63 @@ public class ApproveRoleRequestCommandHandlerTests
// Тикет остаётся Open — можно повторить попытку после назначения второго админа. // Тикет остаётся Open — можно повторить попытку после назначения второго админа.
Assert.Equal(TicketStatus.Open, ticket.Status); 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<CancellationToken>())
.Returns(new List<RoleDto> { new(roleId, "premium", MaxConfigs: 10, 5, false, BillingEnabled: true) });
_roleService.ChangeUserRoleAsync(userId, roleId, Arg.Any<CancellationToken>()).Returns(Result.Success());
_identityService
.GetProfileAsync(userId, Arg.Any<CancellationToken>())
.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<CancellationToken>())
.Returns(new List<RoleDto> { new(roleId, "premium", MaxConfigs: 10, 5, false, BillingEnabled: true) });
_roleService.ChangeUserRoleAsync(userId, roleId, Arg.Any<CancellationToken>()).Returns(Result.Success());
_identityService
.GetProfileAsync(userId, Arg.Any<CancellationToken>())
.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);
}
} }
@@ -41,6 +41,7 @@ public class MarkPaymentSentCommandHandlerTests
.NotifyAdminsPaymentRequestedAsync( .NotifyAdminsPaymentRequestedAsync(
request.Id, request.Id,
Arg.Any<string>(), Arg.Any<string>(),
PaymentRequestKind.Subscription,
PaymentPeriod.Year, PaymentPeriod.Year,
6000, 6000,
Arg.Any<CancellationToken>() Arg.Any<CancellationToken>()
@@ -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);
}
}
+5 -5
View File
@@ -154,8 +154,8 @@ status, createdAt }`. `expiresAt` всегда `null` (лимиты по сро
| Метод | Путь | Тело запроса | Тело ответа | | Метод | Путь | Тело запроса | Тело ответа |
| ----- | -------------------------------------------- | ---------------------- | ------------- | | ----- | -------------------------------------------- | ---------------------- | ------------- |
| GET | `/api/billing/status` | — | `BillingStatusDto { billingEnabled, paidUntil, suspended, requisitesText, activeRequest: PaymentRequestDto \| null }` | | 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` (`409 Billing.ActiveRequestExists`, если уже есть активная заявка; `409 Billing.UnlimitedRoleNotSupported` для ролей с `MaxConfigs=-1`; `500 Billing.PricingNotConfigured`, если ставка для периода не задана) | | 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}/cancel` | — | `204 No Content` (только из `AwaitingPayment`) |
| POST | `/api/billing/requests/{id}/mark-paid` | — | `204 No Content` (`AwaitingPayment → AwaitingConfirmation`, уведомляет админов в Telegram) | | 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 не привязан) | | 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}/comments` | multipart: `body` + `files[]` | `TicketCommentDto` |
| POST | `/api/admin/support/tickets/{id}/resolve` | — | `204 No Content` (любой тип, только из `Open`) | | 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}/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}/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}/approve-extension` | — | `204 No Content` (только `ExtensionRequest`/`Open`; продлевает `BillingPaidUntil` на `RequestedDays`) |
| POST | `/api/admin/support/tickets/{id}/reject-extension` | `{ reason? }` | `204 No Content` (только `ExtensionRequest`; `reason` уходит комментарием) | | 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 }` | | GET | `/api/admin/billing/settings` | admin | — | `BillingSettingsDto { requisitesText, graceDays, defaultBillingEnabledForNewRoles }` |
| PUT | `/api/admin/billing/settings` | admin | `{ requisitesText, graceDays, defaultBillingEnabledForNewRoles }` | `BillingSettingsDto` | | PUT | `/api/admin/billing/settings` | admin | `{ requisitesText, graceDays, defaultBillingEnabledForNewRoles }` | `BillingSettingsDto` |
| GET | `/api/admin/billing/requests` | admin | query: `status?, page=1, pageSize=20` | `PagedList<AdminPaymentRequestDto>` (включает `userName`) | | GET | `/api/admin/billing/requests` | admin | query: `status?, page=1, pageSize=20` | `PagedList<AdminPaymentRequestDto>` (включает `userName`, `kind`, `period: PaymentPeriod \| null`) |
| POST | `/api/admin/billing/requests/{id}/confirm` | admin | — | `204 No Content` (продлевает `BillingPaidUntil`, возвращает приостановленные конфиги в `Active`) | | 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/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) | | POST | `/api/admin/billing/gift` | admin | `{ userId, days }` | `204 No Content` (продлевает `BillingPaidUntil` на `days` от `max(текущий, сейчас)`, возвращает приостановленные конфиги, шлёт Telegram-уведомление пользователю; `403 Billing.NotEnabled`, если роль пользователя не billing) |
+52 -12
View File
@@ -417,33 +417,73 @@ Singleton (как `PricingSettings`) — реквизиты для оплаты
### PaymentRequest — заявка на оплату ### PaymentRequest — заявка на оплату
Пользователь оформляет заявку на период (3/6/12 мес); решает админ на сайте или в Telegram. Не более Пользователь оформляет заявку на период (3/6/12 мес); решает админ на сайте или в Telegram. Не более
одной активной (`AwaitingPayment`/`AwaitingConfirmation`) заявки на пользователя — инвариант одной активной (`AwaitingPayment`/`AwaitingConfirmation`) заявки **`Kind.Subscription`** на
проверяется в `CreatePaymentRequestCommandHandler`. пользователя — инвариант проверяется в `CreatePaymentRequestCommandHandler` и не распространяется на
`Kind.RoleChangeTopUp` (см. ниже) — доплата не должна мешать оформить/продлить обычную подписку.
| Поле | Тип | Заметки | | Поле | Тип | Заметки |
| ------------------ | ----------------------- | ------------------------------------------------------------ | | ------------------ | ----------------------- | ------------------------------------------------------------ |
| `Id` | `Guid` | PK | | `Id` | `Guid` | PK |
| `UserId` | `Guid` | FK → AppUser (заявитель) | | `UserId` | `Guid` | FK → AppUser (заявитель) |
| `Period` | `PaymentPeriod` | `Quarter` (3 мес) / `HalfYear` (6 мес) / `Year` (12 мес) | | `Kind` | `PaymentRequestKind` | `Subscription` (оплата за период) / `RoleChangeTopUp` (доплата за апгрейд роли, см. ниже) |
| `AmountSnapshot` | `int` | Сумма, замороженная на момент создания: `ставка PricingSettings за период × MaxConfigs роли × число месяцев`, затем скидка по лесенке `PricingDiscountTier` (см. выше), если применима. Последующее изменение прайса/лесенки админом не меняет уже созданные заявки | | `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` | | `Status` | `PaymentRequestStatus` | `AwaitingPayment``AwaitingConfirmation``Confirmed`/`Rejected`, либо `Cancelled` из `AwaitingPayment` |
| `DecidedBy`/`DecidedAt`/`RejectionReason` | | Кто/когда решил, причина отказа (опционально) | | `DecidedBy`/`DecidedAt`/`RejectionReason` | | Кто/когда решил, причина отказа (опционально) |
| `CreatedAt` | `DateTimeOffset` | | | `CreatedAt` | `DateTimeOffset` | |
Роль с `MaxConfigs = -1` (unlimited) не поддерживает биллинг по формуле — Роль с `MaxConfigs = -1` (unlimited) не поддерживает биллинг по формуле —
`CreatePaymentRequestCommandHandler` отдаёт `BillingErrors.UnlimitedRoleNotSupported`. `CreatePaymentRequestCommandHandler` отдаёт `BillingErrors.UnlimitedRoleNotSupported`; та же логика в
`RoleChangeTopUp.Compute` (`null`, доплата не считается).
**Переходы** (`backend/src/PnvPanel.Domain/Billing/PaymentRequest.cs`): **Переходы** (`backend/src/PnvPanel.Domain/Billing/PaymentRequest.cs`):
- `Create(userId, period, amount)` `AwaitingPayment`, показываются реквизиты `BillingSettings`. - `Create(userId, period, amount)` (`Kind.Subscription`) / `CreateRoleChangeTopUp(userId, amount)`
Пользователь может `Cancel()` (только из `AwaitingPayment`) или дождаться проверки. (`Kind.RoleChangeTopUp`) → `AwaitingPayment`, показываются реквизиты `BillingSettings`. Пользователь
может `Cancel()` (только из `AwaitingPayment`) или дождаться проверки.
- `MarkPaymentSent()` → пользователь нажал «Я оплатил»; `AwaitingPayment → AwaitingConfirmation`, - `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` и - `Confirm(adminId)`/`Reject(adminId, reason)` → допустимы из **обоих** `AwaitingPayment` и
`AwaitingConfirmation` (админ мог заметить оплату раньше, чем пользователь нажал кнопку). `AwaitingConfirmation` (админ мог заметить оплату раньше, чем пользователь нажал кнопку).
`Confirm` продлевает `AppUser.BillingPaidUntil = max(текущий, сейчас) + период` (не теряет уже Для `Kind.Subscription` `Confirm` продлевает `AppUser.BillingPaidUntil = max(текущий, сейчас) +
оплаченный остаток при досрочной оплате), возвращает в `Active` конфиги, приостановленные за период` (не теряет уже оплаченный остаток при досрочной оплате), возвращает в `Active` конфиги,
неуплату (`Suspend()`/`Resume()` на `VpnConfig`, статус `Expired`), обновляет `ExpiresAt` на всех приостановленные за неуплату (`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 — приостановка за неуплату (фоновая джоба) ### BillingService — приостановка за неуплату (фоновая джоба)
`Infrastructure/BackgroundJobs/BillingService.cs`, раз в час (по образцу `TrafficSyncService`). Для `Infrastructure/BackgroundJobs/BillingService.cs`, раз в час (по образцу `TrafficSyncService`). Для
@@ -103,8 +103,12 @@ export function PaymentRequestPanel({ status }: { status: BillingStatusDto }) {
</CardHeader> </CardHeader>
<CardContent className="flex flex-col gap-4"> <CardContent className="flex flex-col gap-4">
<p className="text-sm"> <p className="text-sm">
{t(`billing.periods.${request.period}`)} <span className="font-medium">{request.amountSnapshot} </span> {request.kind === 'RoleChangeTopUp' ? t('billing.roleChangeTopUp') : t(`billing.periods.${request.period}`)} {' '}
<span className="font-medium">{request.amountSnapshot} </span>
</p> </p>
{request.kind === 'RoleChangeTopUp' && (
<p className="text-xs text-muted-foreground">{t('billing.roleChangeTopUpHint')}</p>
)}
{!isAwaitingConfirmation && ( {!isAwaitingConfirmation && (
<div className="flex flex-col gap-1.5"> <div className="flex flex-col gap-1.5">
+3 -1
View File
@@ -162,7 +162,9 @@ function RequestsSection() {
{data.items.map((request) => ( {data.items.map((request) => (
<tr key={request.id} className="border-b border-border"> <tr key={request.id} className="border-b border-border">
<td className="py-2">{request.userName}</td> <td className="py-2">{request.userName}</td>
<td className="py-2">{t(`billing.periods.${request.period}`)}</td> <td className="py-2">
{request.kind === 'RoleChangeTopUp' ? t('billing.roleChangeTopUp') : t(`billing.periods.${request.period}`)}
</td>
<td className="py-2">{request.amountSnapshot} </td> <td className="py-2">{request.amountSnapshot} </td>
<td className="py-2"> <td className="py-2">
<Badge variant={request.status === 'AwaitingConfirmation' ? 'warning' : 'outline'}> <Badge variant={request.status === 'AwaitingConfirmation' ? 'warning' : 'outline'}>
+7 -2
View File
@@ -202,10 +202,14 @@ export type PaymentRequestStatus =
| 'Confirmed' | 'Confirmed'
| 'Rejected' | 'Rejected'
| 'Cancelled' | 'Cancelled'
/** Subscription — оплата за период (period задан). RoleChangeTopUp — доплата разницы в цене при
* апгрейде роли с активным оплаченным периодом (period null, не продлевает paidUntil). */
export type PaymentRequestKind = 'Subscription' | 'RoleChangeTopUp'
export type PaymentRequestDto = { export type PaymentRequestDto = {
id: string id: string
period: PaymentPeriod kind: PaymentRequestKind
period: PaymentPeriod | null
amountSnapshot: number amountSnapshot: number
status: PaymentRequestStatus status: PaymentRequestStatus
createdAt: string createdAt: string
@@ -231,7 +235,8 @@ export type AdminPaymentRequestDto = {
id: string id: string
userId: string userId: string
userName: string userName: string
period: PaymentPeriod kind: PaymentRequestKind
period: PaymentPeriod | null
amountSnapshot: number amountSnapshot: number
status: PaymentRequestStatus status: PaymentRequestStatus
createdAt: string createdAt: string
+4
View File
@@ -142,6 +142,8 @@ const resources = {
confirmCancel: 'Отменить заявку на оплату?', confirmCancel: 'Отменить заявку на оплату?',
requestCancelled: 'Заявка отменена.', requestCancelled: 'Заявка отменена.',
awaitingAdminHint: 'Администратор уведомлён и проверит оплату. Конфиги не отключатся, пока заявка не решена.', awaitingAdminHint: 'Администратор уведомлён и проверит оплату. Конфиги не отключатся, пока заявка не решена.',
roleChangeTopUp: 'Доплата за смену роли',
roleChangeTopUpHint: 'Новая роль дороже прежней — эта сумма покрывает разницу в цене за оставшуюся часть уже оплаченного периода, срок подписки при этом не меняется.',
}, },
instructions: { instructions: {
@@ -680,6 +682,8 @@ const resources = {
confirmCancel: 'Cancel this payment request?', confirmCancel: 'Cancel this payment request?',
requestCancelled: 'Request cancelled.', requestCancelled: 'Request cancelled.',
awaitingAdminHint: 'The administrator has been notified and will verify the payment. Configs stay active until the request is decided.', 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: { instructions: {