Implement billing functionality and enhance role management
- 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:
@@ -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
|
||||
);
|
||||
+8
@@ -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;
|
||||
+37
@@ -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();
|
||||
}
|
||||
}
|
||||
+9
@@ -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;
|
||||
+64
@@ -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;
|
||||
+56
@@ -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;
|
||||
+45
@@ -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);
|
||||
}
|
||||
+10
@@ -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;
|
||||
+56
@@ -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(),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user