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,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);
|
||||
|
||||
@@ -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>;
|
||||
+147
@@ -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));
|
||||
}
|
||||
}
|
||||
+13
@@ -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
|
||||
);
|
||||
+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(),
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
+16
@@ -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);
|
||||
}
|
||||
}
|
||||
+20
@@ -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 });
|
||||
}
|
||||
}
|
||||
Generated
+1040
File diff suppressed because it is too large
Load Diff
+105
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
+1043
File diff suppressed because it is too large
Load Diff
+29
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
+79
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user