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,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(),
};
}