Implement billing functionality and enhance role management
CI / Backend (build + test) (push) Successful in 1m22s
CI / Frontend (lint + typecheck + build) (push) Successful in 33s

- Introduced billing capabilities, allowing users to request payments for subscription periods (3/6/12 months) with admin approval via Telegram.
- Updated role management to include a `BillingEnabled` property, preventing billing for admin roles.
- Enhanced the `CreateRoleCommand` and `UpdateRoleCommand` to accept billing parameters, ensuring proper handling during role creation and updates.
- Added new endpoints for billing management and integrated billing checks into VPN config creation to enforce payment requirements.
- Updated related services, models, and tests to support the new billing features, ensuring comprehensive coverage and functionality.
- Enhanced documentation to reflect the new billing processes and role management changes.
This commit is contained in:
Leonid Pershin
2026-07-19 01:38:16 +03:00
parent b980dc6cef
commit b2ae358250
106 changed files with 6018 additions and 66 deletions
@@ -0,0 +1,91 @@
using PnvPanel.Api.Common;
using PnvPanel.Application.Admin.Billing;
using PnvPanel.Application.Common.Messaging;
using PnvPanel.Application.Common.Models;
using PnvPanel.Domain.Billing;
using PnvPanel.Infrastructure.Identity;
namespace PnvPanel.Api.Endpoints;
public static class AdminBillingEndpoints
{
public static IEndpointRouteBuilder MapAdminBillingEndpoints(this IEndpointRouteBuilder app)
{
var admin = app.MapGroup("/api/admin/billing")
.WithTags("Admin.Billing")
.RequireAuthorization(policy => policy.RequireRole(RoleNames.Admin));
admin.MapGet("/settings", GetSettings).Produces<BillingSettingsDto>();
admin.MapPut("/settings", UpdateSettings).Produces<BillingSettingsDto>();
admin
.MapGet("/requests", ListRequests)
.Produces<PagedList<AdminPaymentRequestDto>>();
admin
.MapPost("/requests/{id:guid}/confirm", ConfirmRequest)
.Produces(StatusCodes.Status204NoContent);
admin
.MapPost("/requests/{id:guid}/reject", RejectRequest)
.Produces(StatusCodes.Status204NoContent);
return app;
}
private static async Task<IResult> GetSettings(ISender sender, CancellationToken cancellationToken)
{
var result = await sender.Send(new GetBillingSettingsQuery(), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> UpdateSettings(
UpdateBillingSettingsCommand command,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(command, cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> ListRequests(
[AsParameters] ListPaymentRequestsRequest request,
ISender sender,
CancellationToken cancellationToken
)
{
var query = new ListPaymentRequestsQuery(request.Status, request.Page, request.PageSize);
var result = await sender.Send(query, cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> ConfirmRequest(
Guid id,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new ConfirmPaymentRequestCommand(id), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> RejectRequest(
Guid id,
RejectPaymentRequestBody body,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(
new RejectPaymentRequestCommand(id, body.Reason),
cancellationToken
);
return result.ToHttpResult();
}
}
public sealed record ListPaymentRequestsRequest(
PaymentRequestStatus? Status,
int Page = 1,
int PageSize = 20
);
public sealed record RejectPaymentRequestBody(string? Reason);
@@ -0,0 +1,84 @@
using PnvPanel.Api.Common;
using PnvPanel.Application.Billing;
using PnvPanel.Application.Billing.CancelPaymentRequest;
using PnvPanel.Application.Billing.CreatePaymentRequest;
using PnvPanel.Application.Billing.GetMyBillingStatus;
using PnvPanel.Application.Billing.MarkPaymentSent;
using PnvPanel.Application.Billing.SendRequisitesToTelegram;
using PnvPanel.Application.Common.Messaging;
using PnvPanel.Domain.Billing;
namespace PnvPanel.Api.Endpoints;
public static class BillingEndpoints
{
public static IEndpointRouteBuilder MapBillingEndpoints(this IEndpointRouteBuilder app)
{
var group = app.MapGroup("/api/billing").WithTags("Billing").RequireAuthorization();
group.MapGet("/status", GetStatus).Produces<BillingStatusDto>();
group.MapPost("/requests", CreateRequest).Produces<PaymentRequestDto>();
group
.MapPost("/requests/{id:guid}/cancel", CancelRequest)
.Produces(StatusCodes.Status204NoContent);
group
.MapPost("/requests/{id:guid}/mark-paid", MarkPaid)
.Produces(StatusCodes.Status204NoContent);
group
.MapPost("/requests/{id:guid}/send-requisites", SendRequisites)
.Produces(StatusCodes.Status204NoContent);
return app;
}
private static async Task<IResult> GetStatus(ISender sender, CancellationToken cancellationToken)
{
var result = await sender.Send(new GetMyBillingStatusQuery(), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> CreateRequest(
CreatePaymentRequestBody body,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(
new CreatePaymentRequestCommand(body.Period),
cancellationToken
);
return result.ToHttpResult();
}
private static async Task<IResult> CancelRequest(
Guid id,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new CancelPaymentRequestCommand(id), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> MarkPaid(
Guid id,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new MarkPaymentSentCommand(id), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> SendRequisites(
Guid id,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new SendRequisitesToTelegramCommand(id), cancellationToken);
return result.ToHttpResult();
}
}
public sealed record CreatePaymentRequestBody(PaymentPeriod Period);
@@ -53,7 +53,7 @@ public static class RoleEndpoints
)
{
var result = await sender.Send(
new UpdateRoleCommand(id, body.MaxConfigs, body.MaxIpLimit),
new UpdateRoleCommand(id, body.MaxConfigs, body.MaxIpLimit, body.BillingEnabled),
cancellationToken
);
return result.ToHttpResult();
@@ -84,6 +84,6 @@ public static class RoleEndpoints
}
}
public sealed record UpdateRoleBody(int MaxConfigs, int MaxIpLimit);
public sealed record UpdateRoleBody(int MaxConfigs, int MaxIpLimit, bool BillingEnabled);
public sealed record ChangeUserRoleBody(Guid RoleId);
+2
View File
@@ -192,6 +192,8 @@ app.MapAdminAppEndpoints();
app.MapAdminNewsEndpoints();
app.MapAdminInstructionEndpoints();
app.MapAdminPricingEndpoints();
app.MapBillingEndpoints();
app.MapAdminBillingEndpoints();
app.MapSupportEndpoints();
app.MapAdminSupportEndpoints();
app.MapAdminMaintenanceEndpoints();
@@ -1,5 +1,6 @@
using Microsoft.Extensions.Options;
using PnvPanel.Application.Admin.Activation;
using PnvPanel.Application.Admin.Billing;
using PnvPanel.Application.Admin.Support;
using PnvPanel.Application.Common.Interfaces;
using PnvPanel.Application.Common.Messaging;
@@ -442,6 +443,55 @@ public sealed class PnvBotUpdateHandler(
break;
}
case "pay":
{
if (!await TrySetAdminCurrentUserAsync(services, fromId, cancellationToken))
{
await botClient.AnswerCallbackQuery(
callback.Id,
"Недостаточно прав.",
cancellationToken: cancellationToken
);
return;
}
var result =
parts[1] == "approve"
? await sender.Send(
new ConfirmPaymentRequestCommand(requestId),
cancellationToken
)
: await sender.Send(
new RejectPaymentRequestCommand(requestId, Reason: null),
cancellationToken
);
await botClient.AnswerCallbackQuery(
callback.Id,
result.IsSuccess ? "Готово" : result.Error.Message,
cancellationToken: cancellationToken
);
if (callback.Message is not null)
{
var statusText = result.IsSuccess
? (
parts[1] == "approve"
? "✅ Оплата подтверждена."
: "❌ Оплата отклонена."
)
: $"⚠️ {result.Error.Message}";
var text = $"{Escape(callback.Message.Text ?? "")}\n\n{statusText}";
await botClient.EditMessageText(
chatId.Value,
callback.Message.Id,
text,
parseMode: ParseMode.Html,
cancellationToken: cancellationToken
);
}
break;
}
case "cfg":
{
if (!await TrySetCurrentUserAsync(services, fromId, cancellationToken))
@@ -1,5 +1,6 @@
using Microsoft.Extensions.Options;
using PnvPanel.Application.Common.Interfaces;
using PnvPanel.Domain.Billing;
using PnvPanel.Domain.Support;
using PnvPanel.Infrastructure.Telegram;
using Telegram.Bot;
@@ -225,6 +226,56 @@ internal sealed class TelegramNotifier(
}
}
public async Task NotifyAdminsPaymentRequestedAsync(
Guid requestId,
string userName,
PaymentPeriod period,
int amount,
CancellationToken cancellationToken
)
{
if (string.IsNullOrWhiteSpace(options.Value.BotToken))
return;
var text =
$"💰 <b>{Escape(userName)}</b> заявляет об оплате за {PeriodLabel(period)} — {amount} ₽\nПроверьте поступление и подтвердите.";
var keyboard = new InlineKeyboardMarkup(
new[]
{
InlineKeyboardButton.WithCallbackData("✅ Подтвердить", $"pay:approve:{requestId}"),
InlineKeyboardButton.WithCallbackData("❌ Отклонить", $"pay:reject:{requestId}"),
}
);
foreach (var adminId in options.Value.ParseAdminTelegramUserIds())
{
try
{
await botClient.SendMessage(
adminId,
text,
parseMode: ParseMode.Html,
replyMarkup: keyboard,
cancellationToken: cancellationToken
);
}
catch
{
// Админ мог не запускать бота (нет чата с ботом) — пропускаем, не валим команду.
}
}
}
private static string PeriodLabel(PaymentPeriod period) =>
period switch
{
PaymentPeriod.Quarter => "3 месяца",
PaymentPeriod.HalfYear => "полгода",
PaymentPeriod.Year => "год",
_ => period.ToString(),
};
public async Task NotifyUserAsync(
Guid userId,
string message,
@@ -0,0 +1,13 @@
using PnvPanel.Domain.Billing;
namespace PnvPanel.Application.Admin.Billing;
public sealed record AdminPaymentRequestDto(
Guid Id,
Guid UserId,
string UserName,
PaymentPeriod Period,
int AmountSnapshot,
PaymentRequestStatus Status,
DateTimeOffset CreatedAt
);
@@ -0,0 +1,13 @@
using PnvPanel.Domain.Billing;
namespace PnvPanel.Application.Admin.Billing;
public sealed record BillingSettingsDto(
string RequisitesText,
int GraceDays,
bool DefaultBillingEnabledForNewRoles
)
{
public static BillingSettingsDto FromDomain(BillingSettings settings) =>
new(settings.RequisitesText, settings.GraceDays, settings.DefaultBillingEnabledForNewRoles);
}
@@ -0,0 +1,6 @@
using PnvPanel.Application.Common.Messaging;
using PnvPanel.Application.Common.Models;
namespace PnvPanel.Application.Admin.Billing;
public sealed record ConfirmPaymentRequestCommand(Guid RequestId) : ICommand<Result>;
@@ -0,0 +1,147 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using PnvPanel.Application.Auth;
using PnvPanel.Application.Billing;
using PnvPanel.Application.Common.Interfaces;
using PnvPanel.Application.Common.Messaging;
using PnvPanel.Application.Common.Models;
using PnvPanel.Domain.Audit;
using PnvPanel.Domain.Billing;
using PnvPanel.Domain.Configs;
namespace PnvPanel.Application.Admin.Billing;
/// <summary>
/// Продлевает оплату (max(текущий PaidUntil, сейчас) + период), возвращает в Active конфиги,
/// приостановленные за неуплату (Expired), и синхронизирует ExpiresAt на все конфиги пользователя —
/// зеркало UnblockUserCommandHandler, но по статусу Expired (биллинг), а не Disabled (блокировка).
/// </summary>
public sealed class ConfirmPaymentRequestCommandHandler(
IAppDbContext dbContext,
IIdentityService identityService,
IXuiPanelGateway gateway,
IRealtimeNotifier notifier,
ITelegramNotifier telegramNotifier,
ICurrentUser currentUser,
ILogger<ConfirmPaymentRequestCommandHandler> logger
) : ICommandHandler<ConfirmPaymentRequestCommand, Result>
{
public async Task<Result> Handle(
ConfirmPaymentRequestCommand command,
CancellationToken cancellationToken
)
{
if (currentUser.UserId is not { } adminId)
return Result.Failure(AuthErrors.Unauthorized);
var request = await dbContext.PaymentRequests.FirstOrDefaultAsync(
r => r.Id == command.RequestId,
cancellationToken
);
if (request is null)
return Result.Failure(BillingErrors.RequestNotFound);
if (
request.Status
is not (PaymentRequestStatus.AwaitingPayment or PaymentRequestStatus.AwaitingConfirmation)
)
return Result.Failure(BillingErrors.RequestNotDecidable);
var profile = await identityService.GetProfileAsync(request.UserId, cancellationToken);
if (profile is null)
return Result.Failure(AuthErrors.Unauthorized);
var now = DateTimeOffset.UtcNow;
var baseline = profile.BillingPaidUntil is { } paidUntil && paidUntil > now ? paidUntil : now;
var newPaidUntil = baseline.AddMonths(request.Period.ToMonths());
var extendResult = await identityService.ExtendBillingPaidUntilAsync(
request.UserId,
newPaidUntil,
cancellationToken
);
if (!extendResult.IsSuccess)
return extendResult;
request.Confirm(adminId);
var configs = await dbContext
.VpnConfigs.Where(c =>
c.UserId == request.UserId
&& (c.Status == ConfigStatus.Active || c.Status == ConfigStatus.Expired)
)
.ToListAsync(cancellationToken);
foreach (var config in configs)
{
if (config.Status == ConfigStatus.Expired)
{
var inbound = await dbContext
.Inbounds.AsNoTracking()
.FirstOrDefaultAsync(i => i.Id == config.InboundId, cancellationToken);
var node = inbound is null
? null
: await dbContext
.Nodes.AsNoTracking()
.FirstOrDefaultAsync(n => n.Id == inbound.NodeId, cancellationToken);
if (inbound is not null && node is not null)
{
var updateResult = await gateway.UpdateClientAsync(
node,
inbound.RemoteInboundId,
config.ClientExternalId,
config.Protocol,
config.Label ?? config.ClientEmail,
enable: true,
cancellationToken
);
if (!updateResult.IsSuccess)
{
// Нода недоступна — не трогаем локальный статус, подхватится следующим
// подтверждением/циклом BillingService (идемпотентно).
logger.LogWarning(
"Failed to enable client for config {ConfigId} on node {NodeId} while confirming payment {RequestId}: {Error}",
config.Id,
node.Id,
request.Id,
updateResult.Error
);
continue;
}
}
config.Resume();
await notifier.NotifyConfigStatusChangedAsync(
config.UserId,
config.Id,
config.Status,
cancellationToken
);
}
config.SetBillingExpiry(newPaidUntil);
}
dbContext.AuditLogs.Add(
AuditLog.Create(
adminId,
"PaymentConfirmed",
"PaymentRequest",
request.Id.ToString(),
metadata: null,
AuditSource.Web
)
);
await telegramNotifier.NotifyUserAsync(
request.UserId,
$"✅ Оплата подтверждена. Доступ продлён до {newPaidUntil:dd.MM.yyyy}.",
null,
cancellationToken
);
return Result.Success();
}
}
@@ -0,0 +1,6 @@
using PnvPanel.Application.Common.Messaging;
using PnvPanel.Application.Common.Models;
namespace PnvPanel.Application.Admin.Billing;
public sealed record GetBillingSettingsQuery : IQuery<Result<BillingSettingsDto>>;
@@ -0,0 +1,28 @@
using Microsoft.EntityFrameworkCore;
using PnvPanel.Application.Common.Interfaces;
using PnvPanel.Application.Common.Messaging;
using PnvPanel.Application.Common.Models;
using PnvPanel.Domain.Billing;
namespace PnvPanel.Application.Admin.Billing;
public sealed class GetBillingSettingsQueryHandler(IAppDbContext dbContext)
: IQueryHandler<GetBillingSettingsQuery, Result<BillingSettingsDto>>
{
public async Task<Result<BillingSettingsDto>> Handle(
GetBillingSettingsQuery query,
CancellationToken cancellationToken
)
{
var settings = await dbContext
.BillingSettings.AsNoTracking()
.FirstOrDefaultAsync(cancellationToken);
// Ещё не сохранялось ни разу — отдаём дефолты, а не ошибку (см. PricingSettings).
return Result.Success(
settings is null
? new BillingSettingsDto(string.Empty, BillingSettings.DefaultGraceDays, false)
: BillingSettingsDto.FromDomain(settings)
);
}
}
@@ -0,0 +1,8 @@
using PnvPanel.Application.Common.Messaging;
using PnvPanel.Application.Common.Models;
using PnvPanel.Domain.Billing;
namespace PnvPanel.Application.Admin.Billing;
public sealed record ListPaymentRequestsQuery(PaymentRequestStatus? StatusFilter, int Page, int PageSize)
: IQuery<Result<PagedList<AdminPaymentRequestDto>>>;
@@ -0,0 +1,50 @@
using Microsoft.EntityFrameworkCore;
using PnvPanel.Application.Common.Interfaces;
using PnvPanel.Application.Common.Messaging;
using PnvPanel.Application.Common.Models;
namespace PnvPanel.Application.Admin.Billing;
public sealed class ListPaymentRequestsQueryHandler(
IAppDbContext dbContext,
IIdentityService identityService
) : IQueryHandler<ListPaymentRequestsQuery, Result<PagedList<AdminPaymentRequestDto>>>
{
public async Task<Result<PagedList<AdminPaymentRequestDto>>> Handle(
ListPaymentRequestsQuery query,
CancellationToken cancellationToken
)
{
var page = query.Page <= 0 ? 1 : query.Page;
var pageSize = query.PageSize is <= 0 or > 100 ? 20 : query.PageSize;
var requestsQuery = dbContext.PaymentRequests.AsNoTracking();
if (query.StatusFilter is { } status)
requestsQuery = requestsQuery.Where(r => r.Status == status);
var page1 = await requestsQuery
.OrderByDescending(r => r.CreatedAt)
.ToPagedListAsync(page, pageSize, cancellationToken);
var userNames = await identityService.GetUserNamesAsync(
page1.Items.Select(r => r.UserId).Distinct().ToList(),
cancellationToken
);
var items = page1
.Items.Select(r => new AdminPaymentRequestDto(
r.Id,
r.UserId,
userNames.GetValueOrDefault(r.UserId, "?"),
r.Period,
r.AmountSnapshot,
r.Status,
r.CreatedAt
))
.ToList();
return Result.Success(
new PagedList<AdminPaymentRequestDto>(items, page1.Total, page1.Page, page1.PageSize)
);
}
}
@@ -0,0 +1,6 @@
using PnvPanel.Application.Common.Messaging;
using PnvPanel.Application.Common.Models;
namespace PnvPanel.Application.Admin.Billing;
public sealed record RejectPaymentRequestCommand(Guid RequestId, string? Reason) : ICommand<Result>;
@@ -0,0 +1,64 @@
using Microsoft.EntityFrameworkCore;
using PnvPanel.Application.Auth;
using PnvPanel.Application.Billing;
using PnvPanel.Application.Common.Interfaces;
using PnvPanel.Application.Common.Messaging;
using PnvPanel.Application.Common.Models;
using PnvPanel.Domain.Audit;
using PnvPanel.Domain.Billing;
namespace PnvPanel.Application.Admin.Billing;
public sealed class RejectPaymentRequestCommandHandler(
IAppDbContext dbContext,
ITelegramNotifier telegramNotifier,
ICurrentUser currentUser
) : ICommandHandler<RejectPaymentRequestCommand, Result>
{
public async Task<Result> Handle(
RejectPaymentRequestCommand command,
CancellationToken cancellationToken
)
{
if (currentUser.UserId is not { } adminId)
return Result.Failure(AuthErrors.Unauthorized);
var request = await dbContext.PaymentRequests.FirstOrDefaultAsync(
r => r.Id == command.RequestId,
cancellationToken
);
if (request is null)
return Result.Failure(BillingErrors.RequestNotFound);
if (
request.Status
is not (PaymentRequestStatus.AwaitingPayment or PaymentRequestStatus.AwaitingConfirmation)
)
return Result.Failure(BillingErrors.RequestNotDecidable);
request.Reject(adminId, command.Reason);
dbContext.AuditLogs.Add(
AuditLog.Create(
adminId,
"PaymentRejected",
"PaymentRequest",
request.Id.ToString(),
metadata: null,
AuditSource.Web
)
);
var reasonSuffix = string.IsNullOrWhiteSpace(command.Reason)
? string.Empty
: $"\nПричина: {command.Reason}";
await telegramNotifier.NotifyUserAsync(
request.UserId,
$"❌ Заявка на оплату отклонена.{reasonSuffix}",
null,
cancellationToken
);
return Result.Success();
}
}
@@ -0,0 +1,10 @@
using PnvPanel.Application.Common.Messaging;
using PnvPanel.Application.Common.Models;
namespace PnvPanel.Application.Admin.Billing;
public sealed record UpdateBillingSettingsCommand(
string RequisitesText,
int GraceDays,
bool DefaultBillingEnabledForNewRoles
) : ICommand<Result<BillingSettingsDto>>;
@@ -0,0 +1,32 @@
using Microsoft.EntityFrameworkCore;
using PnvPanel.Application.Common.Interfaces;
using PnvPanel.Application.Common.Messaging;
using PnvPanel.Application.Common.Models;
using PnvPanel.Domain.Billing;
namespace PnvPanel.Application.Admin.Billing;
public sealed class UpdateBillingSettingsCommandHandler(IAppDbContext dbContext)
: ICommandHandler<UpdateBillingSettingsCommand, Result<BillingSettingsDto>>
{
public async Task<Result<BillingSettingsDto>> Handle(
UpdateBillingSettingsCommand command,
CancellationToken cancellationToken
)
{
var settings = await dbContext.BillingSettings.FirstOrDefaultAsync(cancellationToken);
if (settings is null)
{
settings = BillingSettings.CreateDefault();
dbContext.BillingSettings.Add(settings);
}
settings.Update(
command.RequisitesText,
command.GraceDays,
command.DefaultBillingEnabledForNewRoles
);
return Result.Success(BillingSettingsDto.FromDomain(settings));
}
}
@@ -0,0 +1,13 @@
using FluentValidation;
namespace PnvPanel.Application.Admin.Billing;
public sealed class UpdateBillingSettingsCommandValidator
: AbstractValidator<UpdateBillingSettingsCommand>
{
public UpdateBillingSettingsCommandValidator()
{
RuleFor(x => x.RequisitesText).NotEmpty().MaximumLength(4000);
RuleFor(x => x.GraceDays).GreaterThanOrEqualTo(0).LessThanOrEqualTo(365);
}
}
@@ -4,5 +4,9 @@ using PnvPanel.Application.Common.Models;
namespace PnvPanel.Application.Admin.Roles;
public sealed record CreateRoleCommand(string Name, int MaxConfigs, int MaxIpLimit)
: ICommand<Result<RoleDto>>;
public sealed record CreateRoleCommand(
string Name,
int MaxConfigs,
int MaxIpLimit,
bool BillingEnabled
) : ICommand<Result<RoleDto>>;
@@ -15,6 +15,7 @@ public sealed class CreateRoleCommandHandler(IRoleService roleService)
command.Name,
command.MaxConfigs,
command.MaxIpLimit,
command.BillingEnabled,
cancellationToken
);
}
@@ -21,4 +21,8 @@ public static class RoleErrors
"Roles.CannotRemoveLastAdmin",
"Нельзя снять роль admin с последнего администратора."
);
public static readonly Error BillingNotAllowedForAdmin = Error.Validation(
"Roles.BillingNotAllowedForAdmin",
"Биллинг нельзя включить для роли admin."
);
}
@@ -4,5 +4,9 @@ using PnvPanel.Application.Common.Models;
namespace PnvPanel.Application.Admin.Roles;
public sealed record UpdateRoleCommand(Guid RoleId, int MaxConfigs, int MaxIpLimit)
: ICommand<Result<RoleDto>>;
public sealed record UpdateRoleCommand(
Guid RoleId,
int MaxConfigs,
int MaxIpLimit,
bool BillingEnabled
) : ICommand<Result<RoleDto>>;
@@ -15,6 +15,7 @@ public sealed class UpdateRoleCommandHandler(IRoleService roleService)
command.RoleId,
command.MaxConfigs,
command.MaxIpLimit,
command.BillingEnabled,
cancellationToken
);
}
@@ -49,6 +49,7 @@ public sealed class ApproveRoleRequestCommandHandler(
ticket.ProposedRoleName!,
ticket.ProposedMaxConfigs!.Value,
ticket.ProposedMaxIpLimit!.Value,
billingEnabled: false,
cancellationToken
);
if (!createResult.IsSuccess)
@@ -0,0 +1,46 @@
using PnvPanel.Application.Common.Models;
namespace PnvPanel.Application.Billing;
public static class BillingErrors
{
public static readonly Error NotEnabled = Error.Forbidden(
"Billing.NotEnabled",
"Биллинг не включён для вашей роли."
);
public static readonly Error PricingNotConfigured = Error.Failure(
"Billing.PricingNotConfigured",
"Цена для выбранного периода ещё не настроена админом."
);
public static readonly Error UnlimitedRoleNotSupported = Error.Failure(
"Billing.UnlimitedRoleNotSupported",
"Для роли без лимита конфигов сумма оплаты не может быть рассчитана."
);
public static readonly Error ActiveRequestExists = Error.Conflict(
"Billing.ActiveRequestExists",
"У вас уже есть активная заявка на оплату."
);
public static readonly Error RequestNotFound = Error.NotFound(
"Billing.RequestNotFound",
"Заявка на оплату не найдена."
);
public static readonly Error RequestNotCancellable = Error.Conflict(
"Billing.RequestNotCancellable",
"Заявку уже нельзя отменить."
);
public static readonly Error RequestNotAwaitingPayment = Error.Conflict(
"Billing.RequestNotAwaitingPayment",
"Заявка уже не ожидает оплаты."
);
public static readonly Error RequestNotDecidable = Error.Conflict(
"Billing.RequestNotDecidable",
"Заявка уже обработана."
);
}
@@ -0,0 +1,9 @@
namespace PnvPanel.Application.Billing;
public sealed record BillingStatusDto(
bool BillingEnabled,
DateTimeOffset? PaidUntil,
bool Suspended,
string RequisitesText,
PaymentRequestDto? ActiveRequest
);
@@ -0,0 +1,8 @@
using PnvPanel.Application.Common.Messaging;
using PnvPanel.Application.Common.Models;
namespace PnvPanel.Application.Billing.CancelPaymentRequest;
public sealed record CancelPaymentRequestCommand(Guid RequestId)
: ICommand<Result>,
IRequiresActivation;
@@ -0,0 +1,37 @@
using Microsoft.EntityFrameworkCore;
using PnvPanel.Application.Auth;
using PnvPanel.Application.Common.Interfaces;
using PnvPanel.Application.Common.Messaging;
using PnvPanel.Application.Common.Models;
using PnvPanel.Domain.Billing;
namespace PnvPanel.Application.Billing.CancelPaymentRequest;
public sealed class CancelPaymentRequestCommandHandler(
IAppDbContext dbContext,
ICurrentUser currentUser
) : ICommandHandler<CancelPaymentRequestCommand, Result>
{
public async Task<Result> Handle(
CancelPaymentRequestCommand command,
CancellationToken cancellationToken
)
{
if (currentUser.UserId is not { } userId)
return Result.Failure(AuthErrors.Unauthorized);
var request = await dbContext.PaymentRequests.FirstOrDefaultAsync(
r => r.Id == command.RequestId && r.UserId == userId,
cancellationToken
);
if (request is null)
return Result.Failure(BillingErrors.RequestNotFound);
if (request.Status != PaymentRequestStatus.AwaitingPayment)
return Result.Failure(BillingErrors.RequestNotCancellable);
request.Cancel();
return Result.Success();
}
}
@@ -0,0 +1,9 @@
using PnvPanel.Application.Common.Messaging;
using PnvPanel.Application.Common.Models;
using PnvPanel.Domain.Billing;
namespace PnvPanel.Application.Billing.CreatePaymentRequest;
public sealed record CreatePaymentRequestCommand(PaymentPeriod Period)
: ICommand<Result<PaymentRequestDto>>,
IRequiresActivation;
@@ -0,0 +1,64 @@
using Microsoft.EntityFrameworkCore;
using PnvPanel.Application.Auth;
using PnvPanel.Application.Common.Interfaces;
using PnvPanel.Application.Common.Messaging;
using PnvPanel.Application.Common.Models;
using PnvPanel.Domain.Billing;
namespace PnvPanel.Application.Billing.CreatePaymentRequest;
public sealed class CreatePaymentRequestCommandHandler(
IAppDbContext dbContext,
IIdentityService identityService,
ICurrentUser currentUser
) : ICommandHandler<CreatePaymentRequestCommand, Result<PaymentRequestDto>>
{
public async Task<Result<PaymentRequestDto>> Handle(
CreatePaymentRequestCommand command,
CancellationToken cancellationToken
)
{
if (currentUser.UserId is not { } userId)
return Result.Failure<PaymentRequestDto>(AuthErrors.Unauthorized);
var profile = await identityService.GetProfileAsync(userId, cancellationToken);
if (profile is null)
return Result.Failure<PaymentRequestDto>(AuthErrors.Unauthorized);
if (!profile.BillingEnabled)
return Result.Failure<PaymentRequestDto>(BillingErrors.NotEnabled);
if (profile.MaxConfigs == RoleQuota.Unlimited)
return Result.Failure<PaymentRequestDto>(BillingErrors.UnlimitedRoleNotSupported);
var hasActiveRequest = await dbContext.PaymentRequests.AnyAsync(
r =>
r.UserId == userId
&& (
r.Status == PaymentRequestStatus.AwaitingPayment
|| r.Status == PaymentRequestStatus.AwaitingConfirmation
),
cancellationToken
);
if (hasActiveRequest)
return Result.Failure<PaymentRequestDto>(BillingErrors.ActiveRequestExists);
var pricing = await dbContext.PricingSettings.AsNoTracking().FirstOrDefaultAsync(cancellationToken);
var ratePerMonth = command.Period switch
{
PaymentPeriod.Quarter => pricing?.PricePerConfigPerQuarter,
PaymentPeriod.HalfYear => pricing?.PricePerConfigPerHalfYear,
PaymentPeriod.Year => pricing?.PricePerConfigPerYear,
_ => null,
};
if (ratePerMonth is not { } rate)
return Result.Failure<PaymentRequestDto>(BillingErrors.PricingNotConfigured);
var amount = rate * profile.MaxConfigs * command.Period.ToMonths();
var request = PaymentRequest.Create(userId, command.Period, amount);
dbContext.PaymentRequests.Add(request);
return Result.Success(PaymentRequestDto.FromDomain(request));
}
}
@@ -0,0 +1,6 @@
using PnvPanel.Application.Common.Messaging;
using PnvPanel.Application.Common.Models;
namespace PnvPanel.Application.Billing.GetMyBillingStatus;
public sealed record GetMyBillingStatusQuery : IQuery<Result<BillingStatusDto>>, IRequiresActivation;
@@ -0,0 +1,56 @@
using Microsoft.EntityFrameworkCore;
using PnvPanel.Application.Auth;
using PnvPanel.Application.Common.Interfaces;
using PnvPanel.Application.Common.Messaging;
using PnvPanel.Application.Common.Models;
using PnvPanel.Domain.Billing;
namespace PnvPanel.Application.Billing.GetMyBillingStatus;
public sealed class GetMyBillingStatusQueryHandler(
IAppDbContext dbContext,
IIdentityService identityService,
ICurrentUser currentUser
) : IQueryHandler<GetMyBillingStatusQuery, Result<BillingStatusDto>>
{
public async Task<Result<BillingStatusDto>> Handle(
GetMyBillingStatusQuery query,
CancellationToken cancellationToken
)
{
if (currentUser.UserId is not { } userId)
return Result.Failure<BillingStatusDto>(AuthErrors.Unauthorized);
var profile = await identityService.GetProfileAsync(userId, cancellationToken);
if (profile is null)
return Result.Failure<BillingStatusDto>(AuthErrors.Unauthorized);
if (!profile.BillingEnabled)
return Result.Success(new BillingStatusDto(false, null, false, string.Empty, null));
var activeRequest = await dbContext
.PaymentRequests.AsNoTracking()
.Where(r =>
r.UserId == userId
&& (
r.Status == PaymentRequestStatus.AwaitingPayment
|| r.Status == PaymentRequestStatus.AwaitingConfirmation
)
)
.FirstOrDefaultAsync(cancellationToken);
var settings = await dbContext
.BillingSettings.AsNoTracking()
.FirstOrDefaultAsync(cancellationToken);
return Result.Success(
new BillingStatusDto(
true,
profile.BillingPaidUntil,
profile.BillingSuspended,
settings?.RequisitesText ?? string.Empty,
activeRequest is null ? null : PaymentRequestDto.FromDomain(activeRequest)
)
);
}
}
@@ -0,0 +1,6 @@
using PnvPanel.Application.Common.Messaging;
using PnvPanel.Application.Common.Models;
namespace PnvPanel.Application.Billing.MarkPaymentSent;
public sealed record MarkPaymentSentCommand(Guid RequestId) : ICommand<Result>, IRequiresActivation;
@@ -0,0 +1,45 @@
using Microsoft.EntityFrameworkCore;
using PnvPanel.Application.Auth;
using PnvPanel.Application.Common.Interfaces;
using PnvPanel.Application.Common.Messaging;
using PnvPanel.Application.Common.Models;
using PnvPanel.Domain.Billing;
namespace PnvPanel.Application.Billing.MarkPaymentSent;
public sealed class MarkPaymentSentCommandHandler(
IAppDbContext dbContext,
IIdentityService identityService,
ITelegramNotifier telegramNotifier,
ICurrentUser currentUser
) : ICommandHandler<MarkPaymentSentCommand, Result>
{
public async Task<Result> Handle(MarkPaymentSentCommand command, CancellationToken cancellationToken)
{
if (currentUser.UserId is not { } userId)
return Result.Failure(AuthErrors.Unauthorized);
var request = await dbContext.PaymentRequests.FirstOrDefaultAsync(
r => r.Id == command.RequestId && r.UserId == userId,
cancellationToken
);
if (request is null)
return Result.Failure(BillingErrors.RequestNotFound);
if (request.Status != PaymentRequestStatus.AwaitingPayment)
return Result.Failure(BillingErrors.RequestNotAwaitingPayment);
request.MarkPaymentSent();
var profile = await identityService.GetProfileAsync(userId, cancellationToken);
await telegramNotifier.NotifyAdminsPaymentRequestedAsync(
request.Id,
profile?.UserName ?? userId.ToString(),
request.Period,
request.AmountSnapshot,
cancellationToken
);
return Result.Success();
}
}
@@ -0,0 +1,15 @@
using PnvPanel.Domain.Billing;
namespace PnvPanel.Application.Billing;
public sealed record PaymentRequestDto(
Guid Id,
PaymentPeriod Period,
int AmountSnapshot,
PaymentRequestStatus Status,
DateTimeOffset CreatedAt
)
{
public static PaymentRequestDto FromDomain(PaymentRequest request) =>
new(request.Id, request.Period, request.AmountSnapshot, request.Status, request.CreatedAt);
}
@@ -0,0 +1,10 @@
using PnvPanel.Application.Common.Messaging;
using PnvPanel.Application.Common.Models;
namespace PnvPanel.Application.Billing.SendRequisitesToTelegram;
/// <summary>Дублирует реквизиты активной заявки в Telegram пользователю — для удобства (скопировать
/// с телефона), сам статус заявки не меняет.</summary>
public sealed record SendRequisitesToTelegramCommand(Guid RequestId)
: ICommand<Result>,
IRequiresActivation;
@@ -0,0 +1,56 @@
using Microsoft.EntityFrameworkCore;
using PnvPanel.Application.Auth;
using PnvPanel.Application.Common.Interfaces;
using PnvPanel.Application.Common.Messaging;
using PnvPanel.Application.Common.Models;
using PnvPanel.Application.Telegram;
using PnvPanel.Domain.Billing;
namespace PnvPanel.Application.Billing.SendRequisitesToTelegram;
public sealed class SendRequisitesToTelegramCommandHandler(
IAppDbContext dbContext,
IIdentityService identityService,
ITelegramNotifier telegramNotifier,
ICurrentUser currentUser
) : ICommandHandler<SendRequisitesToTelegramCommand, Result>
{
public async Task<Result> Handle(
SendRequisitesToTelegramCommand command,
CancellationToken cancellationToken
)
{
if (currentUser.UserId is not { } userId)
return Result.Failure(AuthErrors.Unauthorized);
var request = await dbContext
.PaymentRequests.AsNoTracking()
.FirstOrDefaultAsync(r => r.Id == command.RequestId && r.UserId == userId, cancellationToken);
if (request is null)
return Result.Failure(BillingErrors.RequestNotFound);
var linkInfo = await identityService.GetTelegramLinkInfoAsync(userId, cancellationToken);
if (!linkInfo.IsLinked)
return Result.Failure(TelegramErrors.NotLinked);
var settings = await dbContext
.BillingSettings.AsNoTracking()
.FirstOrDefaultAsync(cancellationToken);
var text =
$"💳 Реквизиты для оплаты ({PeriodLabel(request.Period)}, {request.AmountSnapshot} ₽):\n{settings?.RequisitesText}";
await telegramNotifier.NotifyUserAsync(userId, text, null, cancellationToken);
return Result.Success();
}
private static string PeriodLabel(PaymentPeriod period) =>
period switch
{
PaymentPeriod.Quarter => "3 месяца",
PaymentPeriod.HalfYear => "полгода",
PaymentPeriod.Year => "год",
_ => period.ToString(),
};
}
@@ -3,6 +3,7 @@ using Microsoft.EntityFrameworkCore.Infrastructure;
using PnvPanel.Domain.Activation;
using PnvPanel.Domain.Apps;
using PnvPanel.Domain.Audit;
using PnvPanel.Domain.Billing;
using PnvPanel.Domain.Configs;
using PnvPanel.Domain.Inbounds;
using PnvPanel.Domain.Instructions;
@@ -48,6 +49,10 @@ public interface IAppDbContext
DbSet<PricingSettings> PricingSettings { get; }
DbSet<BillingSettings> BillingSettings { get; }
DbSet<PaymentRequest> PaymentRequests { get; }
/// <summary>Нужен для advisory-lock при проверке квоты конфигов (см. CreateVpnConfigCommandHandler).</summary>
DatabaseFacade Database { get; }
@@ -13,7 +13,19 @@ public sealed record CurrentUserProfile(
bool IsBlocked,
int MaxConfigs,
int MaxIpLimit,
string SubscriptionToken
string SubscriptionToken,
bool BillingEnabled,
DateTimeOffset? BillingPaidUntil,
bool BillingSuspended
);
/// <summary>Срез биллинг-полей пользователя — для фоновой джобы приостановки (см. BillingService),
/// не требует полного CurrentUserProfile (роль/квоты там не нужны).</summary>
public sealed record BillingUserDto(
Guid UserId,
DateTimeOffset? PaidUntil,
bool Suspended,
DateTimeOffset? LastWarnedForPaidUntil
);
public sealed record UserSummaryDto(
@@ -135,4 +147,27 @@ public interface IIdentityService
Guid exceptUserId,
CancellationToken cancellationToken
);
/// <summary>Пользователи с billing-ролью (role.BillingEnabled), не заблокированные — обход для
/// BillingService (приостановка за неуплату / предупреждения).</summary>
Task<IReadOnlyList<BillingUserDto>> ListBillingUsersAsync(CancellationToken cancellationToken);
/// <summary>Гасит конфиги за неуплату на уровне пользователя (BillingSuspended=true) — сами
/// конфиги гасит вызывающая сторона (см. BillingService, по образцу BlockUserCommandHandler).</summary>
Task<Result> SuspendBillingAsync(Guid userId, CancellationToken cancellationToken);
/// <summary>Продлевает оплаченный период, снимает приостановку и сбрасывает флаг "предупреждение
/// отправлено" (новый срок ещё не близко к истечению). Используется и при подтверждении оплаты,
/// и при первичной выдаче грейс-периода.</summary>
Task<Result> ExtendBillingPaidUntilAsync(
Guid userId,
DateTimeOffset paidUntil,
CancellationToken cancellationToken
);
Task<Result> MarkBillingWarningSentAsync(
Guid userId,
DateTimeOffset paidUntil,
CancellationToken cancellationToken
);
}
@@ -2,7 +2,14 @@ using PnvPanel.Application.Common.Models;
namespace PnvPanel.Application.Common.Interfaces;
public sealed record RoleDto(Guid Id, string Name, int MaxConfigs, int MaxIpLimit, bool IsSystem);
public sealed record RoleDto(
Guid Id,
string Name,
int MaxConfigs,
int MaxIpLimit,
bool IsSystem,
bool BillingEnabled
);
public interface IRoleService
{
@@ -10,6 +17,7 @@ public interface IRoleService
string name,
int maxConfigs,
int maxIpLimit,
bool billingEnabled,
CancellationToken cancellationToken
);
@@ -17,6 +25,7 @@ public interface IRoleService
Guid roleId,
int maxConfigs,
int maxIpLimit,
bool billingEnabled,
CancellationToken cancellationToken
);
@@ -1,3 +1,4 @@
using PnvPanel.Domain.Billing;
using PnvPanel.Domain.Support;
namespace PnvPanel.Application.Common.Interfaces;
@@ -51,4 +52,14 @@ public interface ITelegramNotifier
TicketType type,
CancellationToken cancellationToken
);
/// <summary>Пользователь нажал «Я оплатил» — инлайн-кнопки «Подтвердить/Отклонить», решается
/// полностью в Telegram (аналогично заявке на роль).</summary>
Task NotifyAdminsPaymentRequestedAsync(
Guid requestId,
string userName,
PaymentPeriod period,
int amount,
CancellationToken cancellationToken
);
}
@@ -30,4 +30,9 @@ public static class ConfigErrors
"Configs.NodeUnavailable",
"Сервер временно недоступен. Попробуйте повторить операцию позже."
);
public static readonly Error BillingRequired = Error.Forbidden(
"Configs.BillingRequired",
"Требуется оплата подписки — оформите заявку на оплату в разделе «Оплата»."
);
}
@@ -36,6 +36,13 @@ public sealed class CreateVpnConfigCommandHandler(
if (!inbound.AllowedRoleIds.Contains(profile.RoleId))
return Result.Failure<VpnConfigDto>(ConfigErrors.InboundNotAllowedForRole);
// Не даём обойти приостановку за неуплату созданием нового конфига — см. BillingService.
if (
profile.BillingEnabled
&& (profile.BillingPaidUntil is null || profile.BillingPaidUntil < DateTimeOffset.UtcNow)
)
return Result.Failure<VpnConfigDto>(ConfigErrors.BillingRequired);
var node = await dbContext
.Nodes.AsNoTracking()
.FirstOrDefaultAsync(n => n.Id == inbound.NodeId, cancellationToken);
@@ -47,6 +54,8 @@ public sealed class CreateVpnConfigCommandHandler(
return Result.Failure<VpnConfigDto>(ConfigErrors.NodeDisabled);
var config = VpnConfig.Create(userId, inbound.Id, inbound.Protocol, command.Label);
if (profile.BillingEnabled)
config.SetBillingExpiry(profile.BillingPaidUntil);
var reserveResult = await ReserveQuotaSlotAsync(
userId,
@@ -0,0 +1,45 @@
using PnvPanel.Domain.Common;
namespace PnvPanel.Domain.Billing;
/// <summary>
/// Единственная строка в таблице — глобальные настройки биллинга, редактируются админом.
/// </summary>
public sealed class BillingSettings : Entity
{
/// <summary>Реквизиты для оплаты (карта/крипто-адрес/СБП и т.д.) — произвольный текст.</summary>
public string RequisitesText { get; private set; } = string.Empty;
/// <summary>Дней грейс-периода для пользователя, впервые попавшего под биллинг (назначена
/// billing-роль, или роли включили BillingEnabled, а PaidUntil ещё ни разу не выставлялся).</summary>
public int GraceDays { get; private set; } = DefaultGraceDays;
public const int DefaultGraceDays = 7;
/// <summary>Начальное состояние чекбокса "Включить биллинг" в диалоге создания новой роли —
/// чистое удобство админа, не влияет на уже существующие роли и не гейтит ничего само по себе.</summary>
public bool DefaultBillingEnabledForNewRoles { get; private set; }
public DateTimeOffset UpdatedAt { get; private set; }
private BillingSettings() { }
public static BillingSettings CreateDefault()
{
return new BillingSettings
{
Id = Guid.NewGuid(),
GraceDays = DefaultGraceDays,
DefaultBillingEnabledForNewRoles = false,
UpdatedAt = DateTimeOffset.UtcNow,
};
}
public void Update(string requisitesText, int graceDays, bool defaultBillingEnabledForNewRoles)
{
RequisitesText = requisitesText;
GraceDays = graceDays;
DefaultBillingEnabledForNewRoles = defaultBillingEnabledForNewRoles;
UpdatedAt = DateTimeOffset.UtcNow;
}
}
@@ -0,0 +1,20 @@
namespace PnvPanel.Domain.Billing;
public enum PaymentPeriod
{
Quarter,
HalfYear,
Year,
}
public static class PaymentPeriodExtensions
{
public static int ToMonths(this PaymentPeriod period) =>
period switch
{
PaymentPeriod.Quarter => 3,
PaymentPeriod.HalfYear => 6,
PaymentPeriod.Year => 12,
_ => throw new ArgumentOutOfRangeException(nameof(period)),
};
}
@@ -0,0 +1,81 @@
using PnvPanel.Domain.Common;
using PnvPanel.Domain.Exceptions;
namespace PnvPanel.Domain.Billing;
/// <summary>
/// Заявка пользователя на оплату подписки за период (квартал/полгода/год). Сумма замораживается на
/// момент создания (по действовавшей на тот момент ставке PricingSettings) — последующее изменение
/// прайса админом не меняет уже созданные заявки. Не более одной активной (AwaitingPayment/
/// AwaitingConfirmation) заявки на пользователя — инвариант проверяется на уровне Application.
/// </summary>
public sealed class PaymentRequest : Entity
{
public Guid UserId { get; private set; }
public PaymentPeriod Period { get; private set; }
public int AmountSnapshot { get; private set; }
public PaymentRequestStatus Status { get; private set; }
public Guid? DecidedBy { get; private set; }
public DateTimeOffset? DecidedAt { get; private set; }
public string? RejectionReason { get; private set; }
public DateTimeOffset CreatedAt { get; private set; }
private PaymentRequest() { }
public static PaymentRequest Create(Guid userId, PaymentPeriod period, int amountSnapshot)
{
return new PaymentRequest
{
Id = Guid.NewGuid(),
UserId = userId,
Period = period,
AmountSnapshot = amountSnapshot,
Status = PaymentRequestStatus.AwaitingPayment,
CreatedAt = DateTimeOffset.UtcNow,
};
}
/// <summary>Пользователь нажал «Я оплатил» — уходит на подтверждение админом.</summary>
public void MarkPaymentSent()
{
if (Status != PaymentRequestStatus.AwaitingPayment)
throw new DomainException($"Нельзя отметить оплаченной заявку в статусе {Status}.");
Status = PaymentRequestStatus.AwaitingConfirmation;
}
/// <summary>Пользователь передумал — можно только пока не заявлено «Я оплатил» (после этого
/// решение уже за админом, отменять заявку из-под него нельзя).</summary>
public void Cancel()
{
if (Status != PaymentRequestStatus.AwaitingPayment)
throw new DomainException($"Нельзя отменить заявку в статусе {Status}.");
Status = PaymentRequestStatus.Cancelled;
}
/// <summary>Допустимо и из AwaitingPayment (админ увидел оплату раньше, чем юзер нажал кнопку),
/// и из AwaitingConfirmation (обычный путь).</summary>
public void Confirm(Guid decidedBy)
{
EnsureDecidable();
Status = PaymentRequestStatus.Confirmed;
DecidedBy = decidedBy;
DecidedAt = DateTimeOffset.UtcNow;
}
public void Reject(Guid decidedBy, string? reason)
{
EnsureDecidable();
Status = PaymentRequestStatus.Rejected;
DecidedBy = decidedBy;
DecidedAt = DateTimeOffset.UtcNow;
RejectionReason = reason;
}
private void EnsureDecidable()
{
if (Status is not (PaymentRequestStatus.AwaitingPayment or PaymentRequestStatus.AwaitingConfirmation))
throw new DomainException($"Заявка уже обработана (статус {Status}).");
}
}
@@ -0,0 +1,10 @@
namespace PnvPanel.Domain.Billing;
public enum PaymentRequestStatus
{
AwaitingPayment,
AwaitingConfirmation,
Confirmed,
Rejected,
Cancelled,
}
@@ -88,6 +88,26 @@ public sealed class VpnConfig : Entity
Status = ConfigStatus.Active;
}
/// <summary>Приостановка за неуплату (биллинг) — отдельный статус от Disable/Enable (блокировка
/// админом), чтобы разблокировка/оплата не задевали друг друга по ошибке.</summary>
public void Suspend()
{
if (Status == ConfigStatus.Active)
Status = ConfigStatus.Expired;
}
/// <summary>Возврат из приостановки за неуплату — только то, что было погашено именно ею.</summary>
public void Resume()
{
if (Status == ConfigStatus.Expired)
Status = ConfigStatus.Active;
}
/// <summary>Денормализация даты окончания оплаченного периода пользователя (см. AppUser.BillingPaidUntil)
/// на конфиг — используется в Subscription-Userinfo для VPN-клиента (см. SubscriptionAssembler).
/// Null для ролей без биллинга.</summary>
public void SetBillingExpiry(DateTimeOffset? expiresAt) => ExpiresAt = expiresAt;
private void EnsureActive(string action)
{
if (Status != ConfigStatus.Active)
@@ -0,0 +1,175 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using PnvPanel.Application.Common.Interfaces;
using PnvPanel.Domain.Audit;
using PnvPanel.Domain.Billing;
using PnvPanel.Domain.Configs;
using PnvPanel.Infrastructure.Persistence;
namespace PnvPanel.Infrastructure.BackgroundJobs;
/// <summary>
/// Обходит пользователей с billing-ролью: гасит конфиги, у кого истёк оплаченный период (Active →
/// Expired, отдельно от блокировки админом — см. VpnConfig.Suspend), и шлёт предупреждение за 3 дня
/// до истечения. Пользователь с заявкой на оплату в AwaitingConfirmation не трогается вообще — пока
/// админ не подтвердит/отклонит, приостановка не наступает (не по вине пользователя, что админ не
/// успел проверить оплату).
/// </summary>
public sealed class BillingService(
IServiceScopeFactory scopeFactory,
ILogger<BillingService> logger
) : BackgroundService
{
private static readonly TimeSpan Interval = TimeSpan.FromHours(1);
private static readonly TimeSpan WarningWindow = TimeSpan.FromDays(3);
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
using var timer = new PeriodicTimer(Interval);
do
{
try
{
await RunAsync(stoppingToken);
}
catch (Exception ex)
{
logger.LogError(ex, "Billing cycle failed");
}
} while (await timer.WaitForNextTickAsync(stoppingToken));
}
private async Task RunAsync(CancellationToken cancellationToken)
{
await using var scope = scopeFactory.CreateAsyncScope();
var dbContext = scope.ServiceProvider.GetRequiredService<AppDbContext>();
var identityService = scope.ServiceProvider.GetRequiredService<IIdentityService>();
var gateway = scope.ServiceProvider.GetRequiredService<IXuiPanelGateway>();
var notifier = scope.ServiceProvider.GetRequiredService<IRealtimeNotifier>();
var telegramNotifier = scope.ServiceProvider.GetRequiredService<ITelegramNotifier>();
var billingUsers = await identityService.ListBillingUsersAsync(cancellationToken);
var now = DateTimeOffset.UtcNow;
foreach (var user in billingUsers)
{
// Заявка ждёт решения админа — не гасим конфиги, пока он не подтвердит/отклонит (см.
// ConfirmPaymentRequestCommandHandler/RejectPaymentRequestCommandHandler).
var hasAwaitingConfirmation = await dbContext.PaymentRequests.AnyAsync(
r => r.UserId == user.UserId && r.Status == PaymentRequestStatus.AwaitingConfirmation,
cancellationToken
);
if (hasAwaitingConfirmation)
continue;
if (user.PaidUntil is not { } paidUntil || paidUntil <= now)
{
// Идемпотентно гасим конфиги на каждом тике (самовосстановление после временной
// недоступности ноды), но уведомление/аудит/флаг — только один раз, при первом обнаружении.
await DisableActiveConfigsAsync(user.UserId, dbContext, gateway, notifier, cancellationToken);
if (!user.Suspended)
{
await identityService.SuspendBillingAsync(user.UserId, cancellationToken);
dbContext.AuditLogs.Add(
AuditLog.Create(
actorId: null,
"BillingSuspended",
"User",
user.UserId.ToString(),
metadata: null,
AuditSource.System
)
);
await telegramNotifier.NotifyUserAsync(
user.UserId,
"⛔ Оплата подписки истекла — конфиги приостановлены. Продлите в разделе «Оплата», чтобы восстановить доступ.",
"/billing",
cancellationToken
);
}
continue;
}
if (user.Suspended)
continue;
if (paidUntil - now <= WarningWindow && user.LastWarnedForPaidUntil != paidUntil)
{
await telegramNotifier.NotifyUserAsync(
user.UserId,
$"⚠️ Оплата подписки истекает {paidUntil:dd.MM.yyyy}. Продлите в разделе «Оплата», иначе конфиги будут приостановлены.",
"/billing",
cancellationToken
);
await identityService.MarkBillingWarningSentAsync(user.UserId, paidUntil, cancellationToken);
}
}
await dbContext.SaveChangesAsync(cancellationToken);
}
private async Task DisableActiveConfigsAsync(
Guid userId,
AppDbContext dbContext,
IXuiPanelGateway gateway,
IRealtimeNotifier notifier,
CancellationToken cancellationToken
)
{
var configs = await dbContext
.VpnConfigs.Where(c => c.UserId == userId && c.Status == ConfigStatus.Active)
.ToListAsync(cancellationToken);
foreach (var config in configs)
{
var inbound = await dbContext
.Inbounds.AsNoTracking()
.FirstOrDefaultAsync(i => i.Id == config.InboundId, cancellationToken);
var node = inbound is null
? null
: await dbContext
.Nodes.AsNoTracking()
.FirstOrDefaultAsync(n => n.Id == inbound.NodeId, cancellationToken);
if (inbound is not null && node is not null)
{
var updateResult = await gateway.UpdateClientAsync(
node,
inbound.RemoteInboundId,
config.ClientExternalId,
config.Protocol,
config.Label ?? config.ClientEmail,
enable: false,
cancellationToken
);
if (!updateResult.IsSuccess)
{
// Нода недоступна — конфиг остаётся Active, подхватится следующим циклом.
logger.LogWarning(
"Failed to disable client for config {ConfigId} on node {NodeId} while suspending user {UserId} for non-payment: {Error}",
config.Id,
node.Id,
userId,
updateResult.Error
);
continue;
}
}
config.Suspend();
await notifier.NotifyConfigStatusChangedAsync(
config.UserId,
config.Id,
config.Status,
cancellationToken
);
}
}
}
@@ -154,6 +154,7 @@ public static class DependencyInjection
services.AddHostedService<TrafficSyncService>();
services.AddHostedService<NodeHealthCheckService>();
services.AddHostedService<TrafficRetentionService>();
services.AddHostedService<BillingService>();
services.Configure<TelegramOptions>(configuration.GetSection(TelegramOptions.SectionName));
@@ -18,6 +18,10 @@ public class AppRole : IdentityRole<Guid>
public bool IsSystem { get; set; }
/// <summary>Включает биллинг для пользователей с этой ролью (недоступно для роли admin — см.
/// RoleService.UpdateRoleAsync).</summary>
public bool BillingEnabled { get; set; }
public AppRole() { }
public AppRole(string name)
@@ -24,4 +24,15 @@ public class AppUser : IdentityUser<Guid>
public string? TelegramUsername { get; set; }
public DateTimeOffset? TelegramLinkedAt { get; set; }
/// <summary>Оплачено до этой даты (только для billing-ролей); null — ещё ни разу не выставлялся.
/// Продлевается подтверждённой PaymentRequest, см. ConfirmPaymentRequestCommandHandler.</summary>
public DateTimeOffset? BillingPaidUntil { get; set; }
/// <summary>Конфиги приостановлены за неуплату (см. BillingService). Отдельно от IsBlocked.</summary>
public bool BillingSuspended { get; set; }
/// <summary>Для какого BillingPaidUntil уже отправлено предупреждение «истекает через N дней» —
/// не даёт слать его повторно на каждый тик джобы, пока PaidUntil не изменится.</summary>
public DateTimeOffset? BillingLastWarnedForPaidUntil { get; set; }
}
@@ -91,7 +91,10 @@ internal sealed class IdentityService(
user.IsBlocked,
role.MaxConfigs,
role.MaxIpLimit,
user.SubscriptionToken
user.SubscriptionToken,
role.BillingEnabled,
user.BillingPaidUntil,
user.BillingSuspended
);
}
@@ -376,6 +379,80 @@ internal sealed class IdentityService(
.ToListAsync(cancellationToken);
}
public async Task<IReadOnlyList<BillingUserDto>> ListBillingUsersAsync(
CancellationToken cancellationToken
)
{
var billingRoleNames = await roleManager
.Roles.AsNoTracking()
.Where(r => r.BillingEnabled)
.Select(r => r.Name!)
.ToListAsync(cancellationToken);
if (billingRoleNames.Count == 0)
return [];
var users = new List<BillingUserDto>();
foreach (var roleName in billingRoleNames)
{
var usersInRole = await userManager.GetUsersInRoleAsync(roleName);
users.AddRange(
usersInRole
.Where(u => !u.IsBlocked)
.Select(u => new BillingUserDto(
u.Id,
u.BillingPaidUntil,
u.BillingSuspended,
u.BillingLastWarnedForPaidUntil
))
);
}
return users;
}
public async Task<Result> SuspendBillingAsync(Guid userId, CancellationToken cancellationToken)
{
var user = await userManager.FindByIdAsync(userId.ToString());
if (user is null)
return Result.Failure(AuthErrors.Unauthorized);
user.BillingSuspended = true;
await userManager.UpdateAsync(user);
return Result.Success();
}
public async Task<Result> ExtendBillingPaidUntilAsync(
Guid userId,
DateTimeOffset paidUntil,
CancellationToken cancellationToken
)
{
var user = await userManager.FindByIdAsync(userId.ToString());
if (user is null)
return Result.Failure(AuthErrors.Unauthorized);
user.BillingPaidUntil = paidUntil;
user.BillingSuspended = false;
user.BillingLastWarnedForPaidUntil = null;
await userManager.UpdateAsync(user);
return Result.Success();
}
public async Task<Result> MarkBillingWarningSentAsync(
Guid userId,
DateTimeOffset paidUntil,
CancellationToken cancellationToken
)
{
var user = await userManager.FindByIdAsync(userId.ToString());
if (user is null)
return Result.Failure(AuthErrors.Unauthorized);
user.BillingLastWarnedForPaidUntil = paidUntil;
await userManager.UpdateAsync(user);
return Result.Success();
}
private async Task<string> GetPrimaryRoleNameAsync(AppUser user)
{
var roles = await userManager.GetRolesAsync(user);
@@ -4,29 +4,36 @@ using PnvPanel.Application.Admin.Roles;
using PnvPanel.Application.Admin.Users;
using PnvPanel.Application.Common.Interfaces;
using PnvPanel.Application.Common.Models;
using PnvPanel.Domain.Billing;
namespace PnvPanel.Infrastructure.Identity;
internal sealed class RoleService(
RoleManager<AppRole> roleManager,
UserManager<AppUser> userManager
UserManager<AppUser> userManager,
IAppDbContext dbContext
) : IRoleService
{
public async Task<Result<RoleDto>> CreateRoleAsync(
string name,
int maxConfigs,
int maxIpLimit,
bool billingEnabled,
CancellationToken cancellationToken
)
{
if (await roleManager.RoleExistsAsync(name))
return Result.Failure<RoleDto>(RoleErrors.DuplicateName);
if (billingEnabled && name.Equals(RoleNames.Admin, StringComparison.OrdinalIgnoreCase))
return Result.Failure<RoleDto>(RoleErrors.BillingNotAllowedForAdmin);
var role = new AppRole(name)
{
MaxConfigs = maxConfigs,
MaxIpLimit = maxIpLimit,
IsSystem = false,
BillingEnabled = billingEnabled,
};
var result = await roleManager.CreateAsync(role);
if (!result.Succeeded)
@@ -46,6 +53,7 @@ internal sealed class RoleService(
Guid roleId,
int maxConfigs,
int maxIpLimit,
bool billingEnabled,
CancellationToken cancellationToken
)
{
@@ -53,13 +61,50 @@ internal sealed class RoleService(
if (role is null)
return Result.Failure<RoleDto>(RoleErrors.NotFound);
if (
billingEnabled
&& role.Name!.Equals(RoleNames.Admin, StringComparison.OrdinalIgnoreCase)
)
return Result.Failure<RoleDto>(RoleErrors.BillingNotAllowedForAdmin);
var billingJustEnabled = billingEnabled && !role.BillingEnabled;
role.MaxConfigs = maxConfigs;
role.MaxIpLimit = maxIpLimit;
role.BillingEnabled = billingEnabled;
await roleManager.UpdateAsync(role);
if (billingJustEnabled)
{
var usersInRole = await userManager.GetUsersInRoleAsync(role.Name!);
await InitializeBillingGraceAsync(usersInRole, cancellationToken);
}
return Result.Success(ToDto(role));
}
/// <summary>Первая выдача грейс-периода — только пользователям, у которых оплата ещё ни разу не
/// выставлялась (BillingPaidUntil == null), чтобы не сбрасывать уже приостановленным/оплатившим.</summary>
private async Task InitializeBillingGraceAsync(
IEnumerable<AppUser> users,
CancellationToken cancellationToken
)
{
var usersNeedingGrace = users.Where(u => u.BillingPaidUntil is null).ToList();
if (usersNeedingGrace.Count == 0)
return;
var settings = await dbContext.BillingSettings.FirstOrDefaultAsync(cancellationToken);
var graceDays = settings?.GraceDays ?? BillingSettings.DefaultGraceDays;
var paidUntil = DateTimeOffset.UtcNow.AddDays(graceDays);
foreach (var user in usersNeedingGrace)
{
user.BillingPaidUntil = paidUntil;
await userManager.UpdateAsync(user);
}
}
public async Task<Result> DeleteRoleAsync(Guid roleId, CancellationToken cancellationToken)
{
var role = await roleManager.FindByIdAsync(roleId.ToString());
@@ -81,7 +126,14 @@ internal sealed class RoleService(
{
return await roleManager
.Roles.OrderBy(r => r.Name)
.Select(r => new RoleDto(r.Id, r.Name!, r.MaxConfigs, r.MaxIpLimit, r.IsSystem))
.Select(r => new RoleDto(
r.Id,
r.Name!,
r.MaxConfigs,
r.MaxIpLimit,
r.IsSystem,
r.BillingEnabled
))
.ToListAsync(cancellationToken);
}
@@ -114,9 +166,13 @@ internal sealed class RoleService(
await userManager.RemoveFromRolesAsync(user, currentRoles);
await userManager.AddToRoleAsync(user, role.Name!);
if (role.BillingEnabled)
await InitializeBillingGraceAsync([user], cancellationToken);
return Result.Success();
}
private static RoleDto ToDto(AppRole role) =>
new(role.Id, role.Name!, role.MaxConfigs, role.MaxIpLimit, role.IsSystem);
new(role.Id, role.Name!, role.MaxConfigs, role.MaxIpLimit, role.IsSystem, role.BillingEnabled);
}
@@ -4,6 +4,7 @@ using PnvPanel.Application.Common.Interfaces;
using PnvPanel.Domain.Activation;
using PnvPanel.Domain.Apps;
using PnvPanel.Domain.Audit;
using PnvPanel.Domain.Billing;
using PnvPanel.Domain.Configs;
using PnvPanel.Domain.Inbounds;
using PnvPanel.Domain.Instructions;
@@ -58,6 +59,10 @@ public class AppDbContext(DbContextOptions<AppDbContext> options)
public DbSet<PricingSettings> PricingSettings => Set<PricingSettings>();
public DbSet<BillingSettings> BillingSettings => Set<BillingSettings>();
public DbSet<PaymentRequest> PaymentRequests => Set<PaymentRequest>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
@@ -0,0 +1,16 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using PnvPanel.Domain.Billing;
namespace PnvPanel.Infrastructure.Persistence.Configurations;
public class BillingSettingsConfiguration : IEntityTypeConfiguration<BillingSettings>
{
public void Configure(EntityTypeBuilder<BillingSettings> builder)
{
builder.ToTable("BillingSettings");
builder.HasKey(x => x.Id);
builder.Property(x => x.RequisitesText).IsRequired().HasMaxLength(4000);
}
}
@@ -0,0 +1,20 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using PnvPanel.Domain.Billing;
namespace PnvPanel.Infrastructure.Persistence.Configurations;
public class PaymentRequestConfiguration : IEntityTypeConfiguration<PaymentRequest>
{
public void Configure(EntityTypeBuilder<PaymentRequest> builder)
{
builder.ToTable("PaymentRequests");
builder.HasKey(x => x.Id);
builder.Property(x => x.Period).HasConversion<string>().HasMaxLength(32);
builder.Property(x => x.Status).HasConversion<string>().HasMaxLength(32);
builder.Property(x => x.RejectionReason).HasMaxLength(500);
builder.HasIndex(x => new { x.UserId, x.Status });
}
}
@@ -0,0 +1,105 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace PnvPanel.Infrastructure.Persistence.Migrations
{
/// <inheritdoc />
public partial class AddBilling : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<DateTimeOffset>(
name: "BillingLastWarnedForPaidUntil",
table: "AspNetUsers",
type: "timestamp with time zone",
nullable: true);
migrationBuilder.AddColumn<DateTimeOffset>(
name: "BillingPaidUntil",
table: "AspNetUsers",
type: "timestamp with time zone",
nullable: true);
migrationBuilder.AddColumn<bool>(
name: "BillingSuspended",
table: "AspNetUsers",
type: "boolean",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<bool>(
name: "BillingEnabled",
table: "AspNetRoles",
type: "boolean",
nullable: false,
defaultValue: false);
migrationBuilder.CreateTable(
name: "BillingSettings",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
RequisitesText = table.Column<string>(type: "character varying(4000)", maxLength: 4000, nullable: false),
GraceDays = table.Column<int>(type: "integer", nullable: false),
UpdatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_BillingSettings", x => x.Id);
});
migrationBuilder.CreateTable(
name: "PaymentRequests",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
UserId = table.Column<Guid>(type: "uuid", nullable: false),
Period = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
AmountSnapshot = table.Column<int>(type: "integer", nullable: false),
Status = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
DecidedBy = table.Column<Guid>(type: "uuid", nullable: true),
DecidedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
RejectionReason = table.Column<string>(type: "character varying(500)", maxLength: 500, nullable: true),
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_PaymentRequests", x => x.Id);
});
migrationBuilder.CreateIndex(
name: "IX_PaymentRequests_UserId_Status",
table: "PaymentRequests",
columns: new[] { "UserId", "Status" });
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "BillingSettings");
migrationBuilder.DropTable(
name: "PaymentRequests");
migrationBuilder.DropColumn(
name: "BillingLastWarnedForPaidUntil",
table: "AspNetUsers");
migrationBuilder.DropColumn(
name: "BillingPaidUntil",
table: "AspNetUsers");
migrationBuilder.DropColumn(
name: "BillingSuspended",
table: "AspNetUsers");
migrationBuilder.DropColumn(
name: "BillingEnabled",
table: "AspNetRoles");
}
}
}
@@ -0,0 +1,29 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace PnvPanel.Infrastructure.Persistence.Migrations
{
/// <inheritdoc />
public partial class AddBillingDefaultForNewRoles : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "DefaultBillingEnabledForNewRoles",
table: "BillingSettings",
type: "boolean",
nullable: false,
defaultValue: false);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "DefaultBillingEnabledForNewRoles",
table: "BillingSettings");
}
}
}
@@ -250,6 +250,73 @@ namespace PnvPanel.Infrastructure.Persistence.Migrations
b.ToTable("AuditLogs", (string)null);
});
modelBuilder.Entity("PnvPanel.Domain.Billing.BillingSettings", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<bool>("DefaultBillingEnabledForNewRoles")
.HasColumnType("boolean");
b.Property<int>("GraceDays")
.HasColumnType("integer");
b.Property<string>("RequisitesText")
.IsRequired()
.HasMaxLength(4000)
.HasColumnType("character varying(4000)");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.ToTable("BillingSettings", (string)null);
});
modelBuilder.Entity("PnvPanel.Domain.Billing.PaymentRequest", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<int>("AmountSnapshot")
.HasColumnType("integer");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<DateTimeOffset?>("DecidedAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid?>("DecidedBy")
.HasColumnType("uuid");
b.Property<string>("Period")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)");
b.Property<string>("RejectionReason")
.HasMaxLength(500)
.HasColumnType("character varying(500)");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)");
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("UserId", "Status");
b.ToTable("PaymentRequests", (string)null);
});
modelBuilder.Entity("PnvPanel.Domain.Configs.TrafficSample", b =>
{
b.Property<long>("Id")
@@ -713,6 +780,9 @@ namespace PnvPanel.Infrastructure.Persistence.Migrations
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<bool>("BillingEnabled")
.HasColumnType("boolean");
b.Property<string>("ConcurrencyStamp")
.IsConcurrencyToken()
.HasColumnType("text");
@@ -758,6 +828,15 @@ namespace PnvPanel.Infrastructure.Persistence.Migrations
b.Property<Guid?>("ActivatedBy")
.HasColumnType("uuid");
b.Property<DateTimeOffset?>("BillingLastWarnedForPaidUntil")
.HasColumnType("timestamp with time zone");
b.Property<DateTimeOffset?>("BillingPaidUntil")
.HasColumnType("timestamp with time zone");
b.Property<bool>("BillingSuspended")
.HasColumnType("boolean");
b.Property<string>("ConcurrencyStamp")
.IsConcurrencyToken()
.HasColumnType("text");
@@ -0,0 +1,237 @@
using Microsoft.Extensions.Logging;
using NSubstitute;
using PnvPanel.Application.Admin.Billing;
using PnvPanel.Application.Billing;
using PnvPanel.Application.Common.Interfaces;
using PnvPanel.Application.Common.Models;
using PnvPanel.Application.Tests.TestSupport;
using PnvPanel.Domain.Billing;
using PnvPanel.Domain.Configs;
using PnvPanel.Domain.Inbounds;
using PnvPanel.Domain.Nodes;
using Xunit;
namespace PnvPanel.Application.Tests.Admin.Billing;
public class ConfirmPaymentRequestCommandHandlerTests
{
private readonly IIdentityService _identityService = Substitute.For<IIdentityService>();
private readonly IXuiPanelGateway _gateway = Substitute.For<IXuiPanelGateway>();
private readonly IRealtimeNotifier _notifier = Substitute.For<IRealtimeNotifier>();
private readonly ITelegramNotifier _telegramNotifier = Substitute.For<ITelegramNotifier>();
private readonly ILogger<ConfirmPaymentRequestCommandHandler> _logger = Substitute.For<
ILogger<ConfirmPaymentRequestCommandHandler>
>();
private static CurrentUserProfile Profile(Guid userId, DateTimeOffset? paidUntil) =>
new(
userId,
"alice",
Guid.NewGuid(),
"premium",
IsActivated: true,
IsBlocked: false,
MaxConfigs: 5,
MaxIpLimit: 3,
SubscriptionToken: "sub-token",
BillingEnabled: true,
BillingPaidUntil: paidUntil,
BillingSuspended: paidUntil is null
);
[Fact]
public async Task Handle_WhenNoPriorPayment_ExtendsFromNow()
{
using var dbContext = InMemoryDbContextFactory.Create();
var adminId = Guid.NewGuid();
var userId = Guid.NewGuid();
var request = PaymentRequest.Create(userId, PaymentPeriod.Quarter, 1500);
request.MarkPaymentSent();
dbContext.PaymentRequests.Add(request);
await dbContext.SaveChangesAsync(CancellationToken.None);
_identityService
.GetProfileAsync(userId, Arg.Any<CancellationToken>())
.Returns(Profile(userId, paidUntil: null));
_identityService
.ExtendBillingPaidUntilAsync(userId, Arg.Any<DateTimeOffset>(), Arg.Any<CancellationToken>())
.Returns(Result.Success());
var handler = new ConfirmPaymentRequestCommandHandler(
dbContext,
_identityService,
_gateway,
_notifier,
_telegramNotifier,
FakeCurrentUser.Authenticated(adminId, "admin"),
_logger
);
var before = DateTimeOffset.UtcNow;
var result = await handler.Handle(
new ConfirmPaymentRequestCommand(request.Id),
CancellationToken.None
);
Assert.True(result.IsSuccess);
Assert.Equal(PaymentRequestStatus.Confirmed, request.Status);
await _identityService
.Received(1)
.ExtendBillingPaidUntilAsync(
userId,
Arg.Is<DateTimeOffset>(d => d >= before.AddMonths(3).AddMinutes(-1)),
Arg.Any<CancellationToken>()
);
}
[Fact]
public async Task Handle_WhenPaidUntilInFuture_ExtendsFromExistingPaidUntil()
{
using var dbContext = InMemoryDbContextFactory.Create();
var adminId = Guid.NewGuid();
var userId = Guid.NewGuid();
var existingPaidUntil = DateTimeOffset.UtcNow.AddDays(20);
var request = PaymentRequest.Create(userId, PaymentPeriod.Quarter, 1500);
request.MarkPaymentSent();
dbContext.PaymentRequests.Add(request);
await dbContext.SaveChangesAsync(CancellationToken.None);
_identityService
.GetProfileAsync(userId, Arg.Any<CancellationToken>())
.Returns(Profile(userId, paidUntil: existingPaidUntil));
_identityService
.ExtendBillingPaidUntilAsync(userId, Arg.Any<DateTimeOffset>(), Arg.Any<CancellationToken>())
.Returns(Result.Success());
var handler = new ConfirmPaymentRequestCommandHandler(
dbContext,
_identityService,
_gateway,
_notifier,
_telegramNotifier,
FakeCurrentUser.Authenticated(adminId, "admin"),
_logger
);
await handler.Handle(new ConfirmPaymentRequestCommand(request.Id), CancellationToken.None);
var expected = existingPaidUntil.AddMonths(3);
await _identityService
.Received(1)
.ExtendBillingPaidUntilAsync(
userId,
Arg.Is<DateTimeOffset>(d => Math.Abs((d - expected).TotalSeconds) < 5),
Arg.Any<CancellationToken>()
);
}
[Fact]
public async Task Handle_ResumesExpiredConfigsAndSyncsExpiry()
{
using var dbContext = InMemoryDbContextFactory.Create();
var adminId = Guid.NewGuid();
var userId = Guid.NewGuid();
var node = Node.Register(
"node-1",
new Uri("https://node1.example.com"),
new NodeCredentials("admin", "protected"),
null
);
var inbound = Inbound.FromRemote(node.Id, "1", VpnProtocol.Vless, "remark", 443);
var expiredConfig = VpnConfig.Create(userId, inbound.Id, VpnProtocol.Vless, null);
expiredConfig.AssignRemoteClient("ext-1");
expiredConfig.Suspend();
var activeConfig = VpnConfig.Create(userId, inbound.Id, VpnProtocol.Vless, null);
activeConfig.AssignRemoteClient("ext-2");
dbContext.Nodes.Add(node);
dbContext.Inbounds.Add(inbound);
dbContext.VpnConfigs.AddRange(expiredConfig, activeConfig);
var request = PaymentRequest.Create(userId, PaymentPeriod.Quarter, 1500);
request.MarkPaymentSent();
dbContext.PaymentRequests.Add(request);
await dbContext.SaveChangesAsync(CancellationToken.None);
_identityService
.GetProfileAsync(userId, Arg.Any<CancellationToken>())
.Returns(Profile(userId, paidUntil: null));
_identityService
.ExtendBillingPaidUntilAsync(userId, Arg.Any<DateTimeOffset>(), Arg.Any<CancellationToken>())
.Returns(Result.Success());
_gateway
.UpdateClientAsync(
Arg.Any<Node>(),
Arg.Any<string>(),
Arg.Any<string>(),
Arg.Any<VpnProtocol>(),
Arg.Any<string>(),
enable: true,
Arg.Any<CancellationToken>()
)
.Returns(Result.Success());
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(ConfigStatus.Active, expiredConfig.Status);
Assert.NotNull(expiredConfig.ExpiresAt);
Assert.NotNull(activeConfig.ExpiresAt);
Assert.Equal(expiredConfig.ExpiresAt, activeConfig.ExpiresAt);
await _gateway
.Received(1)
.UpdateClientAsync(
Arg.Any<Node>(),
inbound.RemoteInboundId,
"ext-1",
VpnProtocol.Vless,
Arg.Any<string>(),
enable: true,
Arg.Any<CancellationToken>()
);
}
[Fact]
public async Task Handle_WhenRequestAlreadyDecided_ReturnsNotDecidable()
{
using var dbContext = InMemoryDbContextFactory.Create();
var adminId = Guid.NewGuid();
var userId = Guid.NewGuid();
var request = PaymentRequest.Create(userId, PaymentPeriod.Quarter, 1500);
request.Cancel();
dbContext.PaymentRequests.Add(request);
await dbContext.SaveChangesAsync(CancellationToken.None);
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.False(result.IsSuccess);
Assert.Equal(BillingErrors.RequestNotDecidable, result.Error);
}
}
@@ -0,0 +1,69 @@
using NSubstitute;
using PnvPanel.Application.Admin.Billing;
using PnvPanel.Application.Billing;
using PnvPanel.Application.Common.Interfaces;
using PnvPanel.Application.Tests.TestSupport;
using PnvPanel.Domain.Billing;
using Xunit;
namespace PnvPanel.Application.Tests.Admin.Billing;
public class RejectPaymentRequestCommandHandlerTests
{
private readonly ITelegramNotifier _telegramNotifier = Substitute.For<ITelegramNotifier>();
[Fact]
public async Task Handle_WhenAwaitingConfirmation_RejectsAndNotifiesUser()
{
using var dbContext = InMemoryDbContextFactory.Create();
var adminId = Guid.NewGuid();
var userId = Guid.NewGuid();
var request = PaymentRequest.Create(userId, PaymentPeriod.Quarter, 1500);
request.MarkPaymentSent();
dbContext.PaymentRequests.Add(request);
await dbContext.SaveChangesAsync(CancellationToken.None);
var handler = new RejectPaymentRequestCommandHandler(
dbContext,
_telegramNotifier,
FakeCurrentUser.Authenticated(adminId, "admin")
);
var result = await handler.Handle(
new RejectPaymentRequestCommand(request.Id, "Платёж не найден"),
CancellationToken.None
);
Assert.True(result.IsSuccess);
Assert.Equal(PaymentRequestStatus.Rejected, request.Status);
Assert.Equal("Платёж не найден", request.RejectionReason);
await _telegramNotifier
.Received(1)
.NotifyUserAsync(
userId,
Arg.Is<string>(m => m.Contains("Платёж не найден")),
Arg.Any<string?>(),
Arg.Any<CancellationToken>()
);
}
[Fact]
public async Task Handle_WhenRequestNotFound_ReturnsRequestNotFound()
{
using var dbContext = InMemoryDbContextFactory.Create();
var handler = new RejectPaymentRequestCommandHandler(
dbContext,
_telegramNotifier,
FakeCurrentUser.Authenticated(Guid.NewGuid(), "admin")
);
var result = await handler.Handle(
new RejectPaymentRequestCommand(Guid.NewGuid(), null),
CancellationToken.None
);
Assert.False(result.IsSuccess);
Assert.Equal(BillingErrors.RequestNotFound, result.Error);
}
}
@@ -72,8 +72,8 @@ public class FactoryResetCommandHandlerTests
.Returns(
new List<RoleDto>
{
new(adminRoleId, "admin", -1, -1, IsSystem: true),
new(customRoleId, "premium", 10, 5, IsSystem: false),
new(adminRoleId, "admin", -1, -1, IsSystem: true, BillingEnabled: false),
new(customRoleId, "premium", 10, 5, IsSystem: false, BillingEnabled: false),
}
);
_roleService
@@ -27,8 +27,8 @@ public class ApproveRoleRequestCommandHandlerTests
var newRoleId = Guid.NewGuid();
_roleService
.CreateRoleAsync("premium", 10, 5, Arg.Any<CancellationToken>())
.Returns(Result.Success(new RoleDto(newRoleId, "premium", 10, 5, false)));
.CreateRoleAsync("premium", 10, 5, false, Arg.Any<CancellationToken>())
.Returns(Result.Success(new RoleDto(newRoleId, "premium", 10, 5, false, false)));
_roleService
.ChangeUserRoleAsync(userId, newRoleId, Arg.Any<CancellationToken>())
.Returns(Result.Success());
@@ -51,7 +51,7 @@ public class ApproveRoleRequestCommandHandlerTests
Assert.Equal(TicketStatus.Resolved, ticket.Status);
await _roleService
.Received(1)
.CreateRoleAsync("premium", 10, 5, Arg.Any<CancellationToken>());
.CreateRoleAsync("premium", 10, 5, false, Arg.Any<CancellationToken>());
await _roleService
.Received(1)
.ChangeUserRoleAsync(userId, newRoleId, Arg.Any<CancellationToken>());
@@ -100,6 +100,7 @@ public class ApproveRoleRequestCommandHandlerTests
Arg.Any<string>(),
Arg.Any<int>(),
Arg.Any<int>(),
Arg.Any<bool>(),
Arg.Any<CancellationToken>()
);
}
@@ -55,7 +55,10 @@ public class GetCurrentUserQueryHandlerTests
false,
3,
RoleQuota.Unlimited,
"sub-token"
"sub-token",
false,
null,
false
);
_identityService.GetProfileAsync(userId, Arg.Any<CancellationToken>()).Returns(profile);
_identityService
@@ -31,7 +31,10 @@ public class LoginCommandHandlerTests
IsBlocked: false,
MaxConfigs: 3,
MaxIpLimit: RoleQuota.Unlimited,
SubscriptionToken: "sub-token"
SubscriptionToken: "sub-token",
BillingEnabled: false,
BillingPaidUntil: null,
BillingSuspended: false
);
_identityService
@@ -30,7 +30,10 @@ public class RefreshCommandHandlerTests
IsBlocked: false,
MaxConfigs: 3,
MaxIpLimit: RoleQuota.Unlimited,
SubscriptionToken: "sub-token"
SubscriptionToken: "sub-token",
BillingEnabled: false,
BillingPaidUntil: null,
BillingSuspended: false
);
var rotated = new RotatedRefreshToken(
userId,
@@ -0,0 +1,79 @@
using PnvPanel.Application.Billing;
using PnvPanel.Application.Billing.CancelPaymentRequest;
using PnvPanel.Application.Tests.TestSupport;
using PnvPanel.Domain.Billing;
using Xunit;
namespace PnvPanel.Application.Tests.Billing.CancelPaymentRequest;
public class CancelPaymentRequestCommandHandlerTests
{
[Fact]
public async Task Handle_WhenAwaitingPayment_Cancels()
{
using var dbContext = InMemoryDbContextFactory.Create();
var userId = Guid.NewGuid();
var request = PaymentRequest.Create(userId, PaymentPeriod.Quarter, 1000);
dbContext.PaymentRequests.Add(request);
await dbContext.SaveChangesAsync(CancellationToken.None);
var handler = new CancelPaymentRequestCommandHandler(
dbContext,
FakeCurrentUser.Authenticated(userId)
);
var result = await handler.Handle(
new CancelPaymentRequestCommand(request.Id),
CancellationToken.None
);
Assert.True(result.IsSuccess);
Assert.Equal(PaymentRequestStatus.Cancelled, request.Status);
}
[Fact]
public async Task Handle_WhenAwaitingConfirmation_ReturnsNotCancellable()
{
using var dbContext = InMemoryDbContextFactory.Create();
var userId = Guid.NewGuid();
var request = PaymentRequest.Create(userId, PaymentPeriod.Quarter, 1000);
request.MarkPaymentSent();
dbContext.PaymentRequests.Add(request);
await dbContext.SaveChangesAsync(CancellationToken.None);
var handler = new CancelPaymentRequestCommandHandler(
dbContext,
FakeCurrentUser.Authenticated(userId)
);
var result = await handler.Handle(
new CancelPaymentRequestCommand(request.Id),
CancellationToken.None
);
Assert.False(result.IsSuccess);
Assert.Equal(BillingErrors.RequestNotCancellable, result.Error);
}
[Fact]
public async Task Handle_WhenOwnedByAnotherUser_ReturnsNotFound()
{
using var dbContext = InMemoryDbContextFactory.Create();
var request = PaymentRequest.Create(Guid.NewGuid(), PaymentPeriod.Quarter, 1000);
dbContext.PaymentRequests.Add(request);
await dbContext.SaveChangesAsync(CancellationToken.None);
var handler = new CancelPaymentRequestCommandHandler(
dbContext,
FakeCurrentUser.Authenticated(Guid.NewGuid())
);
var result = await handler.Handle(
new CancelPaymentRequestCommand(request.Id),
CancellationToken.None
);
Assert.False(result.IsSuccess);
Assert.Equal(BillingErrors.RequestNotFound, result.Error);
}
}
@@ -0,0 +1,166 @@
using NSubstitute;
using PnvPanel.Application.Billing;
using PnvPanel.Application.Billing.CreatePaymentRequest;
using PnvPanel.Application.Common.Interfaces;
using PnvPanel.Application.Common.Models;
using PnvPanel.Application.Tests.TestSupport;
using PnvPanel.Domain.Billing;
using PnvPanel.Domain.Pricing;
using Xunit;
namespace PnvPanel.Application.Tests.Billing.CreatePaymentRequest;
public class CreatePaymentRequestCommandHandlerTests
{
private readonly IIdentityService _identityService = Substitute.For<IIdentityService>();
private static CurrentUserProfile Profile(
Guid userId,
bool billingEnabled = true,
int maxConfigs = 5,
DateTimeOffset? paidUntil = null
) =>
new(
userId,
"alice",
Guid.NewGuid(),
"premium",
IsActivated: true,
IsBlocked: false,
MaxConfigs: maxConfigs,
MaxIpLimit: 3,
SubscriptionToken: "sub-token",
BillingEnabled: billingEnabled,
BillingPaidUntil: paidUntil,
BillingSuspended: false
);
[Fact]
public async Task Handle_WithConfiguredPricing_ComputesAmountAndCreatesRequest()
{
using var dbContext = InMemoryDbContextFactory.Create();
var userId = Guid.NewGuid();
var pricing = PricingSettings.CreateDefault();
pricing.Update(500, 450, 400);
dbContext.PricingSettings.Add(pricing);
await dbContext.SaveChangesAsync(CancellationToken.None);
_identityService
.GetProfileAsync(userId, Arg.Any<CancellationToken>())
.Returns(Profile(userId, maxConfigs: 5));
var handler = new CreatePaymentRequestCommandHandler(
dbContext,
_identityService,
FakeCurrentUser.Authenticated(userId)
);
var result = await handler.Handle(
new CreatePaymentRequestCommand(PaymentPeriod.Quarter),
CancellationToken.None
);
Assert.True(result.IsSuccess);
Assert.Equal(500 * 5 * 3, result.Value.AmountSnapshot);
Assert.Equal(PaymentRequestStatus.AwaitingPayment, result.Value.Status);
}
[Fact]
public async Task Handle_WhenBillingNotEnabled_ReturnsNotEnabled()
{
using var dbContext = InMemoryDbContextFactory.Create();
var userId = Guid.NewGuid();
_identityService
.GetProfileAsync(userId, Arg.Any<CancellationToken>())
.Returns(Profile(userId, billingEnabled: false));
var handler = new CreatePaymentRequestCommandHandler(
dbContext,
_identityService,
FakeCurrentUser.Authenticated(userId)
);
var result = await handler.Handle(
new CreatePaymentRequestCommand(PaymentPeriod.Quarter),
CancellationToken.None
);
Assert.False(result.IsSuccess);
Assert.Equal(BillingErrors.NotEnabled, result.Error);
}
[Fact]
public async Task Handle_WhenRoleHasUnlimitedConfigs_ReturnsUnlimitedRoleNotSupported()
{
using var dbContext = InMemoryDbContextFactory.Create();
var userId = Guid.NewGuid();
_identityService
.GetProfileAsync(userId, Arg.Any<CancellationToken>())
.Returns(Profile(userId, maxConfigs: RoleQuota.Unlimited));
var handler = new CreatePaymentRequestCommandHandler(
dbContext,
_identityService,
FakeCurrentUser.Authenticated(userId)
);
var result = await handler.Handle(
new CreatePaymentRequestCommand(PaymentPeriod.Quarter),
CancellationToken.None
);
Assert.False(result.IsSuccess);
Assert.Equal(BillingErrors.UnlimitedRoleNotSupported, result.Error);
}
[Fact]
public async Task Handle_WhenActiveRequestExists_ReturnsActiveRequestExists()
{
using var dbContext = InMemoryDbContextFactory.Create();
var userId = Guid.NewGuid();
dbContext.PaymentRequests.Add(PaymentRequest.Create(userId, PaymentPeriod.Quarter, 1000));
await dbContext.SaveChangesAsync(CancellationToken.None);
_identityService
.GetProfileAsync(userId, Arg.Any<CancellationToken>())
.Returns(Profile(userId));
var handler = new CreatePaymentRequestCommandHandler(
dbContext,
_identityService,
FakeCurrentUser.Authenticated(userId)
);
var result = await handler.Handle(
new CreatePaymentRequestCommand(PaymentPeriod.Quarter),
CancellationToken.None
);
Assert.False(result.IsSuccess);
Assert.Equal(BillingErrors.ActiveRequestExists, result.Error);
}
[Fact]
public async Task Handle_WhenPricingNotConfigured_ReturnsPricingNotConfigured()
{
using var dbContext = InMemoryDbContextFactory.Create();
var userId = Guid.NewGuid();
_identityService
.GetProfileAsync(userId, Arg.Any<CancellationToken>())
.Returns(Profile(userId));
var handler = new CreatePaymentRequestCommandHandler(
dbContext,
_identityService,
FakeCurrentUser.Authenticated(userId)
);
var result = await handler.Handle(
new CreatePaymentRequestCommand(PaymentPeriod.Quarter),
CancellationToken.None
);
Assert.False(result.IsSuccess);
Assert.Equal(BillingErrors.PricingNotConfigured, result.Error);
}
}
@@ -0,0 +1,85 @@
using NSubstitute;
using PnvPanel.Application.Billing.GetMyBillingStatus;
using PnvPanel.Application.Common.Interfaces;
using PnvPanel.Application.Tests.TestSupport;
using PnvPanel.Domain.Billing;
using Xunit;
namespace PnvPanel.Application.Tests.Billing.GetMyBillingStatus;
public class GetMyBillingStatusQueryHandlerTests
{
private readonly IIdentityService _identityService = Substitute.For<IIdentityService>();
private static CurrentUserProfile Profile(
Guid userId,
bool billingEnabled,
DateTimeOffset? paidUntil = null,
bool suspended = false
) =>
new(
userId,
"alice",
Guid.NewGuid(),
"premium",
IsActivated: true,
IsBlocked: false,
MaxConfigs: 5,
MaxIpLimit: 3,
SubscriptionToken: "sub-token",
BillingEnabled: billingEnabled,
BillingPaidUntil: paidUntil,
BillingSuspended: suspended
);
[Fact]
public async Task Handle_WhenBillingNotEnabled_ReturnsDisabledStatusWithoutQueryingRequests()
{
using var dbContext = InMemoryDbContextFactory.Create();
var userId = Guid.NewGuid();
_identityService
.GetProfileAsync(userId, Arg.Any<CancellationToken>())
.Returns(Profile(userId, billingEnabled: false));
var handler = new GetMyBillingStatusQueryHandler(
dbContext,
_identityService,
FakeCurrentUser.Authenticated(userId)
);
var result = await handler.Handle(new GetMyBillingStatusQuery(), CancellationToken.None);
Assert.True(result.IsSuccess);
Assert.False(result.Value.BillingEnabled);
Assert.Null(result.Value.ActiveRequest);
}
[Fact]
public async Task Handle_WhenActiveRequestExists_IncludesItInStatus()
{
using var dbContext = InMemoryDbContextFactory.Create();
var userId = Guid.NewGuid();
var paidUntil = DateTimeOffset.UtcNow.AddDays(10);
var request = PaymentRequest.Create(userId, PaymentPeriod.Quarter, 1500);
dbContext.PaymentRequests.Add(request);
await dbContext.SaveChangesAsync(CancellationToken.None);
_identityService
.GetProfileAsync(userId, Arg.Any<CancellationToken>())
.Returns(Profile(userId, billingEnabled: true, paidUntil: paidUntil));
var handler = new GetMyBillingStatusQueryHandler(
dbContext,
_identityService,
FakeCurrentUser.Authenticated(userId)
);
var result = await handler.Handle(new GetMyBillingStatusQuery(), CancellationToken.None);
Assert.True(result.IsSuccess);
Assert.True(result.Value.BillingEnabled);
Assert.Equal(paidUntil, result.Value.PaidUntil);
Assert.NotNull(result.Value.ActiveRequest);
Assert.Equal(request.Id, result.Value.ActiveRequest!.Id);
}
}
@@ -0,0 +1,75 @@
using NSubstitute;
using PnvPanel.Application.Billing;
using PnvPanel.Application.Billing.MarkPaymentSent;
using PnvPanel.Application.Common.Interfaces;
using PnvPanel.Application.Tests.TestSupport;
using PnvPanel.Domain.Billing;
using Xunit;
namespace PnvPanel.Application.Tests.Billing.MarkPaymentSent;
public class MarkPaymentSentCommandHandlerTests
{
private readonly IIdentityService _identityService = Substitute.For<IIdentityService>();
private readonly ITelegramNotifier _telegramNotifier = Substitute.For<ITelegramNotifier>();
[Fact]
public async Task Handle_WhenAwaitingPayment_MovesToAwaitingConfirmationAndNotifiesAdmins()
{
using var dbContext = InMemoryDbContextFactory.Create();
var userId = Guid.NewGuid();
var request = PaymentRequest.Create(userId, PaymentPeriod.Year, 6000);
dbContext.PaymentRequests.Add(request);
await dbContext.SaveChangesAsync(CancellationToken.None);
var handler = new MarkPaymentSentCommandHandler(
dbContext,
_identityService,
_telegramNotifier,
FakeCurrentUser.Authenticated(userId, "alice")
);
var result = await handler.Handle(
new MarkPaymentSentCommand(request.Id),
CancellationToken.None
);
Assert.True(result.IsSuccess);
Assert.Equal(PaymentRequestStatus.AwaitingConfirmation, request.Status);
await _telegramNotifier
.Received(1)
.NotifyAdminsPaymentRequestedAsync(
request.Id,
Arg.Any<string>(),
PaymentPeriod.Year,
6000,
Arg.Any<CancellationToken>()
);
}
[Fact]
public async Task Handle_WhenAlreadyAwaitingConfirmation_ReturnsNotAwaitingPayment()
{
using var dbContext = InMemoryDbContextFactory.Create();
var userId = Guid.NewGuid();
var request = PaymentRequest.Create(userId, PaymentPeriod.Year, 6000);
request.MarkPaymentSent();
dbContext.PaymentRequests.Add(request);
await dbContext.SaveChangesAsync(CancellationToken.None);
var handler = new MarkPaymentSentCommandHandler(
dbContext,
_identityService,
_telegramNotifier,
FakeCurrentUser.Authenticated(userId)
);
var result = await handler.Handle(
new MarkPaymentSentCommand(request.Id),
CancellationToken.None
);
Assert.False(result.IsSuccess);
Assert.Equal(BillingErrors.RequestNotAwaitingPayment, result.Error);
}
}
@@ -28,7 +28,10 @@ public class RequireActivationBehaviorTests
IsBlocked: false,
MaxConfigs: 3,
MaxIpLimit: RoleQuota.Unlimited,
SubscriptionToken: "sub-token"
SubscriptionToken: "sub-token",
BillingEnabled: false,
BillingPaidUntil: null,
BillingSuspended: false
);
[Fact]
@@ -47,7 +47,10 @@ public class GetMyConfigsQueryHandlerTests
false,
5,
RoleQuota.Unlimited,
"sub-token"
"sub-token",
false,
null,
false
);
_identityService.GetProfileAsync(userId, Arg.Any<CancellationToken>()).Returns(profile);
@@ -27,7 +27,10 @@ public class RotateVpnConfigCommandHandlerTests
false,
3,
RoleQuota.Unlimited,
"sub-token"
"sub-token",
false,
null,
false
);
[Fact]
@@ -25,7 +25,10 @@ public class AddTicketCommentCommandHandlerTests
IsBlocked: false,
MaxConfigs: 3,
MaxIpLimit: RoleQuota.Unlimited,
SubscriptionToken: "token"
SubscriptionToken: "token",
BillingEnabled: false,
BillingPaidUntil: null,
BillingSuspended: false
);
[Fact]
@@ -23,7 +23,7 @@ public class CreateRoleRequestTicketCommandHandlerTests
var currentUser = FakeCurrentUser.Authenticated(userId, "alice");
_roleService
.ListRolesAsync(Arg.Any<CancellationToken>())
.Returns(new List<RoleDto> { new(roleId, "premium", 5, 2, false) });
.Returns(new List<RoleDto> { new(roleId, "premium", 5, 2, false, false) });
var handler = new CreateRoleRequestTicketCommandHandler(
dbContext,
@@ -61,7 +61,7 @@ public class CreateRoleRequestTicketCommandHandlerTests
var currentUser = FakeCurrentUser.Authenticated(Guid.NewGuid());
_roleService
.ListRolesAsync(Arg.Any<CancellationToken>())
.Returns(new List<RoleDto> { new(roleId, "admin", -1, -1, true) });
.Returns(new List<RoleDto> { new(roleId, "admin", -1, -1, true, false) });
var handler = new CreateRoleRequestTicketCommandHandler(
dbContext,
@@ -21,7 +21,10 @@ public class ListSelectableRolesQueryHandlerTests
IsBlocked: false,
MaxConfigs: 3,
MaxIpLimit: 1,
SubscriptionToken: "token"
SubscriptionToken: "token",
BillingEnabled: false,
BillingPaidUntil: null,
BillingSuspended: false
);
[Fact]
@@ -37,9 +40,9 @@ public class ListSelectableRolesQueryHandlerTests
.Returns(
new List<RoleDto>
{
new(adminRoleId, "admin", -1, -1, IsSystem: true),
new(currentRoleId, "user", 3, 1, IsSystem: true),
new(extendedRoleId, "extended", 10, 5, IsSystem: false),
new(adminRoleId, "admin", -1, -1, IsSystem: true, BillingEnabled: false),
new(currentRoleId, "user", 3, 1, IsSystem: true, BillingEnabled: false),
new(extendedRoleId, "extended", 10, 5, IsSystem: false, BillingEnabled: false),
}
);
_identityService
@@ -71,8 +74,8 @@ public class ListSelectableRolesQueryHandlerTests
.Returns(
new List<RoleDto>
{
new(adminRoleId, "admin", -1, -1, IsSystem: true),
new(userRoleId, "user", 3, 1, IsSystem: true),
new(adminRoleId, "admin", -1, -1, IsSystem: true, BillingEnabled: false),
new(userRoleId, "user", 3, 1, IsSystem: true, BillingEnabled: false),
}
);
_identityService
@@ -98,7 +98,10 @@ public class GetLoginRequestStatusQueryHandlerTests
false,
3,
RoleQuota.Unlimited,
"sub-token"
"sub-token",
false,
null,
false
);
_identityService.GetProfileAsync(userId, Arg.Any<CancellationToken>()).Returns(profile);
_jwtTokenService
@@ -0,0 +1,101 @@
using PnvPanel.Domain.Billing;
using PnvPanel.Domain.Exceptions;
using Xunit;
namespace PnvPanel.Domain.Tests.Billing;
public class PaymentRequestTests
{
[Fact]
public void Create_StartsInAwaitingPayment()
{
var request = PaymentRequest.Create(Guid.NewGuid(), PaymentPeriod.Quarter, 1500);
Assert.Equal(PaymentRequestStatus.AwaitingPayment, request.Status);
Assert.Equal(1500, request.AmountSnapshot);
}
[Fact]
public void MarkPaymentSent_FromAwaitingPayment_MovesToAwaitingConfirmation()
{
var request = PaymentRequest.Create(Guid.NewGuid(), PaymentPeriod.Quarter, 1500);
request.MarkPaymentSent();
Assert.Equal(PaymentRequestStatus.AwaitingConfirmation, request.Status);
}
[Fact]
public void MarkPaymentSent_WhenAlreadySent_Throws()
{
var request = PaymentRequest.Create(Guid.NewGuid(), PaymentPeriod.Quarter, 1500);
request.MarkPaymentSent();
Assert.Throws<DomainException>(() => request.MarkPaymentSent());
}
[Fact]
public void Cancel_FromAwaitingPayment_MovesToCancelled()
{
var request = PaymentRequest.Create(Guid.NewGuid(), PaymentPeriod.Quarter, 1500);
request.Cancel();
Assert.Equal(PaymentRequestStatus.Cancelled, request.Status);
}
[Fact]
public void Cancel_AfterMarkPaymentSent_Throws()
{
var request = PaymentRequest.Create(Guid.NewGuid(), PaymentPeriod.Quarter, 1500);
request.MarkPaymentSent();
Assert.Throws<DomainException>(() => request.Cancel());
}
[Fact]
public void Confirm_FromAwaitingConfirmation_Succeeds()
{
var adminId = Guid.NewGuid();
var request = PaymentRequest.Create(Guid.NewGuid(), PaymentPeriod.Year, 5000);
request.MarkPaymentSent();
request.Confirm(adminId);
Assert.Equal(PaymentRequestStatus.Confirmed, request.Status);
Assert.Equal(adminId, request.DecidedBy);
Assert.NotNull(request.DecidedAt);
}
[Fact]
public void Confirm_FromAwaitingPayment_Succeeds()
{
// Админ мог увидеть оплату раньше, чем пользователь нажал "Я оплатил".
var request = PaymentRequest.Create(Guid.NewGuid(), PaymentPeriod.Year, 5000);
request.Confirm(Guid.NewGuid());
Assert.Equal(PaymentRequestStatus.Confirmed, request.Status);
}
[Fact]
public void Reject_FromAwaitingConfirmation_SetsReason()
{
var request = PaymentRequest.Create(Guid.NewGuid(), PaymentPeriod.HalfYear, 3000);
request.MarkPaymentSent();
request.Reject(Guid.NewGuid(), "Платёж не найден");
Assert.Equal(PaymentRequestStatus.Rejected, request.Status);
Assert.Equal("Платёж не найден", request.RejectionReason);
}
[Fact]
public void Confirm_WhenAlreadyDecided_Throws()
{
var request = PaymentRequest.Create(Guid.NewGuid(), PaymentPeriod.Quarter, 1500);
request.Cancel();
Assert.Throws<DomainException>(() => request.Confirm(Guid.NewGuid()));
}
}
@@ -137,6 +137,62 @@ public class VpnConfigTests
Assert.Equal(ConfigStatus.Revoked, config.Status);
}
[Fact]
public void Suspend_WhenActive_SetsExpired()
{
var config = VpnConfig.Create(Guid.NewGuid(), Guid.NewGuid(), VpnProtocol.Vless, null);
config.Suspend();
Assert.Equal(ConfigStatus.Expired, config.Status);
}
[Fact]
public void Suspend_WhenDisabledByAdmin_DoesNotOverrideBlock()
{
// Suspend (биллинг) не должен путать своё состояние с Disable (блокировка админом) — иначе
// Resume() ошибочно вернёт в Active конфиг, погашенный не за неуплату.
var config = VpnConfig.Create(Guid.NewGuid(), Guid.NewGuid(), VpnProtocol.Vless, null);
config.Disable();
config.Suspend();
Assert.Equal(ConfigStatus.Disabled, config.Status);
}
[Fact]
public void Resume_WhenExpired_ReturnsToActive()
{
var config = VpnConfig.Create(Guid.NewGuid(), Guid.NewGuid(), VpnProtocol.Vless, null);
config.Suspend();
config.Resume();
Assert.Equal(ConfigStatus.Active, config.Status);
}
[Fact]
public void Resume_WhenDisabledByAdmin_DoesNotResurrect()
{
var config = VpnConfig.Create(Guid.NewGuid(), Guid.NewGuid(), VpnProtocol.Vless, null);
config.Disable();
config.Resume();
Assert.Equal(ConfigStatus.Disabled, config.Status);
}
[Fact]
public void SetBillingExpiry_SetsExpiresAt()
{
var config = VpnConfig.Create(Guid.NewGuid(), Guid.NewGuid(), VpnProtocol.Vless, null);
var expiresAt = DateTimeOffset.UtcNow.AddMonths(3);
config.SetBillingExpiry(expiresAt);
Assert.Equal(expiresAt, config.ExpiresAt);
}
[Fact]
public void UpdateTraffic_SetsBytesAndLastSyncAt()
{
@@ -0,0 +1,86 @@
using System.Net;
using System.Net.Http.Json;
using PnvPanel.IntegrationTests.TestSupport;
using Xunit;
using static PnvPanel.IntegrationTests.TestSupport.AuthTestHelper;
namespace PnvPanel.IntegrationTests.Billing;
[Collection(IntegrationTestCollection.Name)]
public class BillingFlowTests(PnvPanelWebApplicationFactory factory)
{
private sealed record RoleResponse(Guid Id, string Name, bool BillingEnabled);
private sealed record ActivationRequestResponse(Guid Id);
private sealed record BillingStatusResponse(
bool BillingEnabled,
DateTimeOffset? PaidUntil,
bool Suspended,
string RequisitesText,
object? ActiveRequest
);
/// <summary>
/// Проверяет сквозной путь, недоступный unit-тестам (RoleService — Infrastructure/Identity):
/// назначение billing-роли автоматически выдаёт грейс-период, и он виден пользователю через
/// /api/billing/status.
/// </summary>
[Fact]
public async Task AssigningBillingRole_GrantsGracePeriod_VisibleInBillingStatus()
{
using var adminClient = factory.CreateClient();
var adminToken = await LoginAsAdminAsync(adminClient);
adminClient.UseBearerToken(adminToken);
var createRoleResponse = await adminClient.PostJsonAsync(
"/api/admin/roles",
new
{
name = $"billing_{Guid.NewGuid():N}"[..20],
maxConfigs = 5,
maxIpLimit = -1,
billingEnabled = true,
}
);
Assert.Equal(HttpStatusCode.OK, createRoleResponse.StatusCode);
var role = await createRoleResponse.ReadAsAsync<RoleResponse>();
Assert.True(role!.BillingEnabled);
using var userClient = factory.CreateClient();
var userName = $"bill_{Guid.NewGuid():N}"[..20];
var (userId, userToken) = await RegisterAndLoginAsync(userClient, userName, "P@ssw0rd123");
userClient.UseBearerToken(userToken);
var activationRequestResponse = await userClient.PostJsonAsync(
"/api/activation/request",
new { comment = (string?)null }
);
var activationRequest =
await activationRequestResponse.ReadAsAsync<ActivationRequestResponse>();
var approveActivationResponse = await adminClient.PostAsync(
$"/api/admin/activation-requests/{activationRequest!.Id}/approve",
content: null
);
Assert.Equal(HttpStatusCode.NoContent, approveActivationResponse.StatusCode);
var assignRoleResponse = await adminClient.PatchAsJsonAsync(
$"/api/admin/users/{userId}/role",
new { roleId = role.Id },
PnvPanel.IntegrationTests.TestSupport.HttpClientJsonExtensions.JsonOptions
);
Assert.Equal(HttpStatusCode.NoContent, assignRoleResponse.StatusCode);
var beforeCheck = DateTimeOffset.UtcNow;
var statusResponse = await userClient.GetAsync("/api/billing/status");
Assert.Equal(HttpStatusCode.OK, statusResponse.StatusCode);
var status = await statusResponse.ReadAsAsync<BillingStatusResponse>();
Assert.True(status!.BillingEnabled);
Assert.False(status.Suspended);
Assert.NotNull(status.PaidUntil);
// Дефолтный грейс — 7 дней (BillingSettings.DefaultGraceDays), пока админ не настроил своё.
Assert.True(status.PaidUntil > beforeCheck.AddDays(6));
Assert.True(status.PaidUntil < beforeCheck.AddDays(8));
}
}