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.
This commit is contained in:
@@ -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
|
||||||
|
|||||||
+29
-1
@@ -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
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+3
@@ -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
|
||||||
|
|||||||
+1
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
+4
-1
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+1
@@ -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);
|
||||||
|
|||||||
+1073
File diff suppressed because it is too large
Load Diff
+52
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+5
-1
@@ -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)");
|
||||||
|
|||||||
+42
@@ -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()
|
||||||
{
|
{
|
||||||
|
|||||||
+99
-35
@@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+1
@@ -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
@@ -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
@@ -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">
|
||||||
|
|||||||
@@ -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'}>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
Reference in New Issue
Block a user